UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

831 lines 34.5 kB
/** * Sandbox Registry * * Manages registered agentic-sandbox instances. Each sandbox registers * with `aiwg serve` via POST /api/sandboxes/register and then pushes * real-time events over WebSocket at /ws/sandbox/:sandboxId. * * This is the AIWG side of the bidirectional integration: * - aiwg#731 = registration API (this file) * - sandbox#132 = outbound registration (pushes events here) * * @issue #731 * @see #710 — epic * @see #732 — HITL relay (consumes hitl.input_required events) * @see #733 — operator controls (proxies to sandbox HTTP) */ import { randomUUID } from 'crypto'; import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'fs'; import { homedir } from 'os'; import { dirname, isAbsolute, join, resolve as resolvePath } from 'path'; /** * Default identity store location — global, host-level. Preserved for * backward compatibility when `.aiwg/storage.config` doesn't redirect * `sandbox_identity` (#969). */ const DEFAULT_IDENTITY_STORE_PATH = join(homedir(), '.config', 'aiwg', 'sandbox-agents.json'); /** * Resolve the identity-store path, honoring `roots.sandbox_identity` * in `.aiwg/storage.config` when set. Sync read because the sandbox * registry constructor is sync and load happens at construction time. * * Backend support: only `fs` (or absent config) is currently supported * for the identity store — non-fs backends would require an async * adapter call which doesn't fit the sync constructor. Throws a clear * error if the user has configured a non-fs backend for this subsystem. * * Exported for testing — production callers omit `projectRootOverride` * to use `process.cwd()`. * * @issue #969 */ export function resolveIdentityStorePath(projectRootOverride) { // First check env var for tests / one-off overrides const envOverride = process.env['AIWG_SANDBOX_IDENTITY_STORE']; if (envOverride) return envOverride; // Try storage.config — sync read of project-local file const projectRoot = projectRootOverride ?? process.cwd(); const configPath = join(projectRoot, '.aiwg', 'storage.config'); if (!existsSync(configPath)) return DEFAULT_IDENTITY_STORE_PATH; try { const raw = readFileSync(configPath, 'utf-8'); const parsed = JSON.parse(raw); if (parsed.version !== '1') return DEFAULT_IDENTITY_STORE_PATH; // Refuse non-fs backends — sync constructor can't await an adapter const backendType = parsed.backends?.['sandbox_identity']?.type; if (backendType && backendType !== 'fs') { throw new Error(`sandbox-registry: backend "${backendType}" not supported for sandbox_identity ` + `(only fs supported in v1; sync load constraint). Configure roots.sandbox_identity ` + `to redirect within the fs backend, or remove the backends.sandbox_identity entry.`); } const override = parsed.roots?.['sandbox_identity']; if (override) { // Expand ~/ and resolve relative paths against project root let resolved = override; if (resolved.startsWith('~/')) resolved = join(homedir(), resolved.slice(2)); else if (resolved === '~') resolved = homedir(); else if (!isAbsolute(resolved)) resolved = resolvePath(projectRoot, resolved); // The override is a directory; the legacy file lives inside it return join(resolved, 'sandbox-agents.json'); } } catch (err) { // Throw on configured-but-unsupported, swallow on parse errors if (err instanceof Error && err.message.startsWith('sandbox-registry: backend')) { throw err; } // ignore — fall through to default } return DEFAULT_IDENTITY_STORE_PATH; } function loadIdentityStore() { const path = resolveIdentityStorePath(); try { if (existsSync(path)) { const data = JSON.parse(readFileSync(path, 'utf-8')); return new Map(data.map((r) => [r.instanceId, r])); } } catch { /* ignore parse/read errors */ } return new Map(); } /** * Save the identity store atomically: write to a temp file in the same * directory, then rename onto the live path. Prevents readers from * observing a half-written JSON file under concurrent SIGINT. */ function saveIdentityStore(store) { const path = resolveIdentityStorePath(); try { const dir = dirname(path); mkdirSync(dir, { recursive: true }); const tmp = `${path}.tmp.${process.pid}`; writeFileSync(tmp, JSON.stringify([...store.values()], null, 2), 'utf-8'); try { renameSync(tmp, path); } catch (err) { // Best-effort cleanup of the temp file try { unlinkSync(tmp); } catch { /* ignore */ } throw err; } } catch { /* ignore write errors — non-fatal per existing contract */ } } /** Convenience helper — returns true if the sandbox advertises a feature flag. */ export function sandboxHasFeature(reg, feature) { return reg.wsCapabilities?.features.includes(feature) ?? false; } /** Returns true if the sandbox supports a given client message type. */ export function sandboxSupports(reg, msg) { return reg.wsCapabilities?.supported_client_messages.includes(msg) ?? false; } /** Maximum number of samples kept per agent (#911). ~5 min at 5s interval. */ export const METRICS_HISTORY_MAX = 60; // ============================================================ // Event normalization (#933) // ============================================================ // // agentic-sandbox emits SandboxEvent over WS with Serde's // `rename_all = "snake_case"` applied to the enum tag and every field. // So the wire payload is: // // { "type": "agent_connected", "agent_id": "agent-01", // "agent_instance_id": "01HX...", "loadout": "base", ... } // // AIWG's registry switch is written in dot-notation + camelCase // (`agent.connected`, `event.agentId`). Before #933, only // `session_start`/`session_end` were normalized — every `agent_*` // and `hitl_*` event was silently dropped and agents never populated // the registry, which is why the dashboard reported "0 agents". // // normalizeSandboxEvent() maps snake_case event types to the dot // notation the switch expects and aliases snake_case top-level fields // onto their camelCase twins. Nested payloads (metrics, inventory // summaries) are left alone — their consumers already read snake_case // keys (e.g. metrics.cpu_percent, AgentManifestSummary.content_hash). /** Explicit snake_case → dot-notation map for SandboxEventType. */ const SNAKE_TO_DOT_EVENT_TYPE = { agent_connected: 'agent.connected', agent_disconnected: 'agent.disconnected', agent_ready: 'agent.ready', agent_provisioning: 'agent.provisioning', session_start: 'session.start', session_end: 'session.end', hitl_input_required: 'hitl.input_required', hitl_responded: 'hitl.responded', hitl_timed_out: 'hitl.timed_out', agent_inventory_updated: 'agent.inventory_updated', agent_metrics_updated: 'agent.metrics_updated', agent_provisioning_step: 'agent.provisioning_step', agent_provisioning_stalled: 'agent.provisioning_stalled', framework_update_available: 'framework.update_available', session_screen_updated: 'session.screen_updated', task_submitted: 'task.submitted', task_started: 'task.started', task_progressed: 'task.progressed', task_completed: 'task.completed', task_failed: 'task.failed', aiwg_log: 'aiwg.log', agent_sessions: 'agent.sessions', // sandbox#192 — full session inventory replace (#1155) }; /** Top-level field aliases from snake_case payloads → camelCase view. */ const SNAKE_TO_CAMEL_FIELDS = { agent_id: 'agentId', sandbox_id: 'sandboxId', session_id: 'sessionId', exit_code: 'exitCode', hitl_id: 'hitlId', agent_instance_id: 'agentInstanceId', agent_logical_name: 'agentLogicalName', aiwg_frameworks: 'aiwgFrameworks', agent_inventory: 'agentInventory', command_inventory: 'commandInventory', skill_inventory: 'skillInventory', step_index: 'stepIndex', total_steps: 'totalSteps', elapsed_seconds: 'elapsedSeconds', stalled_for_seconds: 'stalledForSeconds', current_version: 'currentVersion', available_version: 'availableVersion', days_behind: 'daysBehind', screen_hash: 'screenHash', changed_lines: 'changedLines', task_error: 'taskError', output_chunk: 'outputChunk', expires_at: 'expiresAt', }; /** * Normalize a raw sandbox event payload into the camelCase + dot-notation * shape `handleEvent` expects. Accepts payloads in either the legacy * snake_case or the newer dot/camelCase shape and is idempotent on the * latter, so it is safe to pipe every inbound event through this helper. */ export function normalizeSandboxEvent(raw) { if (!raw || typeof raw !== 'object') { // Malformed payload — callers discard anything that fails the switch. return { type: 'aiwg.log', sandboxId: '', agentId: '', timestamp: new Date().toISOString() }; } const src = raw; const rawType = typeof src.type === 'string' ? src.type : ''; const type = (SNAKE_TO_DOT_EVENT_TYPE[rawType] ?? rawType); // Copy every field onto the output, aliasing snake_case top-level keys // to their camelCase equivalents when the camelCase key is not already set. const out = { ...src, type }; for (const [snake, camel] of Object.entries(SNAKE_TO_CAMEL_FIELDS)) { if (snake in src && out[camel] === undefined) { out[camel] = src[snake]; } } // Ensure timestamp is always ISO string for downstream consumers. if (typeof out.timestamp !== 'string') { out.timestamp = new Date().toISOString(); } return out; } // ============================================================ // Registry // ============================================================ /** * Debounce window for re-registrations from the same instance_id. * Matches the sandbox's 5 s retry interval — suppressess flicker on rapid restarts. */ const DEBOUNCE_MS = 5_000; export class SandboxRegistry { sandboxes = new Map(); hitlRequests = new Map(); listeners = new Set(); /** instance_id → sandbox_id (stable reverse-lookup for sandbox upsert) */ byInstanceId = new Map(); /** instance_id → last registration timestamp (ms, for debounce) */ lastRegistrationTime = new Map(); /** Timer for HITL expiry checks (#908) */ expiryTimer = null; /** agentInstanceId → { sandboxId, agentId } — cross-restart lookup (#917) */ byAgentInstanceId = new Map(); /** logicalName → { sandboxId, agentId } — human-readable alias lookup (#917) */ byLogicalName = new Map(); /** Persistent store: agentInstanceId → identity record (#917) */ identityStore; constructor() { // Check for expired HITL requests every 60 seconds (#908) this.expiryTimer = setInterval(() => this.checkHitlExpiry(), 60_000); // Load persistent agent identity from disk (#917) this.identityStore = loadIdentityStore(); // Restore logical names from persisted store for (const record of this.identityStore.values()) { if (record.logicalName) { // Will be indexed properly when agent reconnects; logical names are // pre-loaded so aliasAgent() calls can reference them immediately. } } } checkHitlExpiry() { const now = new Date().toISOString(); for (const [hitlId, req] of this.hitlRequests) { if (req.expiresAt && req.expiresAt <= now) { this.hitlRequests.delete(hitlId); this.emit({ type: 'hitl.timed_out', sandboxId: req.sandboxId, agentId: req.agentId, timestamp: new Date().toISOString(), hitlId, sessionId: req.sessionId, }); } } } /** * Register a sandbox instance. * * When the request includes a stable `instance_id`: * - **Debounce**: if a registration for the same instance_id arrived within * DEBOUNCE_MS, return the existing sandbox_id + token without touching state. * - **Upsert**: if outside the debounce window, update the existing entry's * endpoints, version, and lastRegisteredAt in-place. The sandbox_id and token * are preserved so in-flight WS connections stay authenticated. * * When no instance_id is provided, a new entry is always created (legacy behaviour). */ register(req) { const instanceId = req.instance_id; const now = Date.now(); if (instanceId) { const lastTime = this.lastRegistrationTime.get(instanceId); const existingId = this.byInstanceId.get(instanceId); // Debounce: suppress rapid re-registrations within the window if (lastTime !== undefined && (now - lastTime) < DEBOUNCE_MS && existingId) { const existing = this.sandboxes.get(existingId); if (existing) { return { sandbox_id: existingId, token: existing.token }; } } // Upsert: same instance, update endpoints in-place if (existingId) { const existing = this.sandboxes.get(existingId); if (existing) { existing.name = req.name; existing.grpcEndpoint = req.grpc_endpoint; existing.wsEndpoint = req.ws_endpoint; existing.httpEndpoint = req.http_endpoint; existing.capabilities = req.capabilities ?? existing.capabilities; existing.version = req.version ?? existing.version; existing.lastRegisteredAt = new Date().toISOString(); existing.connected = false; // WS will update this when it (re-)connects // Refresh sandbox-level inventory if provided (#906) if (req.agent_inventory || req.command_inventory || req.skill_inventory) { existing.sandboxInventory = { agents: req.agent_inventory ?? existing.sandboxInventory?.agents ?? [], commands: req.command_inventory ?? existing.sandboxInventory?.commands ?? [], skills: req.skill_inventory ?? existing.sandboxInventory?.skills ?? [], last_updated: new Date().toISOString(), }; } // Refresh WS capabilities if provided (#912) if (req.ws_capabilities) { existing.wsCapabilities = req.ws_capabilities; } this.lastRegistrationTime.set(instanceId, now); return { sandbox_id: existingId, token: existing.token }; } } } // New registration (no instance_id, or first time seeing this instance_id) const id = `sandbox-${randomUUID().slice(0, 8)}`; const token = randomUUID(); const now_iso = new Date().toISOString(); const sandboxInventory = (req.agent_inventory || req.command_inventory || req.skill_inventory) ? { agents: req.agent_inventory ?? [], commands: req.command_inventory ?? [], skills: req.skill_inventory ?? [], last_updated: now_iso, } : undefined; const registration = { id, name: req.name, instanceId, grpcEndpoint: req.grpc_endpoint, wsEndpoint: req.ws_endpoint, httpEndpoint: req.http_endpoint, capabilities: req.capabilities ?? [], version: req.version ?? 'unknown', token, registeredAt: now_iso, lastRegisteredAt: now_iso, lastEventAt: now_iso, connected: false, agents: new Map(), sandboxInventory, wsCapabilities: req.ws_capabilities, }; this.sandboxes.set(id, registration); if (instanceId) { this.byInstanceId.set(instanceId, id); this.lastRegistrationTime.set(instanceId, now); } return { sandbox_id: id, token }; } /** * Remove all disconnected sandboxes from the registry. * Forces re-registration on next sandbox startup. * Returns the number of entries removed. */ clearOffline() { const toRemove = []; for (const [id, sandbox] of this.sandboxes) { if (!sandbox.connected) toRemove.push(id); } for (const id of toRemove) { this.deregister(id); } return toRemove.length; } /** * Deregister a sandbox (on shutdown or explicit delete). */ deregister(id) { const sandbox = this.sandboxes.get(id); if (sandbox?.instanceId) { this.byInstanceId.delete(sandbox.instanceId); this.lastRegistrationTime.delete(sandbox.instanceId); } return this.sandboxes.delete(id); } /** * Get a sandbox by ID. */ get(id) { return this.sandboxes.get(id); } /** * Validate the auth token for a sandbox. */ authenticate(id, token) { const sandbox = this.sandboxes.get(id); return sandbox !== undefined && sandbox.token === token; } /** * Mark the event push WebSocket as connected/disconnected. */ setConnected(id, connected) { const sandbox = this.sandboxes.get(id); if (!sandbox) return; sandbox.connected = connected; if (!connected) sandbox.disconnectedAt = new Date().toISOString(); } /** * List all registered sandboxes (serializable). */ list() { return [...this.sandboxes.values()].map(toSummary); } /** * Get a sandbox summary by ID (serializable). */ getSummary(id) { const sandbox = this.sandboxes.get(id); return sandbox ? toSummary(sandbox) : undefined; } /** * Process an event pushed from a sandbox. * Updates internal agent inventory and notifies listeners. */ handleEvent(event) { const sandbox = this.sandboxes.get(event.sandboxId); if (!sandbox) return; sandbox.lastEventAt = event.timestamp || new Date().toISOString(); // Normalize the two underscore variants the sandbox emits for session events. // Other event types (agent.connected, hitl.input_required, etc.) use dot notation // already and must not be altered. const rawType = event.type; const eventType = (rawType === 'session_start' ? 'session.start' : rawType === 'session_end' ? 'session.end' : rawType); // Update agent inventory based on event type switch (eventType) { case 'agent.connected': { // Resolve persistent logical name from identity store (#917) const agentInstanceId = event.agentInstanceId; let logicalName = event.agentLogicalName; if (agentInstanceId) { const record = this.identityStore.get(agentInstanceId); if (record) { logicalName = logicalName ?? record.logicalName; // Update record with current location record.lastAgentId = event.agentId; record.lastSandboxId = event.sandboxId; record.lastSeenAt = event.timestamp; if (!record.logicalName && logicalName) record.logicalName = logicalName; } else { this.identityStore.set(agentInstanceId, { instanceId: agentInstanceId, logicalName, lastAgentId: event.agentId, lastSandboxId: event.sandboxId, lastSeenAt: event.timestamp, }); } saveIdentityStore(this.identityStore); this.byAgentInstanceId.set(agentInstanceId, { sandboxId: event.sandboxId, agentId: event.agentId }); } if (logicalName) { this.byLogicalName.set(logicalName, { sandboxId: event.sandboxId, agentId: event.agentId }); } sandbox.agents.set(event.agentId, { agentId: event.agentId, status: 'ready', loadout: event.loadout, aiwgFrameworks: event.aiwgFrameworks, connectedAt: event.timestamp, lastHeartbeat: event.timestamp, instanceId: agentInstanceId, logicalName, }); break; } case 'agent.disconnected': { const agent = sandbox.agents.get(event.agentId); if (agent) agent.status = 'disconnected'; break; } case 'agent.provisioning': { sandbox.agents.set(event.agentId, { agentId: event.agentId, status: 'provisioning', loadout: event.loadout, lastHeartbeat: event.timestamp, }); break; } case 'agent.ready': { const existing = sandbox.agents.get(event.agentId); if (existing) { existing.status = 'ready'; existing.lastHeartbeat = event.timestamp; } break; } case 'session.start': { const agent = sandbox.agents.get(event.agentId); if (agent) { agent.status = 'busy'; agent.sessionCount = (agent.sessionCount ?? 0) + 1; agent.lastHeartbeat = event.timestamp; } break; } case 'session.end': { const agent = sandbox.agents.get(event.agentId); if (agent) { if (agent.status === 'busy') agent.status = 'ready'; if (agent.sessionCount) agent.sessionCount = Math.max(0, agent.sessionCount - 1); agent.lastHeartbeat = event.timestamp; } break; } case 'agent.sessions': { // Full inventory replace (#1151). The sandbox is authoritative for // session state — replacing wholesale (rather than merging) means a // missed session.start / session.end can't permanently desync the // count. sessionCount is also resynced from the new array length so // the legacy approximate-count consumers stay accurate. const agent = sandbox.agents.get(event.agentId); if (agent && Array.isArray(event.sessions)) { agent.sessions = event.sessions; agent.sessionCount = event.sessions.length; agent.lastHeartbeat = event.timestamp; } break; } case 'hitl.input_required': { if (event.hitlId && event.sessionId) { this.hitlRequests.set(event.hitlId, { id: event.hitlId, sandboxId: event.sandboxId, agentId: event.agentId, sessionId: event.sessionId, timestamp: event.timestamp, prompt: event.prompt ?? '', context: event.context ?? '', expiresAt: event.expiresAt, }); } break; } case 'agent.inventory_updated': { // Update the agent's manifest inventory (#906) const agent = sandbox.agents.get(event.agentId); if (agent && (event.agentInventory || event.commandInventory || event.skillInventory)) { agent.inventory = { agents: event.agentInventory ?? agent.inventory?.agents ?? [], commands: event.commandInventory ?? agent.inventory?.commands ?? [], skills: event.skillInventory ?? agent.inventory?.skills ?? [], last_updated: event.timestamp, }; agent.lastHeartbeat = event.timestamp; } break; } case 'agent.metrics_updated': { // Store latest metrics + rolling history (#911) if (event.metrics) { const agent = sandbox.agents.get(event.agentId); if (agent) { agent.latestMetrics = event.metrics; const sample = { cpu_percent: event.metrics.cpu_percent, memory_percent: event.metrics.memory_total_bytes > 0 ? (event.metrics.memory_used_bytes / event.metrics.memory_total_bytes) * 100 : 0, ts: event.metrics.ts, }; if (!agent.metricsHistory) agent.metricsHistory = []; agent.metricsHistory.push(sample); if (agent.metricsHistory.length > METRICS_HISTORY_MAX) { agent.metricsHistory = agent.metricsHistory.slice(-METRICS_HISTORY_MAX); } agent.lastHeartbeat = event.timestamp; } } break; } case 'agent.provisioning_step': { // Store current provisioning step (#911) const agent = sandbox.agents.get(event.agentId); if (agent && event.step) { agent.provisioningStep = { step: event.step, step_index: event.stepIndex, total_steps: event.totalSteps, elapsed_seconds: event.elapsedSeconds, ts: event.timestamp, }; agent.provisioningStalled = false; agent.lastHeartbeat = event.timestamp; } break; } case 'agent.provisioning_stalled': { // Mark provisioning as stalled (#911) const agent = sandbox.agents.get(event.agentId); if (agent) { agent.provisioningStalled = true; agent.lastHeartbeat = event.timestamp; } break; } // Task lifecycle events — no local state update, listeners handle display (#907) case 'task.submitted': case 'task.started': case 'task.progressed': case 'task.completed': case 'task.failed': // Pass-through events — no AIWG state to update (#910 #913 #914) case 'framework.update_available': case 'session.screen_updated': case 'aiwg.log': // Synthetic HITL events — emitted by aiwg serve, no state to update (#908) case 'hitl.responded': case 'hitl.timed_out': break; default: { // Unknown event type — likely a wire-protocol drift between // agentic-sandbox and AIWG. See #933 for the snake_case fix that // started tracking this. Log so future mismatches surface loudly // instead of silently dropping dashboard state. if (process.env.AIWG_SERVE_DEBUG === '1') { console.warn(`[sandbox-registry] unknown event type "${String(eventType)}" from sandbox ${event.sandboxId} — ignored. Payload keys:`, Object.keys(event)); } break; } } // Notify listeners (browser push, telemetry, etc.) this.emit(event); } /** * Emit a synthetic event to all listeners without modifying internal state. * Used by serve.ts to push server-side events (e.g. hitl.responded) to the browser. */ emit(event) { for (const listener of this.listeners) { try { listener(event); } catch { /* ignore listener errors */ } } } /** * Get all agents across all sandboxes. */ allAgents() { const result = []; for (const sandbox of this.sandboxes.values()) { for (const agent of sandbox.agents.values()) { result.push({ ...agent, sandboxId: sandbox.id, sandboxName: sandbox.name }); } } return result; } /** * Resolve an agent reference by agentId → instanceId → logicalName (#917). * Returns the sandbox registration and agent, or undefined if not found. */ resolveAgent(ref) { // 1. Direct agentId lookup for (const sandbox of this.sandboxes.values()) { const agent = sandbox.agents.get(ref); if (agent) return { sandbox, agent }; } // 2. agentInstanceId lookup const byInstance = this.byAgentInstanceId.get(ref); if (byInstance) { const sandbox = this.sandboxes.get(byInstance.sandboxId); const agent = sandbox?.agents.get(byInstance.agentId); if (sandbox && agent) return { sandbox, agent }; } // 3. logicalName lookup const byName = this.byLogicalName.get(ref); if (byName) { const sandbox = this.sandboxes.get(byName.sandboxId); const agent = sandbox?.agents.get(byName.agentId); if (sandbox && agent) return { sandbox, agent }; } return undefined; } /** * Assign a stable logical name to an agent (#917). * Persists the mapping to ~/.config/aiwg/sandbox-agents.json. */ aliasAgent(sandboxId, agentId, logicalName) { const sandbox = this.sandboxes.get(sandboxId); const agent = sandbox?.agents.get(agentId); if (!sandbox || !agent) return false; // Remove old logical name index if any if (agent.logicalName && agent.logicalName !== logicalName) { this.byLogicalName.delete(agent.logicalName); } agent.logicalName = logicalName; this.byLogicalName.set(logicalName, { sandboxId, agentId }); // Persist to store const instanceId = agent.instanceId; if (instanceId) { const record = this.identityStore.get(instanceId) ?? { instanceId, lastAgentId: agentId, lastSandboxId: sandboxId, lastSeenAt: new Date().toISOString(), }; record.logicalName = logicalName; this.identityStore.set(instanceId, record); saveIdentityStore(this.identityStore); } return true; } /** * List known agent identities from the persistent store (#917). */ knownAgentIdentities() { return [...this.identityStore.values()]; } // ---- HITL ---- /** * List pending HITL requests. */ pendingHitl() { return [...this.hitlRequests.values()]; } /** * Get a specific HITL request. */ getHitl(hitlId) { return this.hitlRequests.get(hitlId); } /** * Remove a HITL request (after response or dismissal). */ resolveHitl(hitlId) { const req = this.hitlRequests.get(hitlId); this.hitlRequests.delete(hitlId); return req; } // ---- Event subscription ---- /** * Subscribe to sandbox events (returns unsubscribe fn). */ subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } /** * Total registered sandbox count. */ get size() { return this.sandboxes.size; } /** * Shut down — clear all state and stop background timers. */ shutdown() { if (this.expiryTimer !== null) { clearInterval(this.expiryTimer); this.expiryTimer = null; } this.sandboxes.clear(); this.hitlRequests.clear(); this.listeners.clear(); this.byInstanceId.clear(); this.lastRegistrationTime.clear(); this.byAgentInstanceId.clear(); this.byLogicalName.clear(); } } function toSummary(s) { return { id: s.id, instanceId: s.instanceId, name: s.name, grpcEndpoint: s.grpcEndpoint, wsEndpoint: s.wsEndpoint, httpEndpoint: s.httpEndpoint, capabilities: s.capabilities, version: s.version, registeredAt: s.registeredAt, lastRegisteredAt: s.lastRegisteredAt, lastEventAt: s.lastEventAt, connected: s.connected, disconnectedAt: s.disconnectedAt, agentCount: s.agents.size, agents: [...s.agents.values()], sandboxInventory: s.sandboxInventory, wsCapabilities: s.wsCapabilities, }; } // Singleton instance export const sandboxRegistry = new SandboxRegistry(); //# sourceMappingURL=sandbox-registry.js.map