Files
GoClaw/server/routers.ts
bboxwtf a8a8ea1ee2 feat(swarm): autonomous agent containers, Swarm Manager with auto-stop, /nodes UI overhaul
## 1. Fix /nodes Swarm Status Display
- Add SwarmStatusBanner component: clear green/red/loading state
- Shows nodeId, managerAddr, isManager badge
- Error state explains what to check (docker.sock mount)
- Header now shows 'swarm unreachable — check gateway' vs 'active'
- swarmOk now checks nodeId presence, not just data existence

## 2. Autonomous Agent Container
- New docker/Dockerfile.agent — builds Go agent binary from gateway/cmd/agent/
- New gateway/cmd/agent/main.go — standalone HTTP microservice:
  * GET /health — liveness probe with idle time info
  * POST /task — receives task, forwards to Gateway orchestrator
  * GET /info  — agent metadata (id, hostname, gateway url)
  * Idle watchdog: calls /api/swarm/agents/{name}/stop after IdleTimeoutMinutes
  * Connects to Swarm overlay network (goclaw-net) → reaches DB/Gateway by DNS
  * Env: AGENT_ID, GATEWAY_URL, DATABASE_URL, IDLE_TIMEOUT_MINUTES

## 3. Swarm Manager Agent (auto-stop after 15min idle)
- New gateway/internal/api/swarm_manager.go:
  * SwarmManager goroutine checks every 60s
  * Scales idle GoClaw agent services to 0 replicas after 15 min
  * Tracks lastActivity from task UpdatedAt timestamps
- New REST endpoints in gateway:
  * GET  /api/swarm/agents           — list agents with idleMinutes
  * POST /api/swarm/agents/{name}/start — scale up agent
  * POST /api/swarm/agents/{name}/stop  — scale to 0
  * DELETE /api/swarm/services/{id}     — remove service permanently
- SwarmManager started as background goroutine in main.go with context cancel

## 4. Docker Client Enhancements
- Added NetworkAttachment type and Networks field to ServiceSpec
- CreateAgentServiceFull(opts) — supports overlay networks, custom labels
- CreateAgentService() delegates to CreateAgentServiceFull for backward compat
- RemoveService(id) — DELETE /v1.44/services/{id}
- GetServiceLastActivity(id) — finds latest task UpdatedAt for idle detection

## 5. tRPC & Gateway Proxy
- New functions: removeSwarmService, listSwarmAgents, startSwarmAgent, stopSwarmAgent
- SwarmAgentInfo type with idleMinutes, lastActivity, desiredReplicas
- createAgentService now accepts networks[] parameter
- New tRPC endpoints: nodes.removeService, nodes.listAgents, nodes.startAgent, nodes.stopAgent

## 6. Nodes.tsx UI Overhaul
- SwarmStatusBanner component at top — no more silent 'connecting…'
- New 'Agents' tab with AgentManagerRow: idle time, auto-stop warning, start/stop/remove buttons
- IdleColor coding: green < 5m, yellow 5-10m, red 10m+ with countdown to auto-stop
- ServiceRow: added Remove button with confirmation dialog
- RemoveConfirmDialog component
- DeployAgentDialog: added overlay networks field, default env includes GATEWAY_URL
- All queries refetch after agent start/stop/remove
2026-03-21 20:37:21 +00:00

