import { createSignal, createMemo, batch } from "solid-js" import { resetSteps } from "./solo-store" import type { Instance, LogEntry } from "../types/instance" import type { LspStatus, Permission } from "@opencode-ai/sdk" import { sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" import { serverApi } from "../lib/api-client" import { serverEvents } from "../lib/server-events" import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } from "../../../server/src/api-types" import { ensureInstanceConfigLoaded } from "./instance-config" import { fetchSessions, fetchAgents, fetchProviders, clearInstanceDraftPrompts, } from "./sessions" import { fetchCommands, clearCommands } from "./commands" import { preferences } from "./preferences" import { setSessionPendingPermission } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForInstance } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" const log = getLogger("api") const [instances, setInstances] = createSignal>(new Map()) const [activeInstanceId, setActiveInstanceId] = createSignal(null) const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) // Permission queue management per instance const [permissionQueues, setPermissionQueues] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) const permissionSessionCounts = new Map>() function syncHasInstancesFlag() { const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready") setHasInstances(readyExists) } interface DisconnectedInstanceInfo { id: string folder: string reason: string } const [disconnectedInstance, setDisconnectedInstance] = createSignal(null) const MAX_LOG_ENTRIES = 1000 function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance { const existing = instances().get(descriptor.id) return { id: descriptor.id, folder: descriptor.path, port: descriptor.port ?? existing?.port ?? 0, pid: descriptor.pid ?? existing?.pid ?? 0, proxyPath: descriptor.proxyPath, status: descriptor.status, error: descriptor.error, client: existing?.client ?? null, metadata: existing?.metadata, binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath, binaryLabel: descriptor.binaryLabel, binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion, environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {}, } } function upsertWorkspace(descriptor: WorkspaceDescriptor) { const mapped = workspaceDescriptorToInstance(descriptor) if (instances().has(descriptor.id)) { updateInstance(descriptor.id, mapped) } else { addInstance(mapped) } if (descriptor.status === "ready") { attachClient(descriptor) } } function attachClient(descriptor: WorkspaceDescriptor) { const instance = instances().get(descriptor.id) if (!instance) return const nextPort = descriptor.port ?? instance.port const nextProxyPath = descriptor.proxyPath if (instance.client && instance.proxyPath === nextProxyPath) { if (nextPort && instance.port !== nextPort) { updateInstance(descriptor.id, { port: nextPort }) } return } if (instance.client) { sdkManager.destroyClient(descriptor.id) } const client = sdkManager.createClient(descriptor.id, nextProxyPath) updateInstance(descriptor.id, { client, port: nextPort ?? 0, proxyPath: nextProxyPath, status: "ready", }) sseManager.seedStatus(descriptor.id, "connecting") void hydrateInstanceData(descriptor.id).catch((error) => { log.error("Failed to hydrate instance data", error) }) } function releaseInstanceResources(instanceId: string) { const instance = instances().get(instanceId) if (!instance) return if (instance.client) { sdkManager.destroyClient(instanceId) } sseManager.seedStatus(instanceId, "disconnected") } async function hydrateInstanceData(instanceId: string) { try { await fetchSessions(instanceId) await fetchAgents(instanceId) await fetchProviders(instanceId) await ensureInstanceConfigLoaded(instanceId) const instance = instances().get(instanceId) if (!instance?.client) return await fetchCommands(instanceId, instance.client) } catch (error) { log.error("Failed to fetch initial data", error) } } void (async function initializeWorkspaces() { try { const workspaces = await serverApi.fetchWorkspaces() workspaces.forEach((workspace) => upsertWorkspace(workspace)) } catch (error) { log.error("Failed to load workspaces", error) } })() serverEvents.on("*", (event) => handleWorkspaceEvent(event)) function handleWorkspaceEvent(event: WorkspaceEventPayload) { switch (event.type) { case "workspace.created": upsertWorkspace(event.workspace) break case "workspace.started": upsertWorkspace(event.workspace) break case "workspace.error": upsertWorkspace(event.workspace) break case "workspace.stopped": releaseInstanceResources(event.workspaceId) removeInstance(event.workspaceId) break case "workspace.log": handleWorkspaceLog(event.entry) break default: break } } const logBuffer = new Map() let logFlushPending = false function flushLogs() { if (logBuffer.size === 0) { logFlushPending = false return } batch(() => { setInstanceLogs((prev) => { const next = new Map(prev) for (const [id, newEntries] of logBuffer) { const existing = next.get(id) ?? [] // Keep only last MAX_LOG_ENTRIES const combined = [...existing, ...newEntries] const updated = combined.slice(-MAX_LOG_ENTRIES) next.set(id, updated) } return next }) }) logBuffer.clear() logFlushPending = false } function handleWorkspaceLog(entry: WorkspaceLogEntry) { const logEntry: LogEntry = { timestamp: new Date(entry.timestamp).getTime(), level: (entry.level as LogEntry["level"]) ?? "info", message: entry.message, } // Only buffer if streaming is enabled for this instance, to save memory if (!isInstanceLogStreaming(entry.workspaceId)) { return } const currentBuffer = logBuffer.get(entry.workspaceId) ?? [] currentBuffer.push(logEntry) logBuffer.set(entry.workspaceId, currentBuffer) if (!logFlushPending) { logFlushPending = true setTimeout(flushLogs, 100) // Throttle updates to every 100ms } } function ensureLogContainer(id: string) { setInstanceLogs((prev) => { if (prev.has(id)) { return prev } const next = new Map(prev) next.set(id, []) return next }) } function ensureLogStreamingState(id: string) { setLogStreamingState((prev) => { if (prev.has(id)) { return prev } const next = new Map(prev) next.set(id, false) return next }) } function removeLogContainer(id: string) { setInstanceLogs((prev) => { if (!prev.has(id)) { return prev } const next = new Map(prev) next.delete(id) return next }) setLogStreamingState((prev) => { if (!prev.has(id)) { return prev } const next = new Map(prev) next.delete(id) return next }) } function getInstanceLogs(instanceId: string): LogEntry[] { return instanceLogs().get(instanceId) ?? [] } function isInstanceLogStreaming(instanceId: string): boolean { return logStreamingState().get(instanceId) ?? false } function setInstanceLogStreaming(instanceId: string, enabled: boolean) { ensureLogStreamingState(instanceId) setLogStreamingState((prev) => { const next = new Map(prev) next.set(instanceId, enabled) return next }) if (!enabled) { clearLogs(instanceId) } } function addInstance(instance: Instance) { setInstances((prev) => { const next = new Map(prev) next.set(instance.id, instance) return next }) ensureLogContainer(instance.id) ensureLogStreamingState(instance.id) resetSteps(instance.id) // Initialize SOLO steps syncHasInstancesFlag() } function updateInstance(id: string, updates: Partial) { setInstances((prev) => { const next = new Map(prev) const instance = next.get(id) if (instance) { next.set(id, { ...instance, ...updates }) } return next }) syncHasInstancesFlag() } function removeInstance(id: string) { let nextActiveId: string | null = null setInstances((prev) => { if (!prev.has(id)) { return prev } const keys = Array.from(prev.keys()) const index = keys.indexOf(id) const next = new Map(prev) next.delete(id) if (activeInstanceId() === id) { if (index > 0) { const prevKey = keys[index - 1] nextActiveId = prevKey ?? null } else { const remainingKeys = Array.from(next.keys()) nextActiveId = remainingKeys.length > 0 ? (remainingKeys[0] ?? null) : null } } return next }) removeLogContainer(id) clearCommands(id) clearPermissionQueue(id) clearInstanceMetadata(id) if (activeInstanceId() === id) { setActiveInstanceId(nextActiveId) } // Clean up session indexes and drafts for removed instance clearCacheForInstance(id) messageStoreBus.unregisterInstance(id) clearInstanceDraftPrompts(id) syncHasInstancesFlag() } async function createInstance(folder: string, _binaryPath?: string): Promise { try { const workspace = await serverApi.createWorkspace({ path: folder }) upsertWorkspace(workspace) setActiveInstanceId(workspace.id) return workspace.id } catch (error) { log.error("Failed to create workspace", error) throw error } } async function stopInstance(id: string) { const instance = instances().get(id) if (!instance) return releaseInstanceResources(id) try { await serverApi.deleteWorkspace(id) } catch (error) { log.error("Failed to stop workspace", error) } removeInstance(id) } async function fetchLspStatus(instanceId: string): Promise { const instance = instances().get(instanceId) if (!instance) { log.warn("[LSP] Skipping status fetch; instance not found", { instanceId }) return undefined } if (!instance.client) { log.warn("[LSP] Skipping status fetch; client not ready", { instanceId }) return undefined } const lsp = instance.client.lsp if (!lsp?.status) { log.warn("[LSP] Skipping status fetch; API unavailable", { instanceId }) return undefined } log.info("lsp.status", { instanceId }) const response = await lsp.status() return response.data ?? [] } function getActiveInstance(): Instance | null { const id = activeInstanceId() return id ? instances().get(id) || null : null } function addLog(id: string, entry: LogEntry) { if (!isInstanceLogStreaming(id)) { return } setInstanceLogs((prev) => { const next = new Map(prev) const existing = next.get(id) ?? [] const updated = existing.length >= MAX_LOG_ENTRIES ? [...existing.slice(1), entry] : [...existing, entry] next.set(id, updated) return next }) } function clearLogs(id: string) { setInstanceLogs((prev) => { if (!prev.has(id)) { return prev } const next = new Map(prev) next.set(id, []) return next }) } // Permission management functions function getPermissionQueue(instanceId: string): Permission[] { const queue = permissionQueues().get(instanceId) if (!queue) { return [] } return queue } function getPermissionQueueLength(instanceId: string): number { return getPermissionQueue(instanceId).length } function incrementSessionPendingCount(instanceId: string, sessionId: string): void { let sessionCounts = permissionSessionCounts.get(instanceId) if (!sessionCounts) { sessionCounts = new Map() permissionSessionCounts.set(instanceId, sessionCounts) } const current = sessionCounts.get(sessionId) ?? 0 sessionCounts.set(sessionId, current + 1) } function decrementSessionPendingCount(instanceId: string, sessionId: string): number { const sessionCounts = permissionSessionCounts.get(instanceId) if (!sessionCounts) return 0 const current = sessionCounts.get(sessionId) ?? 0 if (current <= 1) { sessionCounts.delete(sessionId) if (sessionCounts.size === 0) { permissionSessionCounts.delete(instanceId) } return 0 } const nextValue = current - 1 sessionCounts.set(sessionId, nextValue) return nextValue } function clearSessionPendingCounts(instanceId: string): void { const sessionCounts = permissionSessionCounts.get(instanceId) if (!sessionCounts) return for (const sessionId of sessionCounts.keys()) { setSessionPendingPermission(instanceId, sessionId, false) } permissionSessionCounts.delete(instanceId) } function addPermissionToQueue(instanceId: string, permission: Permission): void { let inserted = false setPermissionQueues((prev) => { const next = new Map(prev) const queue = next.get(instanceId) ?? [] if (queue.some((p) => p.id === permission.id)) { return next } const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created) next.set(instanceId, updatedQueue) inserted = true return next }) if (!inserted) { return } setActivePermissionId((prev) => { const next = new Map(prev) if (!next.get(instanceId)) { next.set(instanceId, permission.id) } return next }) const sessionId = getPermissionSessionId(permission) incrementSessionPendingCount(instanceId, sessionId) setSessionPendingPermission(instanceId, sessionId, true) } function removePermissionFromQueue(instanceId: string, permissionId: string): void { let removedPermission: Permission | null = null setPermissionQueues((prev) => { const next = new Map(prev) const queue = next.get(instanceId) ?? [] const filtered: Permission[] = [] for (const item of queue) { if (item.id === permissionId) { removedPermission = item continue } filtered.push(item) } if (filtered.length > 0) { next.set(instanceId, filtered) } else { next.delete(instanceId) } return next }) const updatedQueue = getPermissionQueue(instanceId) setActivePermissionId((prev) => { const next = new Map(prev) const activeId = next.get(instanceId) if (activeId === permissionId) { const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as Permission) : null next.set(instanceId, nextPermission?.id ?? null) } return next }) const removed = removedPermission if (removed) { const removedSessionId = getPermissionSessionId(removed) const remaining = decrementSessionPendingCount(instanceId, removedSessionId) setSessionPendingPermission(instanceId, removedSessionId, remaining > 0) } } function clearPermissionQueue(instanceId: string): void { setPermissionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) return next }) setActivePermissionId((prev) => { const next = new Map(prev) next.delete(instanceId) return next }) clearSessionPendingCounts(instanceId) } function getPermissionSessionId(permission: Permission): string { return (permission as any).sessionID } async function sendPermissionResponse( instanceId: string, sessionId: string, permissionId: string, response: "once" | "always" | "reject" ): Promise { const instance = instances().get(instanceId) if (!instance?.client) { throw new Error("Instance not ready") } try { await instance.client.postSessionIdPermissionsPermissionId({ path: { id: sessionId, permissionID: permissionId }, body: { response }, }) // Remove from queue after successful response removePermissionFromQueue(instanceId, permissionId) } catch (error) { log.error("Failed to send permission response", error) throw error } } sseManager.onConnectionLost = (instanceId, reason) => { const instance = instances().get(instanceId) if (!instance) { return } setDisconnectedInstance({ id: instanceId, folder: instance.folder, reason, }) } sseManager.onLspUpdated = async (instanceId) => { log.info("lsp.updated", { instanceId }) try { const lspStatus = await fetchLspStatus(instanceId) if (!lspStatus) { return } mergeInstanceMetadata(instanceId, { lspStatus }) } catch (error) { log.error("Failed to refresh LSP status", error) } } async function acknowledgeDisconnectedInstance(): Promise { const pending = disconnectedInstance() if (!pending) { return } try { await stopInstance(pending.id) } catch (error) { log.error("Failed to stop disconnected instance", error) } finally { setDisconnectedInstance(null) } } export { instances, activeInstanceId, setActiveInstanceId, addInstance, updateInstance, removeInstance, createInstance, stopInstance, getActiveInstance, addLog, clearLogs, instanceLogs, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming, // Permission management permissionQueues, activePermissionId, getPermissionQueue, getPermissionQueueLength, addPermissionToQueue, removePermissionFromQueue, clearPermissionQueue, sendPermissionResponse, disconnectedInstance, acknowledgeDisconnectedInstance, fetchLspStatus, }