/** * GoClaw Gateway Proxy * * Forwards orchestrator/agent/tool requests from the Node.js tRPC server * to the Go Gateway running on :18789. * * The Go Gateway handles: * - LLM orchestration (tool-use loop) * - Tool execution (shell, file, docker, http) * - Agent listing from DB * - Model listing from LLM provider * * When GATEWAY_URL is not set or the gateway is unreachable, all functions * return a structured error so callers can fall back gracefully. */ const GATEWAY_BASE_URL = process.env.GATEWAY_URL ?? "http://localhost:18789"; const GATEWAY_TIMEOUT_MS = 180_000; // 3 min — LLM can be slow const QUICK_TIMEOUT_MS = 5_000; // ─── Types ──────────────────────────────────────────────────────────────────── export interface GatewayMessage { role: "user" | "assistant" | "system"; content: string; } export interface GatewayToolCallStep { tool: string; args: Record; result: unknown; // required — matches ToolCallStep interface in Chat.tsx / Skills.tsx error?: string; success: boolean; durationMs: number; } export interface GatewayChatResult { success: boolean; response: string; toolCalls: GatewayToolCallStep[]; model?: string; modelWarning?: string; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; error?: string; } export interface GatewayOrchestratorConfig { id: number | null; name: string; model: string; temperature: number; maxTokens: number; allowedTools: string[]; systemPromptPreview: string; } export interface GatewayToolDef { name: string; description: string; parameters: Record; } export interface GatewayToolResult { success: boolean; output?: unknown; error?: string; durationMs: number; } export interface GatewayHealthResult { connected: boolean; latencyMs: number; llm?: { connected: boolean; latencyMs: number }; error?: string; } // ─── Health ─────────────────────────────────────────────────────────────────── /** * Check if the Go Gateway is running and healthy. * Also returns LLM provider health (Ollama / cloud API). */ export async function checkGatewayHealth(): Promise { const start = Date.now(); try { const res = await fetch(`${GATEWAY_BASE_URL}/health`, { signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); const latencyMs = Date.now() - start; if (res.ok) { const data = await res.json(); return { connected: true, latencyMs, // Go Gateway may return "ollama" or "llm" key depending on version llm: data.llm ?? data.ollama, }; } return { connected: false, latencyMs, error: `HTTP ${res.status}` }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); return { connected: false, latencyMs: Date.now() - start, error: msg }; } } /** * Returns true if the Go Gateway is reachable (fast check, no LLM ping). */ export async function isGatewayAvailable(): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/health`, { signal: AbortSignal.timeout(2_000), }); return res.ok; } catch { return false; } } // ─── Model Info ─────────────────────────────────────────────────────────────── export interface OllamaModelInfo { contextLength: number; parameterSize?: string; family?: string; quantization?: string; capabilities?: string[]; } /** * Fetch model details from Ollama /api/show (context_length, parameters, etc.) * Uses the base URL and API key from environment. */ export async function getOllamaModelInfo(modelId: string): Promise { const baseUrl = (process.env.OLLAMA_BASE_URL ?? "https://ollama.com/v1").replace(/\/v1\/?$/, ""); const apiKey = process.env.OLLAMA_API_KEY ?? ""; try { const headers: Record = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; const res = await fetch(`${baseUrl}/api/show`, { method: "POST", headers, body: JSON.stringify({ model: modelId }), signal: AbortSignal.timeout(10_000), }); if (!res.ok) return null; const data = await res.json(); // context_length is under model_info with key "{arch}.context_length" let contextLength = 0; if (data.model_info) { for (const [k, v] of Object.entries(data.model_info)) { if (k.endsWith(".context_length") && typeof v === "number") { contextLength = v; break; } } } return { contextLength, parameterSize: data.details?.parameter_size, family: data.details?.family, quantization: data.details?.quantization_level, capabilities: data.capabilities, }; } catch { return null; } } // ─── Orchestrator ───────────────────────────────────────────────────────────── /** * Send a chat message to the Go Orchestrator. * Includes full tool-use loop — the Go side handles all LLM ↔ tool iterations. */ export async function gatewayChat( messages: GatewayMessage[], model?: string, maxIter?: number ): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/orchestrator/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages, model, maxIter }), signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS), }); if (!res.ok) { const text = await res.text(); return { success: false, response: "", toolCalls: [], error: `Gateway error (${res.status}): ${text}`, }; } return res.json(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); return { success: false, response: "", toolCalls: [], error: `Gateway unreachable: ${msg}. Is the Go Gateway running on ${GATEWAY_BASE_URL}?`, }; } } /** * Get orchestrator config from Go Gateway (reads from DB). */ export async function getGatewayOrchestratorConfig(): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/orchestrator/config`, { signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); if (!res.ok) return null; return res.json(); } catch { return null; } } // ─── Models ─────────────────────────────────────────────────────────────────── /** * Get list of models from Go Gateway (proxied from LLM provider). */ export async function getGatewayModels(): Promise<{ data: { id: string }[] } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/models`, { signal: AbortSignal.timeout(10_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } // ─── Agents ─────────────────────────────────────────────────────────────────── /** * Get list of agents from Go Gateway (reads from DB). */ export async function getGatewayAgents(): Promise<{ agents: unknown[]; count: number } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/agents`, { signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** * Get a single agent by ID from Go Gateway. */ export async function getGatewayAgent(id: number): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/agents/${id}`, { signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); if (!res.ok) return null; return res.json(); } catch { return null; } } // ─── Tools ──────────────────────────────────────────────────────────────────── /** * Get list of available tools from Go Gateway. */ export async function getGatewayTools(): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/tools`, { signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); if (!res.ok) return null; const data = await res.json(); // Go returns OpenAI format: { tools: [{type: "function", function: {name, description, parameters}}, ...], count: N } if (!Array.isArray(data.tools)) return null; return data.tools.map((t: { type?: string; function?: { name: string; description: string; parameters: Record }; name?: string; description?: string; parameters?: Record }) => { // Handle OpenAI format: {type: "function", function: {name, ...}} if (t.function) { return { name: t.function.name, description: t.function.description, parameters: t.function.parameters, } as GatewayToolDef; } // Handle flat format: {name, description, parameters} return t as GatewayToolDef; }); } catch { return null; } } /** * Execute a single tool via Go Gateway. */ export async function executeGatewayTool( toolName: string, args: Record ): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/tools/execute`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tool: toolName, args }), signal: AbortSignal.timeout(30_000), }); if (!res.ok) { const text = await res.text(); return { success: false, error: `Gateway error (${res.status}): ${text}`, durationMs: 0, }; } return res.json(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); return { success: false, error: `Gateway unreachable: ${msg}`, durationMs: 0, }; } } // ─── Nodes / Docker Swarm ───────────────────────────────────────────────────── export interface GatewayNodeInfo { id: string; hostname: string; role: string; status: string; availability: string; ip: string; os: string; arch: string; cpuCores: number; memTotalMB: number; dockerVersion: string; isLeader: boolean; managerAddr?: string; labels: Record; updatedAt: string; } export interface GatewayContainerInfo { id: string; name: string; image: string; state: string; status: string; } export interface GatewayNodesResult { nodes: GatewayNodeInfo[]; count: number; swarmActive: boolean; managers?: number; totalNodes?: number; containers?: GatewayContainerInfo[]; fetchedAt: string; } export interface GatewayContainerStat { id: string; name: string; cpuPct: number; memUseMB: number; memLimMB: number; memPct: number; } export interface GatewayNodeStatsResult { stats: GatewayContainerStat[]; count: number; fetchedAt: string; } /** * Get Docker Swarm nodes (or standalone Docker host) from Go Gateway. */ export async function getGatewayNodes(): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/nodes`, { signal: AbortSignal.timeout(10_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** * Get live container CPU/RAM stats from Go Gateway. */ export async function getGatewayNodeStats(): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/nodes/stats`, { signal: AbortSignal.timeout(15_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } // ─── Persistent Chat Sessions ───────────────────────────────────────────────── export interface GatewayChatEvent { id: number; sessionId: string; seq: number; eventType: "thinking" | "tool_call" | "delta" | "done" | "error"; content: string; toolName: string; toolArgs: string; // JSON string toolResult: string; toolSuccess: boolean; durationMs: number; model: string; usageJson: string; // JSON string errorMsg: string; createdAt: string; } export interface GatewayChatSession { id: number; sessionId: string; agentId: number; status: "running" | "done" | "error"; userMessage: string; finalResponse: string; model: string; totalTokens: number; processingTimeMs: number; errorMessage: string; createdAt: string; updatedAt: string; } /** * Start a persistent background chat session. * Returns the sessionId immediately; processing continues on the server. */ export async function startChatSession( messages: GatewayMessage[], sessionId: string, model?: string, maxIter = 10 ): Promise<{ sessionId: string; status: string } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/chat/session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages, sessionId, model, maxIter }), signal: AbortSignal.timeout(10_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** * Get session metadata (status, finalResponse, tokens…). */ export async function getChatSession(sessionId: string): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/chat/session/${sessionId}`, { signal: AbortSignal.timeout(5_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** * Fetch events for a session with seq > afterSeq. * Returns { sessionId, status, events[] }. */ export async function getChatEvents( sessionId: string, afterSeq = 0 ): Promise<{ sessionId: string; status: string; events: GatewayChatEvent[] } | null> { try { const res = await fetch( `${GATEWAY_BASE_URL}/api/chat/session/${sessionId}/events?after=${afterSeq}`, { signal: AbortSignal.timeout(5_000) } ); if (!res.ok) return null; return res.json(); } catch { return null; } } /** * List recent sessions (default last 50). */ export async function listChatSessions( limit = 50 ): Promise<{ sessions: GatewayChatSession[] } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/chat/sessions?limit=${limit}`, { signal: AbortSignal.timeout(5_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } // ─── Real Docker Swarm API ──────────────────────────────────────────────────── export interface SwarmNodeInfo { id: string; hostname: string; role: "manager" | "worker"; state: string; availability: string; ip: string; os: string; arch: string; cpuCores: number; memTotalMB: number; dockerVersion: string; isLeader: boolean; managerAddr?: string; labels: Record; updatedAt: string; } export interface SwarmServiceInfo { id: string; name: string; image: string; mode: "replicated" | "global"; desiredReplicas: number; runningTasks: number; desiredTasks: number; labels: Record | null; updatedAt: string; ports: string[] | null; isGoClaw: boolean; } export interface SwarmTaskInfo { id: string; serviceId: string; nodeId: string; slot: number; state: string; message: string; containerId: string; updatedAt: string; } export interface SwarmInfoResult { nodeId: string; localNodeState: string; isManager: boolean; managers: number; nodes: number; managerAddr: string; joinTokens?: { worker: string; manager: string }; } export interface JoinTokenResult { role: string; token: string; managerAddr: string; joinCommand: string; } /** Get overall swarm state + join tokens */ export async function getSwarmInfo(): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/info`, { signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** List all swarm nodes with live status */ export async function listSwarmNodes(): Promise<{ nodes: SwarmNodeInfo[]; count: number } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/nodes`, { signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** List all swarm services */ export async function listSwarmServices(): Promise<{ services: SwarmServiceInfo[]; count: number } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services`, { signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); if (!res.ok) return null; const data = await res.json(); // Normalise null fields so the frontend never has to worry const services: SwarmServiceInfo[] = (data.services ?? []).map((s: any) => ({ ...s, ports: s.ports ?? [], labels: s.labels ?? {}, })); return { services, count: services.length }; } catch { return null; } } /** Get tasks for a specific service */ export async function getServiceTasks(serviceId: string): Promise<{ tasks: SwarmTaskInfo[] } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services/${serviceId}/tasks`, { signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** Scale a service to N replicas */ export async function scaleSwarmService(serviceId: string, replicas: number): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services/${serviceId}/scale`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ replicas }), signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); return res.ok; } catch { return false; } } /** Get join token and command */ export async function getSwarmJoinToken(role: "worker" | "manager"): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/join-token?role=${role}`, { signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** Execute a shell command on the host system */ export async function execSwarmShell(command: string): Promise<{ output: string; success: boolean; error?: string } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/shell`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command }), signal: AbortSignal.timeout(35_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } export interface JoinNodeResult { ok: boolean; output?: string; error?: string; step?: string; note?: string; host?: string; role?: string; command?: string; } /** * SSH into a remote host and run "docker swarm join ..." to add it to the cluster. * The gateway fetches the current join token automatically. */ export async function joinSwarmNodeViaSSH(opts: { host: string; port?: number; user: string; password: string; role?: "worker" | "manager"; }): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/join-node`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts), signal: AbortSignal.timeout(30_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** * Test SSH connectivity and Docker availability on a remote host (no swarm join). */ export async function testSSHConnection(opts: { host: string; port?: number; user: string; password: string; }): Promise<{ ok: boolean; sshOk?: boolean; dockerOk?: boolean; dockerVersion?: string; error?: string; step?: string } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/ssh-test`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts), signal: AbortSignal.timeout(20_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** Add a label to a swarm node */ export async function addSwarmNodeLabel(nodeId: string, key: string, value: string): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/nodes/${nodeId}/label`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }), signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); return res.ok; } catch { return false; } } /** Set node availability (active|pause|drain) */ export async function setNodeAvailability(nodeId: string, availability: "active" | "pause" | "drain"): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/nodes/${nodeId}/availability`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ availability }), signal: AbortSignal.timeout(QUICK_TIMEOUT_MS), }); return res.ok; } catch { return false; } } /** Deploy a new agent as a Swarm service */ export async function createAgentService(opts: { name: string; image: string; replicas: number; env?: string[]; port?: number; networks?: string[]; }): Promise<{ ok: boolean; serviceId?: string; name?: string } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts), signal: AbortSignal.timeout(15_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** Remove (stop) a Swarm service by ID or name */ export async function removeSwarmService(serviceId: string): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services/${encodeURIComponent(serviceId)}`, { method: "DELETE", signal: AbortSignal.timeout(10_000), }); return res.ok; } catch { return false; } } export interface SwarmAgentInfo { id: string; name: string; image: string; desiredReplicas: number; runningTasks: number; lastActivity: string; idleMinutes: number; isGoClaw: boolean; } /** List all GoClaw agent services with idle time info */ export async function listSwarmAgents(): Promise<{ agents: SwarmAgentInfo[]; count: number } | null> { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/agents`, { signal: AbortSignal.timeout(10_000), }); if (!res.ok) return null; return res.json(); } catch { return null; } } /** Start (scale-up) an agent service */ export async function startSwarmAgent(name: string, replicas = 1): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/agents/${encodeURIComponent(name)}/start`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ replicas }), signal: AbortSignal.timeout(10_000), }); return res.ok; } catch { return false; } } /** Stop (scale-to-0) an agent service */ export async function stopSwarmAgent(name: string): Promise { try { const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/agents/${encodeURIComponent(name)}/stop`, { method: "POST", signal: AbortSignal.timeout(10_000), }); return res.ok; } catch { return false; } }