PHASE 19 COMPLETION SUMMARY:
✅ COMPLETED FEATURES:
1. Task Management System (Phase 19.1-19.7)
- Database schema with tasks table (14 columns)
- Query helpers for CRUD operations
- 7 tRPC endpoints for task management
- TasksPanel React component with real-time updates
- Auto-task creation functions
- Chat UI integration with conversationId tracking
2. Auto-Task Creation Integration (Phase 19.8)
- Integrated into orchestratorChat loop
- Detects missing components from tool errors
- Auto-creates tasks for: tools, skills, agents, components, dependencies
- Tracks task completion status
3. Web Research Workflow (Phase 19.9-19.12)
- server/web-research.ts module with 3 main functions:
* performWebResearch() - Execute web searches with Browser Agent
* compileResearchReport() - Generate markdown reports
* createResearchTasks() - Create research tasks for orchestrator
- 3 tRPC endpoints:
* research.search - Perform web research
* research.compileReport - Compile results into report
* research.createTasks - Create research tasks
- WebResearchPanel React component:
* Search input with real-time results
* Options: max results, screenshots, text extraction
* Result cards with expandable details
* Report download functionality
* Error handling and empty states
4. Unit Tests
- 120 tests pass (out of 121 total)
- Web Research tests: 18 tests covering all functions
- Task tests: 5 tests (1 fails due to missing DB table)
- All other tests pass
ARCHITECTURE:
- Browser Agent integration via Puppeteer
- Task tracking with metadata
- Auto-report compilation in markdown
- Screenshot and text extraction support
- Real-time UI updates via tRPC
NEXT STEPS:
1. Run pnpm db:push on production to create tasks table
2. Commit all changes to Gitea
3. Deploy to production
4. Verify tests pass on production DB
5. Test Web Research workflow end-to-end
TEST RESULTS:
- Test Files: 1 failed | 10 passed (11 total)
- Tests: 1 failed | 120 passed (121 total)
- Only failure: tasks.test.ts (requires production DB table)
865 lines
28 KiB
TypeScript
865 lines
28 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);
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Web Research Workflow — Browser Agent Integration
|
||
*/
|
||
research: router({
|
||
search: protectedProcedure
|
||
.input(
|
||
z.object({
|
||
query: z.string().min(1),
|
||
maxResults: z.number().optional().default(5),
|
||
includeScreenshots: z.boolean().optional().default(false),
|
||
extractText: z.boolean().optional().default(false),
|
||
conversationId: z.string().optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { performWebResearch } = await import("./web-research");
|
||
const agentId = 1;
|
||
const conversationId = input.conversationId || `conv-${Date.now()}`;
|
||
return performWebResearch(agentId, conversationId, {
|
||
query: input.query,
|
||
maxResults: input.maxResults,
|
||
includeScreenshots: input.includeScreenshots,
|
||
extractText: input.extractText,
|
||
});
|
||
}),
|
||
|
||
compileReport: protectedProcedure
|
||
.input(
|
||
z.object({
|
||
results: z.array(z.any()),
|
||
title: z.string(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { compileResearchReport } = await import("./web-research");
|
||
return compileResearchReport(input.results, input.title);
|
||
}),
|
||
|
||
createTasks: protectedProcedure
|
||
.input(
|
||
z.object({
|
||
agentId: z.number(),
|
||
conversationId: z.string(),
|
||
queries: z.array(z.string()),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { createResearchTasks } = await import("./web-research");
|
||
return createResearchTasks(input.agentId, input.conversationId, input.queries);
|
||
}),
|
||
}),
|
||
});
|
||
export type AppRouter = typeof appRouter;
|