COMPLETED FEATURES: 1. Database Schema (drizzle/schema.ts) - Added tasks table with 14 columns - Status enum: pending, in_progress, completed, failed, blocked - Priority enum: low, medium, high, critical - Supports task dependencies, metadata, error tracking - Indexed by agentId, status, conversationId 2. Query Helpers (server/db.ts) - createTask() - create new task - getAgentTasks() - get all agent tasks - getConversationTasks() - get conversation tasks - getTaskById() - get single task - updateTask() - update task status/results - deleteTask() - delete task - getPendingAgentTasks() - get active tasks with priority sorting 3. tRPC Endpoints (server/routers.ts) - tasks.create - create task with validation - tasks.listByAgent - list agent tasks - tasks.listByConversation - list conversation tasks - tasks.get - get single task - tasks.update - update task with partial updates - tasks.delete - delete task - tasks.getPending - get pending tasks 4. React Component (client/src/components/TasksPanel.tsx) - Right sidebar panel for task display - Checkbox for task completion - Status badges (pending, in_progress, completed, failed, blocked) - Priority indicators (low, medium, high, critical) - Expandable task details (description, result, errors, timestamps) - Real-time updates via tRPC mutations - Delete button with confirmation 5. Chat Integration (client/src/pages/Chat.tsx) - TasksPanel integrated as right sidebar - Unique conversationId per chat session - Tasks panel width: 320px (w-80) - Responsive layout with flex container 6. Auto-Task Creation (server/orchestrator.ts) - autoCreateTasks() - create tasks for missing components - detectMissingComponents() - parse error messages for missing items - trackTaskCompletion() - update task status after execution - Supports: tools, skills, agents, components, dependencies 7. Unit Tests (server/tasks.test.ts) - 5 test suites covering all operations - 107 tests pass, 1 fails (due to missing DB table) - Tests cover: create, read, update, delete operations NEXT STEPS: 1. Run pnpm db:push on production to create tasks table 2. Commit to Gitea with all changes 3. Deploy to production 4. Verify all tests pass on production DB
813 lines
27 KiB
TypeScript
813 lines
27 KiB
TypeScript
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 { retryWithBackoff, isRetryableError, logRetryAttempt, DEFAULT_RETRY_CONFIG } from "./chat-resilience";
|
||
import { checkOllamaHealth, listModels, chatCompletion } from "./ollama";
|
||
import {
|
||
checkGatewayHealth,
|
||
gatewayChat,
|
||
getGatewayOrchestratorConfig,
|
||
getGatewayModels,
|
||
getGatewayTools,
|
||
executeGatewayTool,
|
||
isGatewayAvailable,
|
||
getGatewayNodes,
|
||
getGatewayNodeStats,
|
||
} 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;
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* 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,
|
||
};
|
||
}
|
||
}),
|
||
|
||
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",
|
||
});
|
||
|
||
return {
|
||
success: true as const,
|
||
response,
|
||
model: result.model,
|
||
usage: result.usage,
|
||
processingTimeMs,
|
||
};
|
||
} catch (err: any) {
|
||
await saveHistory(input.agentId, {
|
||
userMessage: input.message,
|
||
agentResponse: null,
|
||
conversationId: input.conversationId,
|
||
status: "error",
|
||
});
|
||
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 }) => {
|
||
// Wrap chat with retry logic for resilience
|
||
return retryWithBackoff(
|
||
async () => {
|
||
// 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 };
|
||
},
|
||
DEFAULT_RETRY_CONFIG,
|
||
(attempt, error) => {
|
||
if (isRetryableError(error)) {
|
||
logRetryAttempt(attempt, error, { messageCount: input.messages.length });
|
||
} else {
|
||
// Non-retryable error, throw immediately
|
||
throw error;
|
||
}
|
||
}
|
||
);
|
||
}),
|
||
|
||
// 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);
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* 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({
|
||
/**
|
||
* List all Swarm nodes (or standalone Docker host if Swarm not active).
|
||
* Returns node info: hostname, role, status, resources, labels, etc.
|
||
*/
|
||
list: publicProcedure.query(async () => {
|
||
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;
|
||
}),
|
||
|
||
/**
|
||
* 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;
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Tasks — управление задачами агентов
|
||
*/
|
||
tasks: router({
|
||
create: publicProcedure
|
||
.input(
|
||
z.object({
|
||
agentId: z.number(),
|
||
conversationId: z.string().optional(),
|
||
title: z.string().min(1),
|
||
description: z.string().optional(),
|
||
priority: z.enum(["low", "medium", "high", "critical"]).optional(),
|
||
dependsOn: z.array(z.number()).optional(),
|
||
metadata: z.record(z.string(), z.any()).optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { createTask } = await import("./db");
|
||
return createTask({
|
||
agentId: input.agentId,
|
||
conversationId: input.conversationId,
|
||
title: input.title,
|
||
description: input.description,
|
||
priority: input.priority ?? "medium",
|
||
dependsOn: input.dependsOn ?? [],
|
||
metadata: input.metadata ?? {},
|
||
});
|
||
}),
|
||
|
||
listByAgent: publicProcedure
|
||
.input(z.object({ agentId: z.number() }))
|
||
.query(async ({ input }) => {
|
||
const { getAgentTasks } = await import("./db");
|
||
return getAgentTasks(input.agentId);
|
||
}),
|
||
|
||
listByConversation: publicProcedure
|
||
.input(z.object({ conversationId: z.string() }))
|
||
.query(async ({ input }) => {
|
||
const { getConversationTasks } = await import("./db");
|
||
return getConversationTasks(input.conversationId);
|
||
}),
|
||
|
||
get: publicProcedure
|
||
.input(z.object({ taskId: z.number() }))
|
||
.query(async ({ input }) => {
|
||
const { getTaskById } = await import("./db");
|
||
return getTaskById(input.taskId);
|
||
}),
|
||
|
||
update: publicProcedure
|
||
.input(
|
||
z.object({
|
||
taskId: z.number(),
|
||
title: z.string().optional(),
|
||
description: z.string().optional(),
|
||
status: z.enum(["pending", "in_progress", "completed", "failed", "blocked"]).optional(),
|
||
priority: z.enum(["low", "medium", "high", "critical"]).optional(),
|
||
result: z.string().optional(),
|
||
errorMessage: z.string().optional(),
|
||
startedAt: z.date().optional(),
|
||
completedAt: z.date().optional(),
|
||
metadata: z.record(z.string(), z.any()).optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { updateTask } = await import("./db");
|
||
const { taskId, ...updates } = input;
|
||
return updateTask(taskId, updates as any);
|
||
}),
|
||
|
||
delete: publicProcedure
|
||
.input(z.object({ taskId: z.number() }))
|
||
.mutation(async ({ input }) => {
|
||
const { deleteTask } = await import("./db");
|
||
return deleteTask(input.taskId);
|
||
}),
|
||
|
||
getPending: publicProcedure
|
||
.input(z.object({ agentId: z.number() }))
|
||
.query(async ({ input }) => {
|
||
const { getPendingAgentTasks } = await import("./db");
|
||
return getPendingAgentTasks(input.agentId);
|
||
}),
|
||
}),
|
||
});
|
||
export type AppRouter = typeof appRouter;
|