1069 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { COOKIE_NAME } from "@shared/const";
import { z } from "zod";
import { getDb } from "./db";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, router, protectedProcedure } from "./_core/trpc";
import { checkOllamaHealth, listModels, chatCompletion } from "./ollama";
import {
checkGatewayHealth,
gatewayChat,
getGatewayOrchestratorConfig,
getGatewayModels,
getGatewayTools,
executeGatewayTool,
isGatewayAvailable,
getGatewayNodes,
getGatewayNodeStats,
startChatSession,
getChatSession,
getChatEvents,
listChatSessions,
getSwarmInfo,
listSwarmNodes,
listSwarmServices,
getServiceTasks,
scaleSwarmService,
getSwarmJoinToken,
execSwarmShell,
addSwarmNodeLabel,
setNodeAvailability,
createAgentService,
removeSwarmService,
listSwarmAgents,
startSwarmAgent,
stopSwarmAgent,
getOllamaModelInfo,
} from "./gateway-proxy";
// Shared system user id for non-authenticated agent management
const SYSTEM_USER_ID = 1;
export const appRouter = router({
system: systemRouter,
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
}),
/**
* LLM Providers — full CRUD backed by DB (llmProviders table).
* API keys stored encrypted with AES-256-GCM; never returned in plaintext to frontend.
*/
providers: router({
/** List all providers (keys masked). */
list: publicProcedure.query(async () => {
const { listProviders } = await import("./providers");
return listProviders();
}),
/** Create a new provider. */
create: publicProcedure
.input(z.object({
name: z.string().min(1),
baseUrl: z.string().url(),
apiKey: z.string(),
modelDefault: z.string().optional(),
notes: z.string().optional(),
setActive: z.boolean().default(false),
}))
.mutation(async ({ input }) => {
const { createProvider } = await import("./providers");
const id = await createProvider(input);
return { id };
}),
/** Update a provider (pass apiKey="" to keep existing key). */
update: publicProcedure
.input(z.object({
id: z.number(),
name: z.string().optional(),
baseUrl: z.string().url().optional(),
apiKey: z.string().optional(),
modelDefault: z.string().optional(),
notes: z.string().optional(),
isActive: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { updateProvider } = await import("./providers");
const { id, ...rest } = input;
await updateProvider(id, rest);
return { ok: true };
}),
/** Delete a provider (cannot delete the active one). */
delete: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { deleteProvider } = await import("./providers");
return deleteProvider(input.id);
}),
/** Activate a provider and signal the gateway to reload its config. */
activate: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { activateProvider } = await import("./providers");
await activateProvider(input.id);
return { ok: true };
}),
}),
/**
* System config — returns active LLM provider config (key masked).
* Used by Settings page and AgentDetailModal.
*/
config: router({
providers: publicProcedure.query(async () => {
const { listProviders, getActiveProvider } = await import("./providers");
// Try DB first
try {
const rows = await listProviders();
if (rows.length > 0) {
return {
providers: rows.map((p) => ({
id: p.id.toString(),
name: p.name,
baseUrl: p.baseUrl,
hasKey: !!p.apiKeyHint,
maskedKey: p.apiKeyHint ? `${p.apiKeyHint}${"*".repeat(24)}` : "",
isActive: p.isActive,
modelDefault: p.modelDefault,
})),
};
}
} catch { /* fallback below */ }
// Fallback: read from env
const { ENV } = await import("./_core/env");
const baseUrl = ENV.ollamaBaseUrl || "https://ollama.com/v1";
const apiKey = ENV.ollamaApiKey || "";
const hasKey = apiKey.length > 0;
const maskedKey = hasKey ? `${apiKey.slice(0, 8)}${"*".repeat(Math.max(0, apiKey.length - 8))}` : "";
let providerName = "Ollama Cloud";
if (baseUrl.includes("openai.com")) providerName = "OpenAI";
else if (baseUrl.includes("anthropic.com")) providerName = "Anthropic";
else if (baseUrl.includes("groq.com")) providerName = "Groq";
else if (baseUrl.includes("mistral.ai")) providerName = "Mistral";
else if (!baseUrl.includes("ollama.com")) providerName = "Custom";
return {
providers: [{ id: "primary", name: providerName, baseUrl, hasKey, maskedKey, isActive: true, modelDefault: null }],
};
}),
}),
/**
* Ollama API — серверный прокси для безопасного доступа
* Приоритет: Go Gateway → прямой Ollama
*/
ollama: router({
health: publicProcedure.query(async () => {
// Try Go Gateway first, fall back to direct Ollama
const gwHealth = await checkGatewayHealth();
if (gwHealth.connected) {
return {
connected: true as const,
latencyMs: gwHealth.latencyMs,
source: "gateway" as const,
llm: gwHealth.llm,
error: undefined as string | undefined,
};
}
// Fallback: direct Ollama
const ollamaHealth = await checkOllamaHealth();
return { ...ollamaHealth, source: "direct" as const };
}),
models: publicProcedure.query(async () => {
// Try Go Gateway first
const gwModels = await getGatewayModels();
if (gwModels) {
return {
success: true as const,
models: gwModels.data ?? [],
source: "gateway" as const,
};
}
// Fallback: direct Ollama
try {
const result = await listModels();
return {
success: true as const,
models: result.data ?? [],
source: "direct" as const,
};
} catch (err: any) {
return {
success: false as const,
models: [],
error: err.message,
};
}
}),
/** Fetch model details (context_length, family, quantization…) from Ollama /api/show */
modelInfo: publicProcedure
.input(z.object({ modelId: z.string() }))
.query(async ({ input }) => {
const info = await getOllamaModelInfo(input.modelId);
return info ?? { contextLength: 0 };
}),
chat: publicProcedure
.input(
z.object({
model: z.string(),
messages: z.array(
z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
})
),
temperature: z.number().optional(),
max_tokens: z.number().optional(),
})
)
.mutation(async ({ input }) => {
try {
const result = await chatCompletion(input.model, input.messages, {
temperature: input.temperature,
max_tokens: input.max_tokens,
});
return {
success: true as const,
response: result.choices[0]?.message?.content ?? "",
model: result.model,
usage: result.usage,
};
} catch (err: any) {
return {
success: false as const,
response: "",
error: err.message,
};
}
}),
}),
/**
* Agents — управление AI-агентами (public — внутренний инструмент)
*/
agents: router({
list: publicProcedure.query(async () => {
// getAllAgents returns both system agents (userId=0) and user-created agents
const { getAllAgents } = await import("./agents");
return getAllAgents();
}),
get: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const { getAgentById } = await import("./agents");
return getAgentById(input.id);
}),
create: publicProcedure
.input(
z.object({
name: z.string().min(1),
description: z.string().optional(),
role: z.string(),
model: z.string(),
provider: z.string(),
temperature: z.number().min(0).max(2).default(0.7),
maxTokens: z.number().default(2048),
topP: z.number().min(0).max(1).default(1.0),
frequencyPenalty: z.number().min(-2).max(2).default(0.0),
presencePenalty: z.number().min(-2).max(2).default(0.0),
systemPrompt: z.string().optional(),
allowedTools: z.array(z.string()).default([]),
allowedDomains: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
})
)
.mutation(async ({ input }) => {
const { createAgent } = await import("./agents");
return createAgent(SYSTEM_USER_ID, {
...input,
temperature: input.temperature.toString(),
topP: input.topP.toString(),
frequencyPenalty: input.frequencyPenalty.toString(),
presencePenalty: input.presencePenalty.toString(),
} as any);
}),
update: publicProcedure
.input(
z.object({
id: z.number(),
name: z.string().optional(),
description: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().optional(),
topP: z.number().min(0).max(1).optional(),
frequencyPenalty: z.number().min(-2).max(2).optional(),
presencePenalty: z.number().min(-2).max(2).optional(),
systemPrompt: z.string().optional(),
allowedTools: z.array(z.string()).optional(),
allowedDomains: z.array(z.string()).optional(),
isActive: z.boolean().optional(),
tags: z.array(z.string()).optional(),
})
)
.mutation(async ({ input }) => {
const { updateAgent } = await import("./agents");
const { id, temperature, topP, frequencyPenalty, presencePenalty, ...rest } = input;
const updates: Record<string, any> = { ...rest };
if (temperature !== undefined) updates.temperature = temperature.toString();
if (topP !== undefined) updates.topP = topP.toString();
if (frequencyPenalty !== undefined) updates.frequencyPenalty = frequencyPenalty.toString();
if (presencePenalty !== undefined) updates.presencePenalty = presencePenalty.toString();
return updateAgent(id, updates as any);
}),
delete: publicProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
const { deleteAgent } = await import("./agents");
return deleteAgent(input.id);
}),
stats: publicProcedure.input(z.object({ id: z.number(), hoursBack: z.number().default(24) })).query(async ({ input }) => {
const { getAgentStats } = await import("./agents");
return getAgentStats(input.id, input.hoursBack);
}),
metrics: publicProcedure.input(z.object({ id: z.number(), hoursBack: z.number().default(24) })).query(async ({ input }) => {
const { getAgentMetrics } = await import("./agents");
return getAgentMetrics(input.id, input.hoursBack);
}),
history: publicProcedure.input(z.object({ id: z.number(), limit: z.number().default(50) })).query(async ({ input }) => {
const { getAgentHistory } = await import("./agents");
return getAgentHistory(input.id, input.limit);
}),
accessControl: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const { getAgentAccessControl } = await import("./agents");
return getAgentAccessControl(input.id);
}),
updateToolAccess: publicProcedure
.input(
z.object({
agentId: z.number(),
tool: z.string(),
isAllowed: z.boolean(),
maxExecutionsPerHour: z.number().optional(),
timeoutSeconds: z.number().optional(),
allowedPatterns: z.array(z.string()).optional(),
blockedPatterns: z.array(z.string()).optional(),
})
)
.mutation(async ({ input }) => {
const { updateToolAccess } = await import("./agents");
const { agentId, ...updates } = input;
return updateToolAccess(agentId, input.tool, updates);
}),
/**
* Chat with a specific agent using its configuration
*/
chat: publicProcedure
.input(
z.object({
agentId: z.number(),
message: z.string(),
conversationId: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const { getAgentById, saveHistory } = await import("./agents");
const agent = await getAgentById(input.agentId);
if (!agent) {
return { success: false as const, response: "", error: "Agent not found" };
}
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
if (agent.systemPrompt) {
messages.push({ role: "system", content: agent.systemPrompt });
}
messages.push({ role: "user", content: input.message });
const startTime = Date.now();
try {
const result = await chatCompletion(
agent.model,
messages,
{
temperature: agent.temperature ? parseFloat(agent.temperature as string) : 0.7,
max_tokens: agent.maxTokens ?? 2048,
}
);
const processingTimeMs = Date.now() - startTime;
const response = result.choices[0]?.message?.content ?? "";
// Save to history
await saveHistory(input.agentId, {
userMessage: input.message,
agentResponse: response,
conversationId: input.conversationId,
status: "success",
});
// Save metric
const { saveMetric } = await import("./agents");
await saveMetric(input.agentId, {
userMessage: input.message,
agentResponse: response,
inputTokens: result.usage?.prompt_tokens ?? 0,
outputTokens: result.usage?.completion_tokens ?? 0,
totalTokens: result.usage?.total_tokens ?? 0,
processingTimeMs,
status: "success",
toolsCalled: [],
model: result.model ?? agent.model,
}).catch(() => {}); // non-fatal
return {
success: true as const,
response,
model: result.model,
usage: result.usage,
processingTimeMs,
};
} catch (err: any) {
const processingTimeMs = Date.now() - startTime;
await saveHistory(input.agentId, {
userMessage: input.message,
agentResponse: null,
conversationId: input.conversationId,
status: "error",
});
const { saveMetric } = await import("./agents");
saveMetric(input.agentId, {
userMessage: input.message,
processingTimeMs,
status: "error",
errorMessage: err.message,
toolsCalled: [],
model: agent.model,
}).catch(() => {}); // non-fatal
return {
success: false as const,
response: "",
error: err.message,
};
}
}),
}),
/**
* Tools — управление инструментами агентов
*/
tools: router({
list: publicProcedure.query(async () => {
const { getAllTools } = await import("./tools");
return getAllTools();
}),
execute: publicProcedure
.input(
z.object({
agentId: z.number(),
tool: z.string(),
params: z.record(z.string(), z.unknown()),
})
)
.mutation(async ({ input }) => {
const { executeTool } = await import("./tools");
return executeTool(input.agentId, input.tool, input.params);
}),
}),
/**
* Browser Agent — управление браузерными сессиями через Puppeteer
*/
browser: router({
createSession: publicProcedure
.input(z.object({ agentId: z.number() }))
.mutation(async ({ input }) => {
const { createBrowserSession } = await import("./browser-agent");
return createBrowserSession(input.agentId);
}),
execute: publicProcedure
.input(
z.object({
sessionId: z.string(),
action: z.object({
type: z.enum(["navigate", "click", "type", "extract", "screenshot", "scroll", "wait", "evaluate", "close"]),
params: z.record(z.string(), z.unknown()),
}),
})
)
.mutation(async ({ input }) => {
const { executeBrowserAction } = await import("./browser-agent");
return executeBrowserAction(input.sessionId, input.action as any);
}),
getSessions: publicProcedure
.input(z.object({ agentId: z.number() }))
.query(async ({ input }) => {
const { getAgentSessions } = await import("./browser-agent");
return getAgentSessions(input.agentId);
}),
closeSession: publicProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ input }) => {
const { executeBrowserAction } = await import("./browser-agent");
return executeBrowserAction(input.sessionId, { type: "close", params: {} });
}),
closeAllSessions: publicProcedure
.input(z.object({ agentId: z.number() }))
.mutation(async ({ input }) => {
const { closeAllAgentSessions } = await import("./browser-agent");
await closeAllAgentSessions(input.agentId);
return { success: true };
}),
}),
/**
* Tool Builder — генерация и установка новых инструментов через LLM
*/
toolBuilder: router({
generate: publicProcedure
.input(
z.object({
name: z.string().min(1),
description: z.string().min(10),
category: z.string().optional(),
exampleInput: z.string().optional(),
exampleOutput: z.string().optional(),
dangerous: z.boolean().optional(),
})
)
.mutation(async ({ input }) => {
const { generateTool } = await import("./tool-builder");
return generateTool(input);
}),
install: publicProcedure
.input(
z.object({
toolId: z.string(),
name: z.string(),
description: z.string(),
category: z.string(),
dangerous: z.boolean(),
parameters: z.record(z.string(), z.object({
type: z.string(),
description: z.string(),
required: z.boolean().optional(),
})),
implementation: z.string(),
})
)
.mutation(async ({ input }) => {
const { installTool } = await import("./tool-builder");
return installTool(input);
}),
listCustom: publicProcedure.query(async () => {
const { getCustomTools } = await import("./tool-builder");
return getCustomTools();
}),
delete: publicProcedure
.input(z.object({ toolId: z.string() }))
.mutation(async ({ input }) => {
const { deleteTool } = await import("./tool-builder");
return deleteTool(input.toolId);
}),
test: publicProcedure
.input(
z.object({
toolId: z.string(),
params: z.record(z.string(), z.unknown()),
})
)
.mutation(async ({ input }) => {
const { testTool } = await import("./tool-builder");
return testTool(input.toolId, input.params);
}),
}),
/**
* Agent Compiler — компиляция агентов по ТЗ через LLM
*/
agentCompiler: router({
compile: publicProcedure
.input(
z.object({
specification: z.string().min(20),
name: z.string().optional(),
preferredProvider: z.string().optional(),
preferredModel: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const { compileAgentConfig } = await import("./agent-compiler");
return compileAgentConfig({ ...input, userId: SYSTEM_USER_ID });
}),
deploy: publicProcedure
.input(
z.object({
config: z.object({
name: z.string(),
description: z.string(),
role: z.string(),
model: z.string(),
provider: z.string(),
temperature: z.number(),
maxTokens: z.number(),
topP: z.number(),
frequencyPenalty: z.number(),
presencePenalty: z.number(),
systemPrompt: z.string(),
allowedTools: z.array(z.string()),
allowedDomains: z.array(z.string()),
maxRequestsPerHour: z.number(),
tags: z.array(z.string()),
reasoning: z.string(),
}),
})
)
.mutation(async ({ input }) => {
const { deployCompiledAgent } = await import("./agent-compiler");
return deployCompiledAgent(input.config, SYSTEM_USER_ID);
}),
compileAndDeploy: publicProcedure
.input(
z.object({
specification: z.string().min(20),
name: z.string().optional(),
preferredProvider: z.string().optional(),
preferredModel: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const { compileAndDeployAgent } = await import("./agent-compiler");
return compileAndDeployAgent({ ...input, userId: SYSTEM_USER_ID });
}),
}),
/**
* Orchestrator — main AI agent with tool-use loop
* Приоритет: Go Gateway → Node.js fallback
*/
orchestrator: router({
// Get orchestrator config — Go Gateway first, then Node.js DB
getConfig: publicProcedure.query(async () => {
const gwConfig = await getGatewayOrchestratorConfig();
if (gwConfig) {
return { ...gwConfig, source: "gateway" as const };
}
// Fallback: Node.js orchestrator reads from DB directly
const { getOrchestratorConfig } = await import("./orchestrator");
const config = await getOrchestratorConfig();
return { ...config, source: "direct" as const };
}),
chat: publicProcedure
.input(
z.object({
messages: z.array(
z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
})
),
model: z.string().optional(),
maxIterations: z.number().min(1).max(20).optional(),
})
)
.mutation(async ({ input }) => {
// Try Go Gateway first (preferred — full Go tool-use loop)
const gwAvailable = await isGatewayAvailable();
if (gwAvailable) {
const result = await gatewayChat(
input.messages,
input.model,
input.maxIterations ?? 10
);
return { ...result, source: "gateway" as const };
}
// Fallback: Node.js orchestrator
const { orchestratorChat } = await import("./orchestrator");
const result = await orchestratorChat(
input.messages,
input.model,
input.maxIterations ?? 10
);
return { ...result, source: "direct" as const };
}),
// List available tools — Go Gateway first
tools: publicProcedure.query(async () => {
const gwTools = await getGatewayTools();
if (gwTools) {
return gwTools.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters,
source: "gateway" as const,
}));
}
// Fallback: Node.js tool definitions
const { ORCHESTRATOR_TOOLS } = await import("./orchestrator");
return ORCHESTRATOR_TOOLS.map((t) => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters,
source: "direct" as const,
}));
}),
// Gateway health check
gatewayHealth: publicProcedure.query(async () => {
return checkGatewayHealth();
}),
// Execute a single tool via Go Gateway
executeTool: publicProcedure
.input(
z.object({
tool: z.string(),
args: z.record(z.string(), z.unknown()),
})
)
.mutation(async ({ input }) => {
return executeGatewayTool(input.tool, input.args);
}),
// ── Persistent Background Chat Sessions ──────────────────────────────────
// These routes start a session on the Go Gateway and return immediately.
// The Go Gateway runs the orchestrator in a detached goroutine — survives
// HTTP disconnects, page reloads, and laptop sleep.
// The client polls getEvents until status === "done" | "error".
/** Start a background session. Returns { sessionId, status:"running" }. */
startSession: publicProcedure
.input(
z.object({
messages: z.array(
z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
})
),
sessionId: z.string(),
model: z.string().optional(),
maxIter: z.number().min(1).max(20).optional(),
})
)
.mutation(async ({ input }) => {
const result = await startChatSession(
input.messages,
input.sessionId,
input.model,
input.maxIter ?? 10
);
if (!result) throw new Error("Gateway unavailable — cannot start background session");
return result;
}),
/** Get session metadata (status, finalResponse, tokens, model…). */
getSession: publicProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ input }) => {
const sess = await getChatSession(input.sessionId);
if (!sess) throw new Error("Session not found");
return sess;
}),
/** Get events for a session after a given seq number (incremental polling). */
getEvents: publicProcedure
.input(
z.object({
sessionId: z.string(),
afterSeq: z.number().min(0).default(0),
})
)
.query(async ({ input }) => {
const result = await getChatEvents(input.sessionId, input.afterSeq);
if (!result) return { sessionId: input.sessionId, status: "unknown", events: [] };
return result;
}),
/** List recent sessions (default last 50). */
listSessions: publicProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(50) }))
.query(async ({ input }) => {
const result = await listChatSessions(input.limit);
return result?.sessions ?? [];
}),
}),
/**
* Dashboard — aggregated real-time stats for the top status bar
*/
dashboard: router({
/**
* Returns aggregated cluster stats:
* - uptime: server process uptime formatted as "Xd Yh Zm"
* - nodes: running container count from Docker
* - agents: active agent count from DB
* - cpu: average CPU% across all containers
* - mem: total RAM used in MB
*/
stats: publicProcedure.query(async () => {
// 1. Server uptime
const uptimeSec = Math.floor(process.uptime());
const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600);
const mins = Math.floor((uptimeSec % 3600) / 60);
const uptime = days > 0
? `${days}d ${hours}h ${mins}m`
: hours > 0
? `${hours}h ${mins}m`
: `${mins}m`;
// 2. Container / node stats from Go Gateway
const [nodesResult, statsResult] = await Promise.allSettled([
getGatewayNodes(),
getGatewayNodeStats(),
]);
const nodes = nodesResult.status === "fulfilled" && nodesResult.value
? nodesResult.value
: null;
const statsData = statsResult.status === "fulfilled" && statsResult.value
? statsResult.value
: null;
const containerCount = nodes?.containers?.length ?? nodes?.count ?? 0;
const totalContainers = nodes?.containers?.length ?? 0;
// CPU: average across all containers
const cpuPct = statsData?.stats?.length
? statsData.stats.reduce((sum, s) => sum + s.cpuPct, 0) / statsData.stats.length
: 0;
// MEM: total used MB
const memUseMB = statsData?.stats?.length
? statsData.stats.reduce((sum, s) => sum + s.memUseMB, 0)
: 0;
// 3. Active agents from DB
let activeAgents = 0;
try {
const db = await getDb();
if (db) {
const { agents } = await import("../drizzle/schema");
const { count: drizzleCount, eq } = await import("drizzle-orm");
const [{ value }] = await db
.select({ value: drizzleCount() })
.from(agents)
.where(eq(agents.isActive, true));
activeAgents = Number(value);
}
} catch {
// non-fatal
}
return {
uptime,
nodes: `${containerCount} / ${totalContainers || containerCount}`,
agents: activeAgents,
cpuPct: Math.round(cpuPct * 10) / 10,
memUseMB: Math.round(memUseMB),
gatewayOnline: !!nodes,
fetchedAt: new Date().toISOString(),
};
}),
}),
/**
* Nodes — Docker Swarm / standalone Docker monitoring via Go Gateway
*/
nodes: router({
/**
* Full Docker Swarm info: status, node count, manager address, join tokens.
*/
swarmInfo: publicProcedure.query(async () => {
return getSwarmInfo();
}),
/**
* List real Swarm nodes with live state, resources, labels.
* Falls back to the old gateway nodes endpoint if swarm API unavailable.
*/
list: publicProcedure.query(async () => {
// Try real Swarm API first
const swarm = await listSwarmNodes();
if (swarm) return { ...swarm, swarmActive: true, fetchedAt: new Date().toISOString() };
// Fallback: old gateway nodes
const result = await getGatewayNodes();
if (!result) {
return {
nodes: [] as import("./gateway-proxy").GatewayNodeInfo[],
count: 0,
swarmActive: false,
fetchedAt: new Date().toISOString(),
error: "Gateway unavailable — is the Go Gateway running?",
};
}
return result;
}),
/**
* List all Swarm services with replica counts and running task status.
*/
services: publicProcedure.query(async () => {
const result = await listSwarmServices();
return result ?? { services: [], count: 0 };
}),
/**
* Get all tasks for a specific service (where each replica is running).
*/
serviceTasks: publicProcedure
.input(z.object({ serviceId: z.string() }))
.query(async ({ input }) => {
const result = await getServiceTasks(input.serviceId);
return result ?? { tasks: [] };
}),
/**
* Scale a service to N replicas.
*/
scaleService: publicProcedure
.input(z.object({ serviceId: z.string(), replicas: z.number().min(0).max(100) }))
.mutation(async ({ input }) => {
const ok = await scaleSwarmService(input.serviceId, input.replicas);
return { ok };
}),
/**
* Get join token and command for adding a new node.
*/
joinToken: publicProcedure
.input(z.object({ role: z.enum(["worker", "manager"]).default("worker") }))
.query(async ({ input }) => {
return getSwarmJoinToken(input.role);
}),
/**
* Execute a shell command on the HOST system via nsenter.
* Requires gateway container to run with privileged: true + pid: host.
*/
execShell: publicProcedure
.input(z.object({ command: z.string().min(1).max(4096) }))
.mutation(async ({ input }) => {
const result = await execSwarmShell(input.command);
if (!result) throw new Error("Gateway unavailable");
return result;
}),
/**
* Add a label to a swarm node.
*/
addNodeLabel: publicProcedure
.input(z.object({ nodeId: z.string(), key: z.string(), value: z.string() }))
.mutation(async ({ input }) => {
const ok = await addSwarmNodeLabel(input.nodeId, input.key, input.value);
return { ok };
}),
/**
* Set node availability (active | pause | drain).
*/
setAvailability: publicProcedure
.input(z.object({ nodeId: z.string(), availability: z.enum(["active", "pause", "drain"]) }))
.mutation(async ({ input }) => {
const ok = await setNodeAvailability(input.nodeId, input.availability);
return { ok };
}),
/**
* Deploy an agent as a new Swarm service.
*/
deployAgentService: publicProcedure
.input(z.object({
name: z.string().min(1),
image: z.string().min(1),
replicas: z.number().min(1).max(20).default(1),
env: z.array(z.string()).optional(),
port: z.number().optional(),
networks: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const result = await createAgentService(input);
if (!result) throw new Error("Failed to create service");
return result;
}),
/**
* Remove (stop and delete) a Swarm service.
*/
removeService: publicProcedure
.input(z.object({ serviceId: z.string().min(1) }))
.mutation(async ({ input }) => {
const ok = await removeSwarmService(input.serviceId);
return { ok };
}),
/**
* List all GoClaw agent services with idle time info.
*/
listAgents: publicProcedure.query(async () => {
const result = await listSwarmAgents();
return result ?? { agents: [], count: 0 };
}),
/**
* Start (scale-up) an agent service by name.
*/
startAgent: publicProcedure
.input(z.object({ name: z.string().min(1), replicas: z.number().min(1).max(20).default(1) }))
.mutation(async ({ input }) => {
const ok = await startSwarmAgent(input.name, input.replicas);
return { ok };
}),
/**
* Stop (scale-to-0) an agent service by name.
*/
stopAgent: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ input }) => {
const ok = await stopSwarmAgent(input.name);
return { ok };
}),
/**
* Get live container stats (CPU%, RAM) for all running containers.
*/
stats: publicProcedure.query(async () => {
const result = await getGatewayNodeStats();
if (!result) {
return {
stats: [] as import("./gateway-proxy").GatewayContainerStat[],
count: 0,
fetchedAt: new Date().toISOString(),
error: "Gateway unavailable",
};
}
return result;
}),
}),
});
export type AppRouter = typeof appRouter;