From b579e1a4d1ee0a97c09f363c69bb5079688cbc99 Mon Sep 17 00:00:00 2001 From: Manus Date: Sun, 29 Mar 2026 07:08:18 -0400 Subject: [PATCH] Checkpoint: Phase 19: Complete Task Management System Implementation COMPLETED FEATURES: 1. Database Schema (drizzle/schema.ts) - Added tasks table with 14 columns - Status enum: pending, in_progress, completed, failed, blocked - Priority enum: low, medium, high, critical - Supports task dependencies, metadata, error tracking - Indexed by agentId, status, conversationId 2. Query Helpers (server/db.ts) - createTask() - create new task - getAgentTasks() - get all agent tasks - getConversationTasks() - get conversation tasks - getTaskById() - get single task - updateTask() - update task status/results - deleteTask() - delete task - getPendingAgentTasks() - get active tasks with priority sorting 3. tRPC Endpoints (server/routers.ts) - tasks.create - create task with validation - tasks.listByAgent - list agent tasks - tasks.listByConversation - list conversation tasks - tasks.get - get single task - tasks.update - update task with partial updates - tasks.delete - delete task - tasks.getPending - get pending tasks 4. React Component (client/src/components/TasksPanel.tsx) - Right sidebar panel for task display - Checkbox for task completion - Status badges (pending, in_progress, completed, failed, blocked) - Priority indicators (low, medium, high, critical) - Expandable task details (description, result, errors, timestamps) - Real-time updates via tRPC mutations - Delete button with confirmation 5. Chat Integration (client/src/pages/Chat.tsx) - TasksPanel integrated as right sidebar - Unique conversationId per chat session - Tasks panel width: 320px (w-80) - Responsive layout with flex container 6. Auto-Task Creation (server/orchestrator.ts) - autoCreateTasks() - create tasks for missing components - detectMissingComponents() - parse error messages for missing items - trackTaskCompletion() - update task status after execution - Supports: tools, skills, agents, components, dependencies 7. Unit Tests (server/tasks.test.ts) - 5 test suites covering all operations - 107 tests pass, 1 fails (due to missing DB table) - Tests cover: create, read, update, delete operations NEXT STEPS: 1. Run pnpm db:push on production to create tasks table 2. Commit to Gitea with all changes 3. Deploy to production 4. Verify all tests pass on production DB --- client/src/components/TasksPanel.tsx | 250 ++++++ client/src/pages/Chat.tsx | 16 +- drizzle/0005_salty_supernaut.sql | 21 + drizzle/meta/0005_snapshot.json | 1109 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 33 + server/db.ts | 129 +++ server/orchestrator.ts | 170 ++++ server/routers.ts | 86 ++ server/tasks.test.ts | 69 ++ todo.md | 12 + 11 files changed, 1899 insertions(+), 3 deletions(-) create mode 100644 client/src/components/TasksPanel.tsx create mode 100644 drizzle/0005_salty_supernaut.sql create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 server/tasks.test.ts diff --git a/client/src/components/TasksPanel.tsx b/client/src/components/TasksPanel.tsx new file mode 100644 index 0000000..4312fa8 --- /dev/null +++ b/client/src/components/TasksPanel.tsx @@ -0,0 +1,250 @@ +import { useState, useEffect } from "react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { AlertCircle, CheckCircle2, Clock, Trash2, Plus } from "lucide-react"; + +export interface TasksPanelProps { + agentId?: number; + conversationId?: string; +} + +/** + * TasksPanel — правая панель для отображения и управления задачами + */ +export function TasksPanel({ + agentId, + conversationId, +}: TasksPanelProps) { + const [tasks, setTasks] = useState([]); + const [expandedTaskId, setExpandedTaskId] = useState(null); + + const listByAgent = trpc.tasks.listByAgent.useQuery( + { agentId: agentId || 0 }, + { enabled: !!agentId } + ); + + const listByConversation = trpc.tasks.listByConversation.useQuery( + { conversationId: conversationId || "" }, + { enabled: !!conversationId } + ); + + const updateTaskMutation = trpc.tasks.update.useMutation(); + const deleteTaskMutation = trpc.tasks.delete.useMutation(); + + useEffect(() => { + if (agentId && listByAgent.data) { + setTasks(listByAgent.data); + } else if (conversationId && listByConversation.data) { + setTasks(listByConversation.data); + } + }, [listByAgent.data, listByConversation.data, agentId, conversationId]); + + const handleStatusChange = async (taskId: number, newStatus: string) => { + try { + const result = await updateTaskMutation.mutateAsync({ + taskId, + status: newStatus as any, + ...(newStatus === "completed" && { completedAt: new Date() }), + ...(newStatus === "in_progress" && { startedAt: new Date() }), + }); + + if (result) { + setTasks((prev) => + prev.map((t) => (t.id === taskId ? result : t)) + ); + } + } catch (error) { + console.error("Failed to update task:", error); + } + }; + + const handleDeleteTask = async (taskId: number) => { + try { + const success = await deleteTaskMutation.mutateAsync({ taskId }); + if (success) { + setTasks((prev) => prev.filter((t) => t.id !== taskId)); + } + } catch (error) { + console.error("Failed to delete task:", error); + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case "completed": + return ; + case "failed": + return ; + case "in_progress": + return ; + default: + return ; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case "completed": + return "bg-green-100 text-green-800"; + case "failed": + return "bg-red-100 text-red-800"; + case "in_progress": + return "bg-blue-100 text-blue-800"; + case "blocked": + return "bg-orange-100 text-orange-800"; + default: + return "bg-gray-100 text-gray-800"; + } + }; + + const getPriorityColor = (priority: string) => { + switch (priority) { + case "critical": + return "bg-red-100 text-red-800"; + case "high": + return "bg-orange-100 text-orange-800"; + case "medium": + return "bg-yellow-100 text-yellow-800"; + default: + return "bg-gray-100 text-gray-800"; + } + }; + + return ( +
+
+
+

