## 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
402 lines
17 KiB
TypeScript
402 lines
17 KiB
TypeScript
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;
|