Files
GoClaw/drizzle/schema.ts
bboxwtf 5ff2ade579 feat(workflows): add full Workflow section — visual constructor, dashboard & execution engine
## New Feature: Workflow Builder & Execution Engine

### Database Schema (4 new tables)
- workflows: pipeline definitions with status (draft/active/paused/archived), tags, canvas metadata
- workflowNodes: agent/container/trigger/condition/output blocks with canvas positions
- workflowEdges: directional connections between nodes (source→target)
- workflowRuns: execution history with per-node status tracking & timing

### Backend (server/workflows.ts + 13 tRPC endpoints in routers.ts)
- Full CRUD for workflows, nodes, edges
- Atomic canvas save (nodes + edges in one mutation)
- BFS graph execution engine: walks from trigger nodes, executes agents/containers in order
- Single-node test execution for individual block testing
- Run management: start, cancel, poll status, list history
- Aggregated workflow stats (success rate, avg duration, run counts)

### Frontend — Visual Constructor
- WorkflowCanvas: interactive drag-and-drop builder with:
  - Node palette sidebar (trigger/agent/container/condition/output types)
  - Agent list for quick drag-to-canvas agent nodes
  - Edge drawing between output→input ports with bezier curves
  - Pan/zoom controls + grid background
  - Keyboard shortcuts (Delete, Ctrl+S)
  - Real-time run status overlays (running/success/failed per node)
- WorkflowNodeBlock: kind-aware visual cards with status indicators & connection ports
- WorkflowNodeEditModal: per-kind configuration (agent selector, Docker image/env, condition expressions, cron/webhook triggers)
- WorkflowCreateModal: create new workflows with name, description, tags
- WorkflowDashboard: monitoring panel with stats cards, run history timeline, per-node progress bars
- Workflows page: unified list/canvas/dashboard views with tabs

### Navigation & Routing
- Added Workflows nav item (GitBranch icon) in sidebar between Agents and Tools
- Routes: /workflows (list), /workflows/:id (dashboard+canvas)

### Also includes
- fix(nodes): keep AddNodeDialog open after join + canJoin guard
2026-03-22 00:10:53 +00:00

402 lines
17 KiB
TypeScript
Raw 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), // Главный оркестратор чата
// Метаданные
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;