Tasks

+ + {tasks.length} + +
+

+ {agentId ? `Agent #${agentId}` : conversationId ? "Conversation" : "No selection"} +

+
+ +
+ {tasks.length === 0 ? ( +
+ No tasks yet +
+ ) : ( +
+ {tasks.map((task) => ( + + setExpandedTaskId( + expandedTaskId === task.id ? null : task.id + ) + } + > +
+ { + handleStatusChange( + task.id, + checked ? "completed" : "pending" + ); + }} + onClick={(e) => e.stopPropagation()} + className="mt-1" + /> +
+

+ {task.title} +

+
+ {getStatusIcon(task.status)} + + {task.status.replace("_", " ")} + + {task.priority && ( + + {task.priority} + + )} +
+
+ +
+ + {expandedTaskId === task.id && ( +
+ {task.description && ( +
+

+ Description +

+

+ {task.description} +

+
+ )} + {task.result && ( +
+

+ Result +

+

+ {task.result} +

+
+ )} + {task.errorMessage && ( +
+

+ Error +

+

+ {task.errorMessage} +

+
+ )} + {task.createdAt && ( +
+ Created: {new Date(task.createdAt).toLocaleString()} +
+ )} +
+ )} +
+ ))} +
+ )} +
+ +
+ +
+
+ ); +} diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 3ae92b2..133523c 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { TasksPanel } from "@/components/TasksPanel"; import { Terminal, Send, @@ -252,6 +253,7 @@ export default function Chat() { const [isThinking, setIsThinking] = useState(false); const [retryAttempt, setRetryAttempt] = useState(0); const [lastError, setLastError] = useState<{ message: string; isRetryable: boolean } | null>(null); + const [conversationId] = useState(`conv-${Date.now()}`); const scrollRef = useRef(null); const inputRef = useRef(null); @@ -450,9 +452,11 @@ export default function Chat() { - {/* Chat area */} - - + {/* Main Content Area */} +
+ {/* Chat area */} + +
@@ -523,6 +527,12 @@ export default function Chat() {
+ + {/* Tasks Panel */} +
+ +
+
); } diff --git a/drizzle/0005_salty_supernaut.sql b/drizzle/0005_salty_supernaut.sql new file mode 100644 index 0000000..48c6a51 --- /dev/null +++ b/drizzle/0005_salty_supernaut.sql @@ -0,0 +1,21 @@ +CREATE TABLE `tasks` ( + `id` int AUTO_INCREMENT NOT NULL, + `agentId` int NOT NULL, + `conversationId` varchar(64), + `title` varchar(255) NOT NULL, + `description` text, + `status` enum('pending','in_progress','completed','failed','blocked') NOT NULL DEFAULT 'pending', + `priority` enum('low','medium','high','critical') NOT NULL DEFAULT 'medium', + `dependsOn` json DEFAULT ('[]'), + `result` text, + `errorMessage` text, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `startedAt` timestamp, + `completedAt` timestamp, + `metadata` json DEFAULT ('{}'), + CONSTRAINT `tasks_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE INDEX `tasks_agentId_idx` ON `tasks` (`agentId`);--> statement-breakpoint +CREATE INDEX `tasks_status_idx` ON `tasks` (`status`);--> statement-breakpoint +CREATE INDEX `tasks_conversationId_idx` ON `tasks` (`conversationId`); \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..0b0d3bf --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1109 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "a7cffc76-1b19-41d0-90d8-3aa472509ab4", + "prevId": "7d5a0ac2-340e-4028-9e15-2516188db481", + "tables": { + "agentAccessControl": { + "name": "agentAccessControl", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "agentId": { + "name": "agentId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool": { + "name": "tool", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isAllowed": { + "name": "isAllowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "maxExecutionsPerHour": { + "name": "maxExecutionsPerHour", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 100 + }, + "timeoutSeconds": { + "name": "timeoutSeconds", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 30 + }, + "allowedPatterns": { + "name": "allowedPatterns", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('[]')" + }, + "blockedPatterns": { + "name": "blockedPatterns", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('[]')" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "agentAccessControl_agentId_tool_idx": { + "name": "agentAccessControl_agentId_tool_idx", + "columns": [ + "agentId", + "tool" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agentAccessControl_id": { + "name": "agentAccessControl_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agentHistory": { + "name": "agentHistory", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "agentId": { + "name": "agentId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userMessage": { + "name": "userMessage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agentResponse": { + "name": "agentResponse", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "messageIndex": { + "name": "messageIndex", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','success','error')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "agentHistory_agentId_idx": { + "name": "agentHistory_agentId_idx", + "columns": [ + "agentId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agentHistory_id": { + "name": "agentHistory_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agentMetrics": { + "name": "agentMetrics", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "agentId": { + "name": "agentId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userMessage": { + "name": "userMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agentResponse": { + "name": "agentResponse", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inputTokens": { + "name": "inputTokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "outputTokens": { + "name": "outputTokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "totalTokens": { + "name": "totalTokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "processingTimeMs": { + "name": "processingTimeMs", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('success','error','timeout','rate_limited')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "toolsCalled": { + "name": "toolsCalled", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('[]')" + }, + "model": { + "name": "model", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "decimal(3,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "agentMetrics_agentId_idx": { + "name": "agentMetrics_agentId_idx", + "columns": [ + "agentId" + ], + "isUnique": false + }, + "agentMetrics_createdAt_idx": { + "name": "agentMetrics_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agentMetrics_id": { + "name": "agentMetrics_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "agentMetrics_requestId_unique": { + "name": "agentMetrics_requestId_unique", + "columns": [ + "requestId" + ] + } + }, + "checkConstraint": {} + }, + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "decimal(3,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0.7'" + }, + "maxTokens": { + "name": "maxTokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 2048 + }, + "topP": { + "name": "topP", + "type": "decimal(3,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'1.0'" + }, + "frequencyPenalty": { + "name": "frequencyPenalty", + "type": "decimal(3,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0.0'" + }, + "presencePenalty": { + "name": "presencePenalty", + "type": "decimal(3,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0.0'" + }, + "systemPrompt": { + "name": "systemPrompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowedTools": { + "name": "allowedTools", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('[]')" + }, + "allowedDomains": { + "name": "allowedDomains", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('[]')" + }, + "maxRequestsPerHour": { + "name": "maxRequestsPerHour", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 100 + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "isSystem": { + "name": "isSystem", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "isOrchestrator": { + "name": "isOrchestrator", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "tags": { + "name": "tags", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('[]')" + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('{}')" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "agents_userId_idx": { + "name": "agents_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "agents_model_idx": { + "name": "agents_model_idx", + "columns": [ + "model" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agents_id": { + "name": "agents_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "browserSessions": { + "name": "browserSessions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "sessionId": { + "name": "sessionId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agentId": { + "name": "agentId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currentUrl": { + "name": "currentUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('active','idle','closed','error')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'idle'" + }, + "screenshotUrl": { + "name": "screenshotUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastActionAt": { + "name": "lastActionAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "closedAt": { + "name": "closedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "browserSessions_id": { + "name": "browserSessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "browserSessions_sessionId_unique": { + "name": "browserSessions_sessionId_unique", + "columns": [ + "sessionId" + ] + } + }, + "checkConstraint": {} + }, + "nodeMetrics": { + "name": "nodeMetrics", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "containerId": { + "name": "containerId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "containerName": { + "name": "containerName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cpuPercent": { + "name": "cpuPercent", + "type": "decimal(6,2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0.00'" + }, + "memUsedMb": { + "name": "memUsedMb", + "type": "decimal(10,2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0.00'" + }, + "memLimitMb": { + "name": "memLimitMb", + "type": "decimal(10,2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'0.00'" + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "recordedAt": { + "name": "recordedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "nodeMetrics_containerId_idx": { + "name": "nodeMetrics_containerId_idx", + "columns": [ + "containerId" + ], + "isUnique": false + }, + "nodeMetrics_recordedAt_idx": { + "name": "nodeMetrics_recordedAt_idx", + "columns": [ + "recordedAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "nodeMetrics_id": { + "name": "nodeMetrics_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "agentId": { + "name": "agentId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','in_progress','completed','failed','blocked')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "enum('low','medium','high','critical')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "dependsOn": { + "name": "dependsOn", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('[]')" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('{}')" + } + }, + "indexes": { + "tasks_agentId_idx": { + "name": "tasks_agentId_idx", + "columns": [ + "agentId" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_conversationId_idx": { + "name": "tasks_conversationId_idx", + "columns": [ + "conversationId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tasks_id": { + "name": "tasks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "toolDefinitions": { + "name": "toolDefinitions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "toolId": { + "name": "toolId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'custom'" + }, + "dangerous": { + "name": "dangerous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "parameters": { + "name": "parameters", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "implementation": { + "name": "implementation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "createdBy": { + "name": "createdBy", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "toolDefinitions_id": { + "name": "toolDefinitions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "toolDefinitions_toolId_unique": { + "name": "toolDefinitions_toolId_unique", + "columns": [ + "toolId" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2a6ee76..1549dde 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1774056583522, "tag": "0004_steady_doctor_octopus", "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1774778712293, + "tag": "0005_salty_supernaut", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 1e9d7d0..7337bae 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -221,3 +221,36 @@ export const nodeMetrics = mysqlTable("nodeMetrics", { export type NodeMetric = typeof nodeMetrics.$inferSelect; 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().default([]), + + result: text("result"), + errorMessage: text("errorMessage"), + + createdAt: timestamp("createdAt").defaultNow().notNull(), + startedAt: timestamp("startedAt"), + completedAt: timestamp("completedAt"), + + metadata: json("metadata").$type>().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; diff --git a/server/db.ts b/server/db.ts index cdad5b6..e9c91bd 100644 --- a/server/db.ts +++ b/server/db.ts @@ -168,3 +168,132 @@ export async function pruneOldNodeMetrics(hours = 2): Promise { // Non-critical — ignore prune errors } } + + +// ── Tasks ──────────────────────────────────────────────────────────── + +import { tasks, Task, InsertTask } from "../drizzle/schema"; + +/** + * Create a new task + */ +export async function createTask(task: InsertTask): Promise { + const db = await getDb(); + if (!db) return null; + try { + await db.insert(tasks).values(task); + // Get the last inserted task + const newTask = await db + .select() + .from(tasks) + .orderBy(desc(tasks.createdAt)) + .limit(1); + return newTask.length > 0 ? newTask[0] : null; + } catch (error) { + console.error("[DB] Failed to create task:", error); + return null; + } +} + +/** + * Get all tasks for an agent + */ +export async function getAgentTasks(agentId: number): Promise { + const db = await getDb(); + if (!db) return []; + try { + return await db + .select() + .from(tasks) + .where(eq(tasks.agentId, agentId)) + .orderBy(desc(tasks.createdAt)); + } catch (error) { + console.error("[DB] Failed to get agent tasks:", error); + return []; + } +} + +/** + * Get tasks for a conversation + */ +export async function getConversationTasks(conversationId: string): Promise { + const db = await getDb(); + if (!db) return []; + try { + return await db + .select() + .from(tasks) + .where(eq(tasks.conversationId, conversationId)) + .orderBy(desc(tasks.createdAt)); + } catch (error) { + console.error("[DB] Failed to get conversation tasks:", error); + return []; + } +} + +/** + * Get a single task by ID + */ +export async function getTaskById(taskId: number): Promise { + const db = await getDb(); + if (!db) return null; + try { + const result = await db + .select() + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1); + return result.length > 0 ? result[0] : null; + } catch (error) { + console.error("[DB] Failed to get task:", error); + return null; + } +} + +/** + * Update a task + */ +export async function updateTask(taskId: number, updates: Partial): Promise { + const db = await getDb(); + if (!db) return null; + try { + await db.update(tasks).set(updates).where(eq(tasks.id, taskId)); + return await getTaskById(taskId); + } catch (error) { + console.error("[DB] Failed to update task:", error); + return null; + } +} + +/** + * Get pending tasks for an agent + */ +export async function getPendingAgentTasks(agentId: number): Promise { + const db = await getDb(); + if (!db) return []; + try { + return await db + .select() + .from(tasks) + .where(eq(tasks.agentId, agentId)) + .orderBy(desc(tasks.priority), desc(tasks.createdAt)); + } catch (error) { + console.error("[DB] Failed to get pending tasks:", error); + return []; + } +} + +/** + * Delete a task + */ +export async function deleteTask(taskId: number): Promise { + const db = await getDb(); + if (!db) return false; + try { + await db.delete(tasks).where(eq(tasks.id, taskId)); + return true; + } catch (error) { + console.error("[DB] Failed to delete task:", error); + return false; + } +} diff --git a/server/orchestrator.ts b/server/orchestrator.ts index a4ac186..2c686dc 100644 --- a/server/orchestrator.ts +++ b/server/orchestrator.ts @@ -621,3 +621,173 @@ export async function orchestratorChat( usage: lastUsage, }; } + + +// ─── Auto-Task Creation ──────────────────────────────────────────────────────── + +/** + * Automatically create tasks when agent detects missing components + */ +export async function autoCreateTasks( + agentId: number, + conversationId: string | undefined, + detectedNeeds: Array<{ + type: "tool" | "skill" | "agent" | "component" | "dependency"; + name: string; + description: string; + priority?: "low" | "medium" | "high" | "critical"; + }> +): Promise { + const { createTask } = await import("./db"); + const createdTaskIds: number[] = []; + + for (const need of detectedNeeds) { + try { + const task = await createTask({ + agentId, + conversationId, + title: `${need.type === "tool" ? "🔧" : need.type === "skill" ? "📚" : need.type === "agent" ? "🤖" : need.type === "component" ? "⚙️" : "📦"} ${need.name}`, + description: need.description, + status: "pending", + priority: need.priority ?? "medium", + metadata: { + needType: need.type, + originalName: need.name, + autoCreated: true, + createdAt: new Date().toISOString(), + }, + }); + + if (task?.id) { + createdTaskIds.push(task.id); + } + } catch (error) { + console.error(`[Orchestrator] Failed to create auto-task for ${need.name}:`, error); + } + } + + return createdTaskIds; +} + +/** + * Detect missing components from LLM response or tool execution + */ +export function detectMissingComponents( + toolResult: any, + toolName: string +): Array<{ + type: "tool" | "skill" | "agent" | "component" | "dependency"; + name: string; + description: string; + priority?: "low" | "medium" | "high" | "critical"; +}> { + const missing: Array<{ + type: "tool" | "skill" | "agent" | "component" | "dependency"; + name: string; + description: string; + priority?: "low" | "medium" | "high" | "critical"; + }> = []; + + if (toolResult?.error) { + const errorMsg = String(toolResult.error).toLowerCase(); + + if (errorMsg.includes("tool not found") || errorMsg.includes("unknown tool")) { + const toolMatch = toolResult.error.match(/tool[:\s]+(\w+)/i); + if (toolMatch) { + missing.push({ + type: "tool", + name: toolMatch[1], + description: `Tool "${toolMatch[1]}" is missing and needs to be created or installed`, + priority: "high", + }); + } + } + + if (errorMsg.includes("skill not found") || errorMsg.includes("capability missing")) { + const skillMatch = toolResult.error.match(/skill[:\s]+(\w+)/i); + if (skillMatch) { + missing.push({ + type: "skill", + name: skillMatch[1], + description: `Skill "${skillMatch[1]}" is not available and needs to be installed`, + priority: "high", + }); + } + } + + if ( + errorMsg.includes("module not found") || + errorMsg.includes("package not found") || + errorMsg.includes("cannot find") + ) { + const depMatch = toolResult.error.match(/(?:module|package)[:\s]+(\S+)/i); + if (depMatch) { + missing.push({ + type: "dependency", + name: depMatch[1], + description: `Dependency "${depMatch[1]}" is missing and needs to be installed`, + priority: "high", + }); + } + } + + if (errorMsg.includes("agent not found") || errorMsg.includes("no agent")) { + const agentMatch = toolResult.error.match(/agent[:\s]+(\w+)/i); + if (agentMatch) { + missing.push({ + type: "agent", + name: agentMatch[1], + description: `Agent "${agentMatch[1]}" is not available and needs to be created`, + priority: "medium", + }); + } + } + + if (errorMsg.includes("component") && errorMsg.includes("missing")) { + const compMatch = toolResult.error.match(/component[:\s]+(\w+)/i); + if (compMatch) { + missing.push({ + type: "component", + name: compMatch[1], + description: `Component "${compMatch[1]}" is missing and needs to be implemented`, + priority: "medium", + }); + } + } + } + + if (toolResult?.timeout || toolResult?.incomplete) { + missing.push({ + type: "component", + name: `Timeout Handler for ${toolName}`, + description: `Tool "${toolName}" timed out and needs optimization or retry logic`, + priority: "medium", + }); + } + + return missing; +} + +/** + * Track task completion in orchestrator workflow + */ +export async function trackTaskCompletion( + taskId: number, + status: "in_progress" | "completed" | "failed" | "blocked", + result?: string, + errorMessage?: string +): Promise { + const { updateTask } = await import("./db"); + + try { + await updateTask(taskId, { + status, + result, + errorMessage, + ...(status === "completed" && { completedAt: new Date() }), + ...(status === "in_progress" && { startedAt: new Date() }), + }); + } catch (error) { + console.error(`[Orchestrator] Failed to track task completion for task #${taskId}:`, error); + } +} diff --git a/server/routers.ts b/server/routers.ts index ee5111f..fa0fc6a 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -722,5 +722,91 @@ export const appRouter = router({ return result; }), }), + + /** + * Tasks — управление задачами агентов + */ + tasks: router({ + create: publicProcedure + .input( + z.object({ + agentId: z.number(), + conversationId: z.string().optional(), + title: z.string().min(1), + description: z.string().optional(), + priority: z.enum(["low", "medium", "high", "critical"]).optional(), + dependsOn: z.array(z.number()).optional(), + metadata: z.record(z.string(), z.any()).optional(), + }) + ) + .mutation(async ({ input }) => { + const { createTask } = await import("./db"); + return createTask({ + agentId: input.agentId, + conversationId: input.conversationId, + title: input.title, + description: input.description, + priority: input.priority ?? "medium", + dependsOn: input.dependsOn ?? [], + metadata: input.metadata ?? {}, + }); + }), + + listByAgent: publicProcedure + .input(z.object({ agentId: z.number() })) + .query(async ({ input }) => { + const { getAgentTasks } = await import("./db"); + return getAgentTasks(input.agentId); + }), + + listByConversation: publicProcedure + .input(z.object({ conversationId: z.string() })) + .query(async ({ input }) => { + const { getConversationTasks } = await import("./db"); + return getConversationTasks(input.conversationId); + }), + + get: publicProcedure + .input(z.object({ taskId: z.number() })) + .query(async ({ input }) => { + const { getTaskById } = await import("./db"); + return getTaskById(input.taskId); + }), + + update: publicProcedure + .input( + z.object({ + taskId: z.number(), + title: z.string().optional(), + description: z.string().optional(), + status: z.enum(["pending", "in_progress", "completed", "failed", "blocked"]).optional(), + priority: z.enum(["low", "medium", "high", "critical"]).optional(), + result: z.string().optional(), + errorMessage: z.string().optional(), + startedAt: z.date().optional(), + completedAt: z.date().optional(), + metadata: z.record(z.string(), z.any()).optional(), + }) + ) + .mutation(async ({ input }) => { + const { updateTask } = await import("./db"); + const { taskId, ...updates } = input; + return updateTask(taskId, updates as any); + }), + + delete: publicProcedure + .input(z.object({ taskId: z.number() })) + .mutation(async ({ input }) => { + const { deleteTask } = await import("./db"); + return deleteTask(input.taskId); + }), + + getPending: publicProcedure + .input(z.object({ agentId: z.number() })) + .query(async ({ input }) => { + const { getPendingAgentTasks } = await import("./db"); + return getPendingAgentTasks(input.agentId); + }), + }), }); export type AppRouter = typeof appRouter; diff --git a/server/tasks.test.ts b/server/tasks.test.ts new file mode 100644 index 0000000..58ce978 --- /dev/null +++ b/server/tasks.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { + createTask, + getAgentTasks, + getTaskById, + updateTask, + deleteTask, +} from "./db"; + +describe("Tasks Management", () => { + const testAgentId = 1; + + describe("createTask", () => { + it("should create a new task", async () => { + const task = await createTask({ + agentId: testAgentId, + title: "Test Task", + description: "This is a test task", + status: "pending", + priority: "high", + }); + + expect(task).toBeDefined(); + expect(task?.title).toBe("Test Task"); + expect(task?.status).toBe("pending"); + expect(task?.priority).toBe("high"); + }); + }); + + describe("getAgentTasks", () => { + it("should retrieve all tasks for an agent", async () => { + const tasks = await getAgentTasks(testAgentId); + expect(Array.isArray(tasks)).toBe(true); + }); + }); + + describe("getTaskById", () => { + it("should return null for non-existent task", async () => { + const task = await getTaskById(99999); + expect(task).toBeNull(); + }); + }); + + describe("updateTask", () => { + it("should update task status", async () => { + const task = await createTask({ + agentId: testAgentId, + title: "Update Test", + status: "pending", + priority: "medium", + }); + + if (task?.id) { + const updated = await updateTask(task.id, { + status: "in_progress", + }); + + expect(updated?.status).toBe("in_progress"); + } + }); + }); + + describe("deleteTask", () => { + it("should return false for non-existent task", async () => { + const success = await deleteTask(99999); + expect(success).toBe(false); + }); + }); +}); diff --git a/todo.md b/todo.md index 86aa2d0..c576679 100644 --- a/todo.md +++ b/todo.md @@ -226,3 +226,15 @@ - [x] Update Chat.tsx: retry state, auto-retry on network errors, retry indicator - [x] Write vitest tests for retry logic (17 tests, all pass — 103 total tests pass) - [ ] Commit to Gitea and deploy to production (Phase 17) + + +## Phase 19: Complete Task Management System & Final Integration +- [x] Phase 19.1: Add tasks table to drizzle/schema.ts with full schema +- [x] Phase 19.2: Create query helpers in server/db.ts (createTask, getAgentTasks, etc) +- [x] Phase 19.3: Create tRPC endpoints in server/routers.ts (tasks.create, tasks.list, etc) +- [x] Phase 19.4: Create TasksPanel React component for right sidebar +- [x] Phase 19.5: Add auto-task creation functions in orchestrator.ts +- [x] Phase 19.6: Integrate TasksPanel into Chat UI with conversationId tracking +- [x] Phase 19.7: Write vitest tests for tasks (107 tests pass, 1 fails due to missing DB table) +- [ ] Phase 19.8: Run pnpm db:push on production to create tasks table +- [ ] Phase 19.9: Commit to Gitea and deploy to production