Files
GoClaw/drizzle/schema.ts
bboxwtf 153399f41e feat(phase-A): agent-worker container — autonomous agent HTTP server
PHASE A COMPLETE: каждый агент теперь может жить в отдельном Docker Swarm контейнере как автономная единица.

- HTTP-сервер агента: GET /health, GET /info, POST /chat, POST /task, GET /tasks, GET /tasks/{id}, GET /memory
- Загружает конфиг из shared DB по AGENT_ID env var (model, systemPrompt, allowedTools)
- 4 горутины-воркера для параллельной обработки задач
- In-memory task queue (buffered channel, depth=100) + ring buffer последних 50 задач
- Callback URL: POST результата при завершении async задачи
- Sliding window памяти: загружает последние 20 сообщений из DB при каждом запросе
- Изолированные инструменты: агент получает только allowedTools из своей конфигурации
- Агент сам вызывает LLM напрямую через LLM_BASE_URL (не через Gateway)
- Graceful shutdown с таймаутом 15s

- 20 unit-тестов: все PASS
- Покрытие: инициализация, task queue, /health, /info, /task, /tasks, /memory, инструменты, lifecycle

- Multi-stage Go build: golang:1.23-alpine → alpine:3.21
- EXPOSE 8001, HEALTHCHECK на /health каждые 15s
- Агенты деплоятся динамически Swarm (не статический сервис в stack)

- Новые поля в таблице agents: serviceName, servicePort, containerImage, containerStatus
- SQL migration: drizzle/migrations/0006_agent_container_fields.sql

- AgentConfig + AgentRow: новые поля serviceName, servicePort, containerImage, containerStatus
- UpdateContainerStatus() — обновление статуса при деплое/остановке
- GetAgentHistory() — sliding window памяти агента из DB
- SaveHistory() — сохранение диалога агента в DB

- delegate_to_agent: реальный HTTP POST к контейнеру агента через overlay DNS
  - sync: POST /chat (ждёт ответ)
  - async: POST /task (возвращает task_id)
  - fallback: если агент не запущен — информативное сообщение
- SetDatabase() — инжекция DB для резолва адресов живых агентов

- Orchestrator инжектирует DB в Executor через SetDatabase() при инициализации
2026-03-31 23:11:02 +00:00

