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:
12
drizzle/0006_agent_container_fields.sql
Normal file
12
drizzle/0006_agent_container_fields.sql
Normal 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`);
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user