feat(agents): restore agent-worker container architecture + fix chat scroll and parallel chats

- Restore agent-worker from commit 153399f: autonomous HTTP server per agent
  (main.go 597 lines, main_test.go 438 lines, Dockerfile.agent-worker)
- Add container fields to agents table (serviceName, servicePort, containerImage, containerStatus)
- Update executor.go: real delegateToAgent() with HTTP POST to agent containers
- Update db.go: GetAgentByID, UpdateContainerStatus, GetAgentHistory, SaveHistory
- Update orchestrator.go: inject DB into executor for container address resolution
- Add tRPC endpoints: agents.deployContainer, agents.stopContainer, agents.containerStatus
- Add Docker Swarm deploy/stop logic in server/agents.ts
- Add Start/Stop container buttons to Agents.tsx with status badges
- Fix chat auto-scroll: replace ScrollArea with overflow-y-auto for direct scrollTop control
- Fix parallel chats: make isThinking per-conversation (thinkingConvId) instead of global
  so switching between chats works while one is processing
This commit is contained in:
¨NW¨
2026-04-10 15:43:33 +01:00
parent 42a4f2d01d
commit 0f23dffc26
14 changed files with 2583 additions and 473 deletions

View File

@@ -0,0 +1,12 @@
-- Migration: 0006_agent_container_fields
-- Add Docker Swarm container tracking fields to agents table.
-- Each agent can now be deployed as an autonomous Swarm service.
ALTER TABLE `agents`
ADD COLUMN `serviceName` VARCHAR(100) NULL COMMENT 'Docker Swarm service name: goclaw-agent-{id}',
ADD COLUMN `servicePort` INT NULL COMMENT 'HTTP API port inside overlay network (8001-8999)',
ADD COLUMN `containerImage` VARCHAR(255) NOT NULL DEFAULT 'goclaw-agent-worker:latest' COMMENT 'Docker image to run',
ADD COLUMN `containerStatus` ENUM('stopped','deploying','running','error') NOT NULL DEFAULT 'stopped' COMMENT 'Current container lifecycle state';
-- Index for quick lookup of running agents
CREATE INDEX `agents_containerStatus_idx` ON `agents` (`containerStatus`);

View File