453 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, decimal, json, boolean, index } from "drizzle-orm/mysql-core";
/**
* Core user table backing auth flow.
* Extend this file with additional tables as your product grows.
* Columns use camelCase to match both database fields and generated types.
*/
export const users = mysqlTable("users", {
/**
* Surrogate primary key. Auto-incremented numeric value managed by the database.
* Use this for relations between tables.
*/
id: int("id").autoincrement().primaryKey(),
/** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
openId: varchar("openId", { length: 64 }).notNull().unique(),
name: text("name"),
email: varchar("email", { length: 320 }),
loginMethod: varchar("loginMethod", { length: 64 }),
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
});
export type User = typeof users.$inferSelect;
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), // Главный оркестратор чата
// ── Container / Swarm fields ──────────────────────────────────────────────
// Имя Docker Swarm service: "goclaw-agent-{id}"
serviceName: varchar("serviceName", { length: 100 }),
// Порт HTTP API агента внутри overlay сети (80018999)
servicePort: int("servicePort"),
// Docker image для запуска агента
containerImage: varchar("containerImage", { length: 255 }).default("goclaw-agent-worker:latest"),
// Статус контейнера (обновляется при деплое/остановке)
containerStatus: mysqlEnum("containerStatus", ["stopped", "deploying", "running", "error"]).default("stopped"),
// Метаданные
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;
/**
* 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 type AgentMetric = typeof agentMetrics.$inferSelect;
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 type AgentHistory = typeof agentHistory.$inferSelect;
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 type AgentAccessControl = typeof agentAccessControl.$inferSelect;
export type InsertAgentAccessControl = typeof agentAccessControl.$inferInsert;
/**
* Tool Definitions — пользовательские инструменты, созданные Tool Builder Agent
*/
export const toolDefinitions = mysqlTable("toolDefinitions", {
id: int("id").autoincrement().primaryKey(),
toolId: varchar("toolId", { length: 100 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
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 }>>(),
implementation: text("implementation").notNull(), // JS код функции
isActive: boolean("isActive").default(true),
createdBy: int("createdBy"), // agentId или null
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type ToolDefinition = typeof toolDefinitions.$inferSelect;
export type InsertToolDefinition = typeof toolDefinitions.$inferInsert;
/**
* Browser Sessions — активные сессии браузера для Browser Agent
*/
export const browserSessions = mysqlTable("browserSessions", {
id: int("id").autoincrement().primaryKey(),
sessionId: varchar("sessionId", { length: 64 }).notNull().unique(),
agentId: int("agentId").notNull(),
currentUrl: text("currentUrl"),
title: text("title"),
status: mysqlEnum("status", ["active", "idle", "closed", "error"]).default("idle"),
screenshotUrl: text("screenshotUrl"), // S3 URL последнего скриншота
lastActionAt: timestamp("lastActionAt").defaultNow(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
closedAt: timestamp("closedAt"),
});
export type BrowserSession = typeof browserSessions.$inferSelect;
export type InsertBrowserSession = typeof browserSessions.$inferInsert;
/**
* LLM Providers — хранение конфигурации подключений к LLM API.
* API-ключи хранятся в зашифрованном виде (AES-256-GCM через crypto.ts).
* Активный провайдер читается gateway при каждом запросе.
*/
export const llmProviders = mysqlTable("llmProviders", {
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 128 }).notNull(), // "Ollama Cloud", "OpenAI", etc.
baseUrl: varchar("baseUrl", { length: 512 }).notNull(), // https://ollama.com/v1
apiKeyEncrypted: text("apiKeyEncrypted"), // AES-256-GCM encrypted key
apiKeyHint: varchar("apiKeyHint", { length: 16 }), // First 8 chars for display
isActive: boolean("isActive").default(false).notNull(), // Only one can be active
isDefault: boolean("isDefault").default(false).notNull(), // Default provider for new agents
modelDefault: varchar("modelDefault", { length: 128 }), // Default model for this provider
notes: text("notes"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type LlmProvider = typeof llmProviders.$inferSelect;
export type InsertLlmProvider = typeof llmProviders.$inferInsert;
/**
* Chat Sessions — persistent server-side chat runs.
* Each user message creates one session. The Go gateway processes it
* and writes events to chatEvents. The frontend polls for events.
*/
export const chatSessions = mysqlTable("chatSessions", {
id: int("id").autoincrement().primaryKey(),
sessionId: varchar("sessionId", { length: 64 }).notNull().unique(),
agentId: int("agentId").notNull().default(1),
status: mysqlEnum("status", ["running", "done", "error"]).notNull().default("running"),
userMessage: text("userMessage").notNull(),
finalResponse: text("finalResponse"),
model: varchar("model", { length: 128 }),
totalTokens: int("totalTokens").default(0),
processingTimeMs: int("processingTimeMs").default(0),
errorMessage: text("errorMessage"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
statusIdx: index("chatSessions_status_idx").on(table.status),
createdAtIdx: index("chatSessions_createdAt_idx").on(table.createdAt),
}));
export type ChatSession = typeof chatSessions.$inferSelect;
export type InsertChatSession = typeof chatSessions.$inferInsert;
/**
* Chat Events — individual SSE events written by Go gateway, read by frontend.
*/
export const chatEvents = mysqlTable("chatEvents", {
id: int("id").autoincrement().primaryKey(),
sessionId: varchar("sessionId", { length: 64 }).notNull(),
seq: int("seq").notNull().default(0),
eventType: mysqlEnum("eventType", ["thinking", "tool_call", "delta", "done", "error"]).notNull(),
content: text("content"),
toolName: varchar("toolName", { length: 128 }),
toolArgs: json("toolArgs"),
toolResult: text("toolResult"),
toolSuccess: boolean("toolSuccess"),
durationMs: int("durationMs"),
model: varchar("model", { length: 128 }),
usageJson: json("usageJson"),
errorMsg: text("errorMsg"),
createdAt: timestamp("createdAt", { fsp: 3 }).defaultNow().notNull(),
}, (table) => ({
sessionSeqIdx: index("chatEvents_sessionId_seq_idx").on(table.sessionId, table.seq),
}));
export type ChatEvent = typeof chatEvents.$inferSelect;
export type InsertChatEvent = typeof chatEvents.$inferInsert;
// ─── Workflows ────────────────────────────────────────────────────────────────
/**
* Workflows — visual pipeline definitions composed of agent/container nodes.
* Each workflow is a directed graph stored as nodes + edges.
*/
export const workflows = mysqlTable("workflows", {
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
/** Visual status used in the list/dashboard */
status: mysqlEnum("status", ["draft", "active", "paused", "archived"]).default("draft").notNull(),
/** JSON blob of canvas-level metadata: viewport position, zoom, layout hints */
canvasMeta: json("canvasMeta").$type<{ viewportX?: number; viewportY?: number; zoom?: number }>().default({}),
tags: json("tags").$type<string[]>().default([]),
createdBy: int("createdBy"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Workflow = typeof workflows.$inferSelect;
export type InsertWorkflow = typeof workflows.$inferInsert;
/**
* Workflow Nodes — individual blocks inside a workflow.
* Each node references either an agent (agentId) or an arbitrary container config.
*/
export const workflowNodes = mysqlTable("workflowNodes", {
id: int("id").autoincrement().primaryKey(),
workflowId: int("workflowId").notNull(),
/** Unique client-side ID used by the canvas (e.g. "node_abc123") */
nodeKey: varchar("nodeKey", { length: 64 }).notNull(),
label: varchar("label", { length: 255 }).notNull(),
/** Node kind: agent = uses an existing agent; container = custom Docker image; trigger = entry point; output = terminal */
kind: mysqlEnum("kind", ["agent", "container", "trigger", "condition", "output"]).notNull(),
/** Link to agents table (nullable — only for kind=agent) */
agentId: int("agentId"),
/** For kind=container: Docker image, env vars, ports etc. */
containerConfig: json("containerConfig").$type<{
image?: string;
env?: string[];
ports?: string[];
command?: string;
volumes?: string[];
}>().default({}),
/** For kind=condition: JS expression evaluated at runtime */
conditionExpr: text("conditionExpr"),
/** Trigger config: cron, webhook, manual */
triggerConfig: json("triggerConfig").$type<{ type?: string; cron?: string; webhookPath?: string }>().default({}),
/** Canvas position */
posX: int("posX").default(0),
posY: int("posY").default(0),
/** Extra metadata (colour, icon override, etc.) */
meta: json("meta").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
workflowIdIdx: index("workflowNodes_workflowId_idx").on(table.workflowId),
}));
export type WorkflowNode = typeof workflowNodes.$inferSelect;
export type InsertWorkflowNode = typeof workflowNodes.$inferInsert;
/**
* Workflow Edges — connections between nodes.
*/
export const workflowEdges = mysqlTable("workflowEdges", {
id: int("id").autoincrement().primaryKey(),
workflowId: int("workflowId").notNull(),
/** Edge identifier on the canvas */
edgeKey: varchar("edgeKey", { length: 64 }).notNull(),
sourceNodeKey: varchar("sourceNodeKey", { length: 64 }).notNull(),
targetNodeKey: varchar("targetNodeKey", { length: 64 }).notNull(),
/** Optional: which output handle → which input handle */
sourceHandle: varchar("sourceHandle", { length: 64 }),
targetHandle: varchar("targetHandle", { length: 64 }),
/** Edge label (e.g. "on success", "on fail") */
label: varchar("label", { length: 128 }),
/** Visual styling */
meta: json("meta").$type<Record<string, any>>().default({}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
workflowIdIdx: index("workflowEdges_workflowId_idx").on(table.workflowId),
}));
export type WorkflowEdge = typeof workflowEdges.$inferSelect;
export type InsertWorkflowEdge = typeof workflowEdges.$inferInsert;
/**
* Workflow Runs — execution history. Each run tracks overall status and
* per-node results so the dashboard can show progress in real-time.
*/
export const workflowRuns = mysqlTable("workflowRuns", {
id: int("id").autoincrement().primaryKey(),
workflowId: int("workflowId").notNull(),
runKey: varchar("runKey", { length: 64 }).notNull().unique(),
status: mysqlEnum("status", ["pending", "running", "success", "failed", "cancelled"]).default("pending").notNull(),
/** Per-node execution results: { [nodeKey]: { status, output, durationMs, error? } } */
nodeResults: json("nodeResults").$type<Record<string, {
status: "pending" | "running" | "success" | "failed" | "skipped";
output?: string;
durationMs?: number;
error?: string;
startedAt?: string;
finishedAt?: string;
}>>().default({}),
/** The node currently being executed */
currentNodeKey: varchar("currentNodeKey", { length: 64 }),
/** Global input passed to the first node */
input: text("input"),
/** Final aggregated output */
output: text("output"),
totalDurationMs: int("totalDurationMs"),
errorMessage: text("errorMessage"),
startedAt: timestamp("startedAt"),
finishedAt: timestamp("finishedAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
workflowIdIdx: index("workflowRuns_workflowId_idx").on(table.workflowId),
statusIdx: index("workflowRuns_status_idx").on(table.status),
}));
export type WorkflowRun = typeof workflowRuns.$inferSelect;
export type InsertWorkflowRun = typeof workflowRuns.$inferInsert;
// ─── TaskBoard ───────────────────────────────────────────────────────────────
/**
* ChatTasks — persistent task board linked to chat sessions.
* Both the orchestrator and agents can create/update tasks.
* Subtasks are stored as JSON array inside the parent task row.
*/
export const chatTasks = mysqlTable("chatTasks", {
id: int("id").autoincrement().primaryKey(),
taskId: varchar("taskId", { length: 32 }).notNull().unique(),
sessionId: varchar("sessionId", { length: 64 }), // linked chat session
content: text("content").notNull(),
status: mysqlEnum("status", ["pending", "in_progress", "completed", "failed", "blocked"]).default("pending").notNull(),
priority: mysqlEnum("priority", ["critical", "high", "medium", "low"]).default("medium").notNull(),
createdBy: varchar("createdBy", { length: 128 }).default("user"),
assignedTo: varchar("assignedTo", { length: 128 }),
/** Subtasks stored as JSON array */
subtasks: json("subtasks").$type<Array<{
id: string;
content: string;
status: string;
createdBy: string;
createdAt: number;
completedAt?: number;
}>>().default([]),
elapsedMs: int("elapsedMs").default(0),
retryCount: int("retryCount").default(0),
lastError: text("lastError"),
testedAt: timestamp("testedAt"),
startedAt: timestamp("startedAt"),
completedAt: timestamp("completedAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
sessionIdx: index("chatTasks_sessionId_idx").on(table.sessionId),
statusIdx: index("chatTasks_status_idx").on(table.status),
}));
export type ChatTask = typeof chatTasks.$inferSelect;
export type InsertChatTask = typeof chatTasks.$inferInsert;