@@ -1,4 +1,15 @@
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, decimal, json, boolean, index } from "drizzle-orm/mysql-core";
import {
int,
mysqlEnum,
mysqlTable,
text,
timestamp,
varchar,
decimal,
json,
boolean,
index,
} from "drizzle-orm/mysql-core";
/**
* Core user table backing auth flow.
@@ -28,48 +39,73 @@ export type InsertUser = typeof users.$inferInsert;
/**
* Agents — конфигурация и управление AI-агентами
*/
export const agents = mysqlTable("agents", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(), // Владелец агента
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
role: varchar("role", { length: 100 }).notNull(), // "developer", "researcher", "executor"
// Модель LLM
model: varchar("model", { length: 100 }).notNull(),
provider: varchar("provider", { length: 50 }).notNull(),
// Параметры LLM
temperature: decimal("temperature", { precision: 3, scale: 2 }).default("0.7"),
maxTokens: int("maxTokens").default(2048),
topP: decimal("topP", { precision: 3, scale: 2 }).default("1.0"),
frequencyPenalty: decimal("frequencyPenalty", { precision: 3, scale: 2 }).default("0.0"),
presencePenalty: decimal("presencePenalty", { precision: 3, scale: 2 }).default("0.0"),
// System Prompt
systemPrompt: text("systemPrompt"),
// Доступы и разрешения
allowedTools: json("allowedTools").$type<string[]>().default([]),
allowedDomains: json("allowedDomains").$type<string[]>().default([]),
maxRequestsPerHour: int("maxRequestsPerHour").default(100),
// Статус
isActive: boolean("isActive").default(true),
isPublic: boolean("isPublic").default(false),
isSystem: boolean("isSystem").default(false), // Системный агент (нельзя удалить)
isOrchestrator: boolean("isOrchestrator").default(false), // Главный оркестратор чата
// Метаданные
tags: json("tags").$type<string[]>().default([]),
metadata: json("metadata").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
userIdIdx: index("agents_userId_idx").on(table.userId),
modelIdx: index("agents_model_idx").on(table.model),
}));
export const agents = mysqlTable(
"agents",
{
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(), // Владелец агента
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
role: varchar("role", { length: 100 }).notNull(), // "developer", "researcher", "executor"
// Модель LLM
model: varchar("model", { length: 100 }).notNull(),
provider: varchar("provider", { length: 50 }).notNull(),
// Параметры LLM
temperature: decimal("temperature", { precision: 3, scale: 2 }).default(
"0.7"
),
maxTokens: int("maxTokens").default(2048),
topP: decimal("topP", { precision: 3, scale: 2 }).default("1.0"),
frequencyPenalty: decimal("frequencyPenalty", {
precision: 3,
scale: 2,
}).default("0.0"),
presencePenalty: decimal("presencePenalty", {
precision: 3,
scale: 2,
}).default("0.0"),
// System Prompt
systemPrompt: text("systemPrompt"),
// Доступы и разрешения
allowedTools: json("allowedTools").$type<string[]>().default([]),
allowedDomains: json("allowedDomains").$type<string[]>().default([]),
maxRequestsPerHour: int("maxRequestsPerHour").default(100),
// Статус
isActive: boolean("isActive").default(true),
isPublic: boolean("isPublic").default(false),
isSystem: boolean("isSystem").default(false), // Системный агент (нельзя удалить)
isOrchestrator: boolean("isOrchestrator").default(false), // Главный оркестратор чата
// Docker Swarm / Container fields (Phase A)
serviceName: varchar("serviceName", { length: 100 }), // Docker Swarm service name: goclaw-agent-{id}
servicePort: int("servicePort"), // HTTP API port inside overlay network (8001-8999)
containerImage: varchar("containerImage", { length: 255 }).default(
"goclaw-agent-worker:latest"
), // Docker image to run
containerStatus: mysqlEnum("containerStatus", [
"stopped",
"deploying",
"running",
"error",
]).default("stopped"), // Container lifecycle state
// Метаданные
tags: json("tags").$type<string[]>().default([]),
metadata: json("metadata").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
},
table => ({
userIdIdx: index("agents_userId_idx").on(table.userId),
modelIdx: index("agents_model_idx").on(table.model),
})
);
export type Agent = typeof agents.$inferSelect;
export type InsertAgent = typeof agents.$inferInsert;
@@ -77,39 +113,48 @@ export type InsertAgent = typeof agents.$inferInsert;
/**
* Agent Metrics — метрики производительности агентов
*/
export const agentMetrics = mysqlTable("agentMetrics", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
// Информация о запросе
requestId: varchar("requestId", { length: 64 }).notNull().unique(),
userMessage: text("userMessage"),
agentResponse: text("agentResponse"),
// Токены
inputTokens: int("inputTokens").default(0),
outputTokens: int("outputTokens").default(0),
totalTokens: int("totalTokens").default(0),
// Время обработки
processingTimeMs: int("processingTimeMs").notNull(),
// Статус
status: mysqlEnum("status", ["success", "error", "timeout", "rate_limited"]).notNull(),
errorMessage: text("errorMessage"),
// Инструменты
toolsCalled: json("toolsCalled").$type<string[]>().default([]),
// Модель
model: varchar("model", { length: 100 }),
temperature: decimal("temperature", { precision: 3, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
agentIdIdx: index("agentMetrics_agentId_idx").on(table.agentId),
createdAtIdx: index("agentMetrics_createdAt_idx").on(table.createdAt),
}));
export const agentMetrics = mysqlTable(
"agentMetrics",
{
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
// Информация о запросе
requestId: varchar("requestId", { length: 64 }).notNull().unique(),
userMessage: text("userMessage"),
agentResponse: text("agentResponse"),
// Токены
inputTokens: int("inputTokens").default(0),
outputTokens: int("outputTokens").default(0),
totalTokens: int("totalTokens").default(0),
// Время обработки
processingTimeMs: int("processingTimeMs").notNull(),
// Статус
status: mysqlEnum("status", [
"success",
"error",
"timeout",
"rate_limited",
]).notNull(),
errorMessage: text("errorMessage"),
// Инструменты
toolsCalled: json("toolsCalled").$type<string[]>().default([]),
// Модель
model: varchar("model", { length: 100 }),
temperature: decimal("temperature", { precision: 3, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
},
table => ({
agentIdIdx: index("agentMetrics_agentId_idx").on(table.agentId),
createdAtIdx: index("agentMetrics_createdAt_idx").on(table.createdAt),
})
);
export type AgentMetric = typeof agentMetrics.$inferSelect;
export type InsertAgentMetric = typeof agentMetrics.$inferInsert;
@@ -117,22 +162,28 @@ export type InsertAgentMetric = typeof agentMetrics.$inferInsert;
/**
* Agent History — полная история запросов
*/
export const agentHistory = mysqlTable("agentHistory", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
userMessage: text("userMessage").notNull(),
agentResponse: text("agentResponse"),
conversationId: varchar("conversationId", { length: 64 }),
messageIndex: int("messageIndex"),
status: mysqlEnum("status", ["pending", "success", "error"]).default("pending"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
agentIdIdx: index("agentHistory_agentId_idx").on(table.agentId),
}));
export const agentHistory = mysqlTable(
"agentHistory",
{
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
userMessage: text("userMessage").notNull(),
agentResponse: text("agentResponse"),
conversationId: varchar("conversationId", { length: 64 }),
messageIndex: int("messageIndex"),
status: mysqlEnum("status", ["pending", "success", "error"]).default(
"pending"
),
createdAt: timestamp("createdAt").defaultNow().notNull(),
},
table => ({
agentIdIdx: index("agentHistory_agentId_idx").on(table.agentId),
})
);
export type AgentHistory = typeof agentHistory.$inferSelect;
export type InsertAgentHistory = typeof agentHistory.$inferInsert;
@@ -140,25 +191,32 @@ export type InsertAgentHistory = typeof agentHistory.$inferInsert;
/**
* Agent Access Control — управление доступами
*/
export const agentAccessControl = mysqlTable("agentAccessControl", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
tool: varchar("tool", { length: 50 }).notNull(),
isAllowed: boolean("isAllowed").default(true),
maxExecutionsPerHour: int("maxExecutionsPerHour").default(100),
timeoutSeconds: int("timeoutSeconds").default(30),
allowedPatterns: json("allowedPatterns").$type<string[]>().default([]),
blockedPatterns: json("blockedPatterns").$type<string[]>().default([]),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
agentIdToolIdx: index("agentAccessControl_agentId_tool_idx").on(table.agentId, table.tool),
}));
export const agentAccessControl = mysqlTable(
"agentAccessControl",
{
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
tool: varchar("tool", { length: 50 }).notNull(),
isAllowed: boolean("isAllowed").default(true),
maxExecutionsPerHour: int("maxExecutionsPerHour").default(100),
timeoutSeconds: int("timeoutSeconds").default(30),
allowedPatterns: json("allowedPatterns").$type<string[]>().default([]),
blockedPatterns: json("blockedPatterns").$type<string[]>().default([]),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
},
table => ({
agentIdToolIdx: index("agentAccessControl_agentId_tool_idx").on(
table.agentId,
table.tool
),
})
);
export type AgentAccessControl = typeof agentAccessControl.$inferSelect;
export type InsertAgentAccessControl = typeof agentAccessControl.$inferInsert;
@@ -172,7 +230,10 @@ export const toolDefinitions = mysqlTable("toolDefinitions", {
description: text("description").notNull(),
category: varchar("category", { length: 50 }).notNull().default("custom"),
dangerous: boolean("dangerous").default(false),
parameters: json("parameters").$type<Record<string, { type: string; description: string; required?: boolean }>>(),
parameters:
json("parameters").$type<
Record<string, { type: string; description: string; required?: boolean }>
>(),
implementation: text("implementation").notNull(), // JS код функции
isActive: boolean("isActive").default(true),
createdBy: int("createdBy"), // agentId или null
@@ -192,7 +253,9 @@ export const browserSessions = mysqlTable("browserSessions", {
agentId: int("agentId").notNull(),
currentUrl: text("currentUrl"),
title: text("title"),
status: mysqlEnum("status", ["active", "idle", "closed", "error"]).default("idle"),
status: mysqlEnum("status", ["active", "idle", "closed", "error"]).default(
"idle"
),
screenshotUrl: text("screenshotUrl"), // S3 URL последнего скриншота
lastActionAt: timestamp("lastActionAt").defaultNow(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
@@ -205,19 +268,29 @@ export type InsertBrowserSession = typeof browserSessions.$inferInsert;
/**
* Node Metrics — исторические метрики Docker-контейнеров/нод (сохраняется каждые 30s)
*/
export const nodeMetrics = mysqlTable("nodeMetrics", {
id: int("id").autoincrement().primaryKey(),
containerId: varchar("containerId", { length: 64 }).notNull(),
containerName: varchar("containerName", { length: 255 }).notNull(),
cpuPercent: decimal("cpuPercent", { precision: 6, scale: 2 }).notNull().default("0.00"),
memUsedMb: decimal("memUsedMb", { precision: 10, scale: 2 }).notNull().default("0.00"),
memLimitMb: decimal("memLimitMb", { precision: 10, scale: 2 }).notNull().default("0.00"),
status: varchar("status", { length: 32 }).notNull().default("running"),
recordedAt: timestamp("recordedAt").defaultNow().notNull(),
}, (table) => ({
containerIdIdx: index("nodeMetrics_containerId_idx").on(table.containerId),
recordedAtIdx: index("nodeMetrics_recordedAt_idx").on(table.recordedAt),
}));
export const nodeMetrics = mysqlTable(
"nodeMetrics",
{
id: int("id").autoincrement().primaryKey(),
containerId: varchar("containerId", { length: 64 }).notNull(),
containerName: varchar("containerName", { length: 255 }).notNull(),
cpuPercent: decimal("cpuPercent", { precision: 6, scale: 2 })
.notNull()
.default("0.00"),
memUsedMb: decimal("memUsedMb", { precision: 10, scale: 2 })
.notNull()
.default("0.00"),
memLimitMb: decimal("memLimitMb", { precision: 10, scale: 2 })
.notNull()
.default("0.00"),
status: varchar("status", { length: 32 }).notNull().default("running"),
recordedAt: timestamp("recordedAt").defaultNow().notNull(),
},
table => ({
containerIdIdx: index("nodeMetrics_containerId_idx").on(table.containerId),
recordedAtIdx: index("nodeMetrics_recordedAt_idx").on(table.recordedAt),
})
);
export type NodeMetric = typeof nodeMetrics.$inferSelect;
export type InsertNodeMetric = typeof nodeMetrics.$inferInsert;
@@ -225,32 +298,48 @@ export type InsertNodeMetric = typeof nodeMetrics.$inferInsert;
/**
* Tasks — задачи, создаваемые агентами для отслеживания работы
*/
export const tasks = mysqlTable("tasks", {
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
conversationId: varchar("conversationId", { length: 64 }),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
status: mysqlEnum("status", ["pending", "in_progress", "completed", "failed", "blocked"]).default("pending").notNull(),
priority: mysqlEnum("priority", ["low", "medium", "high", "critical"]).default("medium").notNull(),
dependsOn: json("dependsOn").$type<number[]>().default([]),
result: text("result"),
errorMessage: text("errorMessage"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
startedAt: timestamp("startedAt"),
completedAt: timestamp("completedAt"),
metadata: json("metadata").$type<Record<string, any>>().default({}),
}, (table) => ({
agentIdIdx: index("tasks_agentId_idx").on(table.agentId),
statusIdx: index("tasks_status_idx").on(table.status),
conversationIdIdx: index("tasks_conversationId_idx").on(table.conversationId),
}));
export const tasks = mysqlTable(
"tasks",
{
id: int("id").autoincrement().primaryKey(),
agentId: int("agentId").notNull(),
conversationId: varchar("conversationId", { length: 64 }),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
status: mysqlEnum("status", [
"pending",
"in_progress",
"completed",
"failed",
"blocked",
])
.default("pending")
.notNull(),
priority: mysqlEnum("priority", ["low", "medium", "high", "critical"])
.default("medium")
.notNull(),
dependsOn: json("dependsOn").$type<number[]>().default([]),
result: text("result"),
errorMessage: text("errorMessage"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
startedAt: timestamp("startedAt"),
completedAt: timestamp("completedAt"),
metadata: json("metadata").$type<Record<string, any>>().default({}),
},
table => ({
agentIdIdx: index("tasks_agentId_idx").on(table.agentId),
statusIdx: index("tasks_status_idx").on(table.status),
conversationIdIdx: index("tasks_conversationId_idx").on(
table.conversationId
),
})
);
export type Task = typeof tasks.$inferSelect;
export type InsertTask = typeof tasks.$inferInsert;