From 7b3e94d6dc4bb8b25115402fa3fb40bce9e9f9f1 Mon Sep 17 00:00:00 2001 From: jcfs Date: Mon, 12 Jan 2026 02:02:47 +0000 Subject: [PATCH] feat(backend): add persistent agent session tracking - Add AgentSession database entity with SQLite persistence - Implement AgentSession CRUD operations in repository layer - Update executor to use hybrid cache + database approach - Add session restoration on startup (LoadActiveSessionsFromDB) - Link comments to agent sessions via agent_session_id - Fix SQLite NULL handling for nullable task fields - Add dev database setup with seed data (make dev, make dev-db-reset) - Update Makefile with dev targets for development mode This enables agent sessions to persist across backend restarts, solving the 'execution not found' error for resumed conversations. --- apps/backend/Makefile | 24 +- apps/backend/cmd/kandev/main.go | 182 +++- apps/backend/dev/init-db.sh | 74 ++ apps/backend/dev/seed.sql | 76 ++ .../internal/agent/lifecycle/manager.go | 117 ++- apps/backend/internal/events/types.go | 4 +- .../internal/integration/integration_test.go | 561 ------------ .../internal/integration/orchestrator_test.go | 2 +- .../internal/orchestrator/acp/handler.go | 174 ---- .../internal/orchestrator/acp/memory_store.go | 152 ---- .../orchestrator/acp/memory_store_test.go | 352 -------- .../internal/orchestrator/acp/sqlite_store.go | 242 ----- .../orchestrator/acp/sqlite_store_test.go | 823 ------------------ .../orchestrator/controller/controller.go | 42 +- .../orchestrator/controller/errors.go | 8 +- apps/backend/internal/orchestrator/dto/dto.go | 21 - .../orchestrator/executor/executor.go | 319 ++++++- .../orchestrator/handlers/handlers.go | 26 - .../orchestrator/scheduler/scheduler_test.go | 36 +- apps/backend/internal/orchestrator/service.go | 44 +- .../internal/orchestrator/watcher/watcher.go | 62 +- apps/backend/internal/task/models/models.go | 110 ++- .../internal/task/repository/interface.go | 11 + .../internal/task/repository/memory.go | 142 +++ .../internal/task/repository/sqlite.go | 399 ++++++++- apps/backend/internal/task/service/service.go | 49 +- apps/backend/pkg/api/v1/task.go | 27 +- apps/backend/pkg/websocket/actions.go | 3 +- 28 files changed, 1514 insertions(+), 2568 deletions(-) create mode 100755 apps/backend/dev/init-db.sh create mode 100644 apps/backend/dev/seed.sql delete mode 100644 apps/backend/internal/orchestrator/acp/handler.go delete mode 100644 apps/backend/internal/orchestrator/acp/memory_store.go delete mode 100644 apps/backend/internal/orchestrator/acp/memory_store_test.go delete mode 100644 apps/backend/internal/orchestrator/acp/sqlite_store.go delete mode 100644 apps/backend/internal/orchestrator/acp/sqlite_store_test.go diff --git a/apps/backend/Makefile b/apps/backend/Makefile index e57641e2e1..152fec9d4c 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -14,7 +14,7 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) -.PHONY: all build clean run test lint fmt vet help +.PHONY: all build clean run dev dev-db dev-db-reset test lint fmt vet help ## Default target all: build @@ -42,6 +42,25 @@ run: build @echo "Running $(BINARY_NAME)..." ./$(BUILD_DIR)/$(BINARY_NAME) +## Run in development mode with pre-seeded database +dev: build dev-db + @echo "Running $(BINARY_NAME) in development mode..." + KANDEV_DB_PATH=./dev/kandev-dev.db ./$(BUILD_DIR)/$(BINARY_NAME) + +## Initialize/reset the development database +dev-db: + @if [ ! -f ./dev/kandev-dev.db ]; then \ + echo "Initializing development database..."; \ + ./dev/init-db.sh; \ + else \ + echo "Development database already exists. Use 'make dev-db-reset' to recreate."; \ + fi + +## Reset the development database (recreate with fresh seed data) +dev-db-reset: + @echo "Resetting development database..." + ./dev/init-db.sh --reset + ## Run tests test: @echo "Running tests..." @@ -99,6 +118,9 @@ help: @echo " build-all Build all binaries (unified + individual services)" @echo " clean Remove build artifacts" @echo " run Build and run the application" + @echo " dev Run in development mode with pre-seeded database" + @echo " dev-db Initialize development database (if not exists)" + @echo " dev-db-reset Reset development database with fresh seed data" @echo " test Run tests" @echo " test-coverage Run tests with coverage report" @echo " lint Run golangci-lint" diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 1bcfffb8fb..4622d5e608 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -46,8 +46,7 @@ import ( "github.com/kandev/kandev/internal/task/repository" taskservice "github.com/kandev/kandev/internal/task/service" - // ACP - "github.com/kandev/kandev/internal/orchestrator/acp" + // ACP protocol "github.com/kandev/kandev/pkg/acp/protocol" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" @@ -141,15 +140,9 @@ func main() { ) log.Info("Task Service initialized") - // Create SQLite message store for ACP log persistence - // Get the underlying *sql.DB from the SQLite repository - sqliteRepo, ok := taskRepo.(*repository.SQLiteRepository) - if !ok { - log.Fatal("Expected SQLiteRepository for ACP message store") - } - acpMessageStore := acp.NewSQLiteMessageStore(sqliteRepo.DB()) - acpHandler := acp.NewHandler(acpMessageStore, log) - log.Info("ACP message store initialized for persistent log storage") + // ACP messages are now stored as comments in the task_comments table + // The comment system provides unified storage for all task-related communication + log.Info("ACP messages will be stored as comments") // ============================================ // AGENT MANAGER @@ -202,7 +195,7 @@ func main() { } serviceCfg := orchestrator.DefaultServiceConfig() - orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, nil, agentManagerClient, taskRepoAdapter, log) + orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, nil, agentManagerClient, taskRepoAdapter, taskRepo, log) // Set up comment creator for saving agent responses as comments orchestratorSvc.SetCommentCreator(&commentCreatorAdapter{svc: taskSvc}) @@ -223,7 +216,6 @@ func main() { // Create orchestrator controller and handlers (Pattern A) orchestratorCtrl := orchestratorcontroller.NewController(orchestratorSvc) - orchestratorCtrl.SetACPHandler(acpHandler) orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorCtrl, log) orchestratorHandlers.RegisterHandlers(gateway.Dispatcher) log.Info("Registered Orchestrator WebSocket handlers") @@ -241,22 +233,35 @@ func main() { gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log) // Set up historical logs provider for task subscriptions + // Uses comments instead of execution logs - all agent messages are now comments gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) { - messages, err := acpHandler.GetAllMessages(ctx, taskID) + comments, err := taskSvc.ListComments(ctx, taskID) if err != nil { return nil, err } - // Convert protocol.Message to ws.Message notifications - result := make([]*ws.Message, 0, len(messages)) - for _, msg := range messages { - action := "acp." + string(msg.Type) - notification, err := ws.NewNotification(action, map[string]interface{}{ - "task_id": taskID, - "type": msg.Type, - "data": msg.Data, - "timestamp": msg.Timestamp, - }) + // Convert comments to ws.Message notifications + result := make([]*ws.Message, 0, len(comments)) + for _, comment := range comments { + // Determine the action based on comment type + action := ws.ActionCommentAdded + payload := map[string]interface{}{ + "comment_id": comment.ID, + "task_id": comment.TaskID, + "author_type": string(comment.AuthorType), + "author_id": comment.AuthorID, + "content": comment.Content, + "type": string(comment.Type), + "requests_input": comment.RequestsInput, + "created_at": comment.CreatedAt.Format(time.RFC3339), + } + if comment.ACPSessionID != "" { + payload["acp_session_id"] = comment.ACPSessionID + } + if comment.Metadata != nil { + payload["metadata"] = comment.Metadata + } + notification, err := ws.NewNotification(action, payload) if err != nil { continue } @@ -264,17 +269,13 @@ func main() { } return result, nil }) - log.Info("Historical logs provider configured for task subscriptions") + log.Info("Historical logs provider configured for task subscriptions (using comments)") - // Wire ACP handler to persist messages to SQLite database - orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) { - if err := acpHandler.ProcessMessage(context.Background(), msg); err != nil { - log.Error("failed to persist ACP message", - zap.String("task_id", taskID), - zap.String("message_type", string(msg.Type)), - zap.Error(err)) - } - }) + // NOTE: We no longer create comments for each ACP message chunk. + // Instead, the lifecycle manager accumulates message chunks and: + // 1. Flushes the buffer as a comment when a tool use starts (step boundary) + // 2. Flushes the buffer as a comment when the prompt completes (final response) + // This is handled via the prompt.complete and step.complete events. // Wire ACP handler to broadcast to WebSocket clients as notifications orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) { @@ -369,15 +370,20 @@ func main() { return nil } - notification, _ := ws.NewNotification(ws.ActionCommentAdded, map[string]interface{}{ + payload := map[string]interface{}{ "task_id": taskID, "comment_id": data["comment_id"], "author_type": data["author_type"], "author_id": data["author_id"], "content": data["content"], + "type": data["type"], "requests_input": data["requests_input"], "created_at": data["created_at"], - }) + } + if metadata, ok := data["metadata"]; ok && metadata != nil { + payload["metadata"] = metadata + } + notification, _ := ws.NewNotification(ws.ActionCommentAdded, payload) gateway.Hub.BroadcastToTask(taskID, notification) return nil }) @@ -513,10 +519,17 @@ func newLifecycleAdapter(mgr *lifecycle.Manager, reg *registry.Registry, log *lo // LaunchAgent starts a new agent container for a task func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + // The RepositoryURL field should contain a local filesystem path for the workspace + // If it's empty, we cannot mount the workspace into the container + workspacePath := req.RepositoryURL + if workspacePath == "" { + return nil, fmt.Errorf("task has no repository_url configured - please set a local repository path on the task") + } + launchReq := &lifecycle.LaunchRequest{ TaskID: req.TaskID, AgentType: req.AgentType, - WorkspacePath: req.RepositoryURL, // Use repository URL as workspace path + WorkspacePath: workspacePath, TaskDescription: req.TaskDescription, Metadata: req.Metadata, } @@ -638,11 +651,98 @@ type commentCreatorAdapter struct { } // CreateAgentComment creates a comment with author_type="agent" -func (a *commentCreatorAdapter) CreateAgentComment(ctx context.Context, taskID, content string) error { +func (a *commentCreatorAdapter) CreateAgentComment(ctx context.Context, taskID, content, agentSessionID string) error { _, err := a.svc.CreateComment(ctx, &taskservice.CreateCommentRequest{ - TaskID: taskID, - Content: content, - AuthorType: "agent", + TaskID: taskID, + Content: content, + AuthorType: "agent", + AgentSessionID: agentSessionID, }) return err } + +// CreateToolCallComment creates a comment for a tool call with type="tool_call" +func (a *commentCreatorAdapter) CreateToolCallComment(ctx context.Context, taskID, toolCallID, title, status, agentSessionID string) error { + _, err := a.svc.CreateComment(ctx, &taskservice.CreateCommentRequest{ + TaskID: taskID, + Content: title, + AuthorType: "agent", + AgentSessionID: agentSessionID, + Type: "tool_call", + Metadata: map[string]interface{}{ + "tool_call_id": toolCallID, + "title": title, + "status": status, + }, + }) + return err +} + +// mapACPTypeToCommentType maps ACP message types to comment types +func mapACPTypeToCommentType(msgType protocol.MessageType) string { + switch msgType { + case protocol.MessageTypeProgress: + return "progress" + case protocol.MessageTypeLog: + return "content" // Log messages are treated as content + case protocol.MessageTypeResult: + return "status" + case protocol.MessageTypeError: + return "error" + case protocol.MessageTypeStatus: + return "status" + case protocol.MessageTypeInputRequired: + return "content" + case protocol.MessageTypeSessionInfo: + return "status" + default: + return "content" + } +} + +// extractACPContent extracts a human-readable message from ACP data +func extractACPContent(msg *protocol.Message) string { + if msg.Data == nil { + return "" + } + + // Check for nested content.text structure (used by agent_message_chunk) + if content, ok := msg.Data["content"].(map[string]interface{}); ok { + if text, ok := content["text"].(string); ok && text != "" { + return text + } + } + + // Try common message fields + if message, ok := msg.Data["message"].(string); ok && message != "" { + return message + } + if text, ok := msg.Data["text"].(string); ok && text != "" { + return text + } + if errMsg, ok := msg.Data["error"].(string); ok && errMsg != "" { + return errMsg + } + if summary, ok := msg.Data["summary"].(string); ok && summary != "" { + return summary + } + if prompt, ok := msg.Data["prompt"].(string); ok && prompt != "" { + return prompt + } + + // For progress updates, generate a message + if msg.Type == protocol.MessageTypeProgress { + if progress, ok := msg.Data["progress"].(float64); ok { + return fmt.Sprintf("Progress: %d%%", int(progress)) + } + } + + // For status updates + if msg.Type == protocol.MessageTypeStatus { + if status, ok := msg.Data["status"].(string); ok && status != "" { + return fmt.Sprintf("Status: %s", status) + } + } + + return "" +} diff --git a/apps/backend/dev/init-db.sh b/apps/backend/dev/init-db.sh new file mode 100755 index 0000000000..6531582754 --- /dev/null +++ b/apps/backend/dev/init-db.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Initialize the development database with seed data +# Usage: ./dev/init-db.sh [--reset] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEV_DIR="$SCRIPT_DIR" +DB_PATH="$DEV_DIR/kandev-dev.db" +SEED_SQL="$DEV_DIR/seed.sql" +SEED_SQL_PROCESSED="$DEV_DIR/.seed-processed.sql" + +# Get the repository root path (three levels up from apps/backend/dev/) +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}=== Kandev Development Database Initialization ===${NC}" +echo -e "Repository path: ${YELLOW}$REPO_ROOT${NC}" + +# Check if reset flag is passed +if [[ "$1" == "--reset" ]]; then + echo -e "${YELLOW}Resetting database...${NC}" + rm -f "$DB_PATH" "$DB_PATH-wal" "$DB_PATH-shm" +fi + +# Check if database already exists +if [[ -f "$DB_PATH" ]]; then + echo -e "${YELLOW}Database already exists at: $DB_PATH${NC}" + echo "Use --reset flag to recreate: ./dev/init-db.sh --reset" + exit 0 +fi + +# Build the backend to ensure schema is created +echo -e "${GREEN}Building backend...${NC}" +cd "$SCRIPT_DIR/.." +go build -o /tmp/kandev-init ./cmd/kandev + +# Create the database by running the backend briefly (to create schema) +echo -e "${GREEN}Creating database schema...${NC}" +KANDEV_DB_PATH="$DB_PATH" timeout 2 /tmp/kandev-init 2>/dev/null || true + +# Wait for the database file to be created +sleep 1 + +if [[ ! -f "$DB_PATH" ]]; then + echo -e "${RED}Failed to create database${NC}" + exit 1 +fi + +# Process seed.sql - replace $KANDEV_REPO_PATH with actual path +echo -e "${GREEN}Processing seed data...${NC}" +sed "s|\\\$KANDEV_REPO_PATH|$REPO_ROOT|g" "$SEED_SQL" > "$SEED_SQL_PROCESSED" + +# Apply seed data +echo -e "${GREEN}Applying seed data...${NC}" +sqlite3 "$DB_PATH" < "$SEED_SQL_PROCESSED" + +# Clean up processed file +rm -f "$SEED_SQL_PROCESSED" + +echo -e "${GREEN}=== Database initialized successfully ===${NC}" +echo -e "Database path: ${YELLOW}$DB_PATH${NC}" +echo "" +echo "To run the backend with this database:" +echo -e " ${YELLOW}KANDEV_DB_PATH=$DB_PATH ./bin/kandev${NC}" +echo "" +echo "Or use the Makefile target:" +echo -e " ${YELLOW}make dev${NC}" + diff --git a/apps/backend/dev/seed.sql b/apps/backend/dev/seed.sql new file mode 100644 index 0000000000..d9a8689b7a --- /dev/null +++ b/apps/backend/dev/seed.sql @@ -0,0 +1,76 @@ +-- Seed data for development database +-- This file is used to populate the dev database with sample data + +-- Default workspace (created by initSchema, just update if needed) +INSERT OR REPLACE INTO workspaces (id, name, description, owner_id, created_at, updated_at) +VALUES ('default', 'Default Workspace', 'Your default workspace', '', datetime('now'), datetime('now')); + +-- Sample board +INSERT OR REPLACE INTO boards (id, workspace_id, name, description, created_at, updated_at) +VALUES + ('dev-board-1', 'default', 'Kandev Development', 'Main development board for kandev', datetime('now'), datetime('now')), + ('dev-board-2', 'default', 'Feature Backlog', 'Upcoming features and ideas', datetime('now'), datetime('now')); + +-- Columns for dev-board-1 +INSERT OR REPLACE INTO columns (id, board_id, name, position, state, created_at, updated_at) +VALUES + ('col-backlog', 'dev-board-1', 'Backlog', 0, 'TODO', datetime('now'), datetime('now')), + ('col-todo', 'dev-board-1', 'To Do', 1, 'TODO', datetime('now'), datetime('now')), + ('col-inprogress', 'dev-board-1', 'In Progress', 2, 'IN_PROGRESS', datetime('now'), datetime('now')), + ('col-review', 'dev-board-1', 'Review', 3, 'REVIEW', datetime('now'), datetime('now')), + ('col-done', 'dev-board-1', 'Done', 4, 'COMPLETED', datetime('now'), datetime('now')); + +-- Columns for dev-board-2 +INSERT OR REPLACE INTO columns (id, board_id, name, position, state, created_at, updated_at) +VALUES + ('col-ideas', 'dev-board-2', 'Ideas', 0, 'TODO', datetime('now'), datetime('now')), + ('col-planned', 'dev-board-2', 'Planned', 1, 'TODO', datetime('now'), datetime('now')), + ('col-scheduled', 'dev-board-2', 'Scheduled', 2, 'IN_PROGRESS', datetime('now'), datetime('now')); + +-- Sample tasks for dev-board-1 +-- NOTE: repository_url should be an absolute path to a local git repository +-- The placeholder $KANDEV_REPO_PATH will be replaced by init-db.sh with the actual path +INSERT OR REPLACE INTO tasks (id, board_id, column_id, title, description, state, priority, position, agent_type, repository_url, branch, metadata, created_at, updated_at) +VALUES + -- Backlog tasks (no agent assigned) + ('task-001', 'dev-board-1', 'col-backlog', 'Add dark mode support', 'Implement a dark theme toggle for the UI', 'TODO', 3, 0, '', '', '', '{}', datetime('now'), datetime('now')), + ('task-002', 'dev-board-1', 'col-backlog', 'Implement search functionality', 'Add full-text search for tasks across all boards', 'TODO', 2, 1, '', '', '', '{}', datetime('now'), datetime('now')), + + -- To Do tasks (agent assigned with repository) + ('task-003', 'dev-board-1', 'col-todo', 'Fix WebSocket reconnection', 'Handle WebSocket disconnects gracefully and auto-reconnect', 'TODO', 5, 0, 'augment-agent', '$KANDEV_REPO_PATH', 'main', '{}', datetime('now'), datetime('now')), + ('task-004', 'dev-board-1', 'col-todo', 'Add task labels/tags', 'Allow users to add colored labels to tasks for categorization', 'TODO', 2, 1, '', '', '', '{}', datetime('now'), datetime('now')), + + -- In Progress tasks (agent with repository) + ('task-005', 'dev-board-1', 'col-inprogress', 'Agent session persistence', 'Persist agent sessions in database instead of memory', 'IN_PROGRESS', 8, 0, 'augment-agent', '$KANDEV_REPO_PATH', 'main', '{}', datetime('now'), datetime('now')), + + -- Review tasks + ('task-006', 'dev-board-1', 'col-review', 'Comment system improvements', 'Enhanced comment display with markdown and tool calls', 'REVIEW', 6, 0, 'augment-agent', '$KANDEV_REPO_PATH', 'main', '{}', datetime('now'), datetime('now')), + + -- Done tasks + ('task-007', 'dev-board-1', 'col-done', 'Basic kanban board UI', 'Create the initial kanban board with drag-and-drop', 'COMPLETED', 10, 0, '', '', '', '{}', datetime('now', '-3 days'), datetime('now', '-1 day')), + ('task-008', 'dev-board-1', 'col-done', 'WebSocket API implementation', 'Implement real-time WebSocket communication', 'COMPLETED', 9, 1, '', '', '', '{}', datetime('now', '-5 days'), datetime('now', '-2 days')); + +-- Sample tasks for dev-board-2 (Feature Backlog) +INSERT OR REPLACE INTO tasks (id, board_id, column_id, title, description, state, priority, position, agent_type, repository_url, branch, metadata, created_at, updated_at) +VALUES + ('task-101', 'dev-board-2', 'col-ideas', 'Multi-user collaboration', 'Allow multiple users to work on the same board', 'TODO', 5, 0, '', '', '', '{}', datetime('now'), datetime('now')), + ('task-102', 'dev-board-2', 'col-ideas', 'Task dependencies', 'Add ability to link tasks as dependencies', 'TODO', 4, 1, '', '', '', '{}', datetime('now'), datetime('now')), + ('task-103', 'dev-board-2', 'col-planned', 'Agent type marketplace', 'Browse and install different agent types', 'TODO', 6, 0, '', '', '', '{}', datetime('now'), datetime('now')); + +-- Sample comments on tasks +INSERT OR REPLACE INTO task_comments (id, task_id, author_type, author_id, content, type, metadata, requests_input, acp_session_id, agent_session_id, created_at) +VALUES + -- Comments on task-005 (Agent session persistence) + ('comment-001', 'task-005', 'user', 'dev-user', 'Starting work on database schema for agent sessions', 'message', '{}', 0, '', '', datetime('now', '-2 hours')), + ('comment-002', 'task-005', 'agent', 'augment-agent', 'I''ll help you implement the agent session persistence. Let me start by analyzing the current codebase structure.', 'message', '{}', 0, '', '', datetime('now', '-1 hour')), + ('comment-003', 'task-005', 'agent', 'augment-agent', 'codebase-retrieval', 'tool_call', '{"tool_call_id": "tc-001", "title": "codebase-retrieval: Looking for agent execution tracking", "status": "completed"}', 0, '', '', datetime('now', '-55 minutes')), + + -- Comments on task-006 (Comment system) + ('comment-004', 'task-006', 'user', 'dev-user', 'Need to add support for tool call rendering in the chat panel', 'message', '{}', 0, '', '', datetime('now', '-1 day')), + ('comment-005', 'task-006', 'agent', 'augment-agent', 'I''ve implemented the tool call comment type with proper rendering. The changes include:\n\n1. Added `type` field to comments\n2. Added `metadata` for tool call details\n3. Updated the frontend to render tool calls differently', 'message', '{}', 0, '', '', datetime('now', '-20 hours')); + +-- Sample agent session +INSERT OR REPLACE INTO agent_sessions (id, task_id, agent_instance_id, agent_type, acp_session_id, status, progress, error_message, metadata, started_at, completed_at, updated_at) +VALUES + ('session-001', 'task-005', 'agent-instance-123', 'augment-agent', 'acp-session-abc', 'running', 0.0, '', '{}', datetime('now', '-1 hour'), NULL, datetime('now')); + diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 43403f6864..7b48970f89 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -11,6 +11,7 @@ import ( "sync" "time" + acp "github.com/coder/acp-go-sdk" "github.com/google/uuid" "go.uber.org/zap" @@ -477,7 +478,7 @@ func (m *Manager) handleSessionNotification(instance *AgentInstance, notificatio zap.String("instance_id", instance.ID)) m.updateInstanceProgress(instance.ID, 50) - // Accumulate message content for saving as comment when prompt completes + // Accumulate message content for saving as comment when a step completes if update.AgentMessageChunk.Content.Text != nil { instance.messageMu.Lock() instance.messageBuffer.WriteString(update.AgentMessageChunk.Content.Text.Text) @@ -491,27 +492,45 @@ func (m *Manager) handleSessionNotification(instance *AgentInstance, notificatio } if update.ToolCall != nil { + // Tool call starting marks a step boundary - flush the accumulated message as a comment + // This way, each agent response before a tool use becomes a separate comment + m.flushMessageBufferAsComment(instance) + + toolCallID := string(update.ToolCall.ToolCallId) + toolTitle := update.ToolCall.Title + toolStatus := string(update.ToolCall.Status) + m.logger.Info("tool call started", zap.String("instance_id", instance.ID), - zap.String("tool_call_id", string(update.ToolCall.ToolCallId))) + zap.String("tool_call_id", toolCallID), + zap.String("title", toolTitle)) m.updateInstanceProgress(instance.ID, 60) + + // Publish tool call as a comment so it appears in the chat + m.publishToolCall(instance, toolCallID, toolTitle, toolStatus, nil) } if update.ToolCallUpdate != nil { + toolCallID := string(update.ToolCallUpdate.ToolCallId) + m.logger.Debug("tool call update", zap.String("instance_id", instance.ID), - zap.String("tool_call_id", string(update.ToolCallUpdate.ToolCallId))) + zap.String("tool_call_id", toolCallID)) // Check if tool call completed if update.ToolCallUpdate.Status != nil { - switch *update.ToolCallUpdate.Status { + status := string(*update.ToolCallUpdate.Status) + switch status { case "complete": m.logger.Info("tool call completed", zap.String("instance_id", instance.ID)) m.updateInstanceProgress(instance.ID, 80) + // Publish tool call completion + m.publishToolCallComplete(instance, toolCallID, update.ToolCallUpdate) case "error": m.logger.Error("tool call error", zap.String("instance_id", instance.ID)) + m.publishToolCallComplete(instance, toolCallID, update.ToolCallUpdate) } } } @@ -624,6 +643,96 @@ func (m *Manager) publishPromptComplete(instance *AgentInstance, agentMessage st } } +// flushMessageBufferAsComment extracts any accumulated message from the buffer and +// publishes it as a step complete event (which will be saved as a comment). +// This is called when a tool use starts to save the agent's response before the tool call. +func (m *Manager) flushMessageBufferAsComment(instance *AgentInstance) { + instance.messageMu.Lock() + agentMessage := instance.messageBuffer.String() + instance.messageBuffer.Reset() + instance.messageMu.Unlock() + + // Only publish if there's actual content (ignore whitespace-only) + trimmed := strings.TrimSpace(agentMessage) + if trimmed == "" { + return + } + + m.logger.Debug("flushing message buffer as step comment", + zap.String("instance_id", instance.ID), + zap.String("task_id", instance.TaskID), + zap.Int("message_length", len(agentMessage))) + + // Reuse the prompt_complete event type - the orchestrator handles it the same way + m.publishPromptComplete(instance, agentMessage) +} + +// publishToolCall publishes a tool call start event (to be saved as a comment) +func (m *Manager) publishToolCall(instance *AgentInstance, toolCallID, title, status string, args map[string]interface{}) { + if m.eventBus == nil { + return + } + + data := map[string]interface{}{ + "type": "tool_call", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "agent_id": instance.ID, + "task_id": instance.TaskID, + "tool_call_id": toolCallID, + "title": title, + "status": status, + } + if args != nil { + data["args"] = args + } + + event := bus.NewEvent(events.ToolCallStarted, "agent-manager", data) + subject := events.ToolCallStarted + "." + instance.TaskID + + if err := m.eventBus.Publish(context.Background(), subject, event); err != nil { + m.logger.Error("failed to publish tool_call event", + zap.String("instance_id", instance.ID), + zap.String("task_id", instance.TaskID), + zap.Error(err)) + } else { + m.logger.Debug("published tool_call event", + zap.String("task_id", instance.TaskID), + zap.String("tool_call_id", toolCallID), + zap.String("title", title)) + } +} + +// publishToolCallComplete publishes a tool call completion event +func (m *Manager) publishToolCallComplete(instance *AgentInstance, toolCallID string, update *acp.SessionToolCallUpdate) { + if m.eventBus == nil { + return + } + + status := "" + if update.Status != nil { + status = string(*update.Status) + } + + data := map[string]interface{}{ + "type": "tool_call_complete", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "agent_id": instance.ID, + "task_id": instance.TaskID, + "tool_call_id": toolCallID, + "status": status, + } + + event := bus.NewEvent(events.ToolCallComplete, "agent-manager", data) + subject := events.ToolCallComplete + "." + instance.TaskID + + if err := m.eventBus.Publish(context.Background(), subject, event); err != nil { + m.logger.Error("failed to publish tool_call_complete event", + zap.String("instance_id", instance.ID), + zap.String("task_id", instance.TaskID), + zap.Error(err)) + } +} + // updateInstanceProgress updates an instance's progress func (m *Manager) updateInstanceProgress(instanceID string, progress int) { m.mu.Lock() diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 3a5ba0a094..4c2653c5ea 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -66,7 +66,9 @@ const ( // Event types for agent prompts const ( - PromptComplete = "prompt.complete" // Agent finished responding to a prompt + PromptComplete = "prompt.complete" // Agent finished responding to a prompt + ToolCallStarted = "tool_call.started" // Agent started a tool call + ToolCallComplete = "tool_call.complete" // Agent finished a tool call ) // BuildACPSubject creates an ACP subject for a specific task diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index 5969f1c6a4..146c898946 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -20,12 +20,10 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" - "github.com/kandev/kandev/internal/orchestrator/acp" taskcontroller "github.com/kandev/kandev/internal/task/controller" taskhandlers "github.com/kandev/kandev/internal/task/handlers" "github.com/kandev/kandev/internal/task/repository" taskservice "github.com/kandev/kandev/internal/task/service" - "github.com/kandev/kandev/pkg/acp/protocol" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -925,565 +923,6 @@ func TestTaskSubscription(t *testing.T) { }) } -// ============================================ -// HISTORICAL LOGS TESTS -// ============================================ - -// TestServerWithACP holds the test server with ACP handler support -type TestServerWithACP struct { - *TestServer - ACPHandler *acp.Handler - MessageStore *acp.MemoryMessageStore -} - -// NewTestServerWithACP creates a test server with ACP handler for historical logs -func NewTestServerWithACP(t *testing.T) *TestServerWithACP { - t.Helper() - - // Initialize logger (quiet for tests) - log, err := logger.NewLogger(logger.LoggingConfig{ - Level: "error", - Format: "console", - }) - require.NoError(t, err) - - // Create context - ctx, cancel := context.WithCancel(context.Background()) - - // Initialize event bus - eventBus := bus.NewMemoryEventBus(log) - - // Initialize task repository (in-memory for tests) - taskRepo := repository.NewMemoryRepository() - - // Initialize task service - taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) - - // Create ACP message store and handler - messageStore := acp.NewMemoryMessageStore(1000) - acpHandler := acp.NewHandler(messageStore, log) - - // Create WebSocket gateway - gateway := gateways.NewGateway(log) - - // Register a mock handler for task.logs that uses the ACP handler - gateway.Dispatcher.RegisterFunc(ws.ActionTaskLogs, func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req struct { - TaskID string `json:"task_id"` - Limit int `json:"limit,omitempty"` - } - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - - // Get all historical messages from the store - messages, err := acpHandler.GetAllMessages(ctx, req.TaskID) - if err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to retrieve logs: "+err.Error(), nil) - } - - // Apply limit if specified - if req.Limit > 0 && len(messages) > req.Limit { - messages = messages[len(messages)-req.Limit:] - } - - type LogEntry struct { - Type string `json:"type"` - Timestamp string `json:"timestamp"` - AgentID string `json:"agent_id,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` - } - - logs := make([]LogEntry, 0, len(messages)) - for _, m := range messages { - logs = append(logs, LogEntry{ - Type: string(m.Type), - Timestamp: m.Timestamp.Format("2006-01-02T15:04:05Z"), - AgentID: m.AgentID, - Data: m.Data, - }) - } - - return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ - "task_id": req.TaskID, - "logs": logs, - "total": len(logs), - }) - }) - - // Start hub - go gateway.Hub.Run(ctx) - - // Set up historical logs provider for task subscriptions - gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) { - messages, err := acpHandler.GetAllMessages(ctx, taskID) - if err != nil { - return nil, err - } - - // Convert protocol.Message to ws.Message notifications - result := make([]*ws.Message, 0, len(messages)) - for _, msg := range messages { - action := "acp." + string(msg.Type) - notification, err := ws.NewNotification(action, map[string]interface{}{ - "task_id": taskID, - "type": msg.Type, - "data": msg.Data, - "timestamp": msg.Timestamp, - }) - if err != nil { - continue - } - result = append(result, notification) - } - return result, nil - }) - - // Create router - gin.SetMode(gin.TestMode) - router := gin.New() - gateway.SetupRoutes(router) - - // Register handlers (HTTP + WS) - workspaceController := taskcontroller.NewWorkspaceController(taskSvc) - boardController := taskcontroller.NewBoardController(taskSvc) - taskController := taskcontroller.NewTaskController(taskSvc) - taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) - taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) - taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) - - // Create test server - server := httptest.NewServer(router) - - return &TestServerWithACP{ - TestServer: &TestServer{ - Server: server, - Gateway: gateway, - TaskRepo: taskRepo, - TaskSvc: taskSvc, - EventBus: eventBus, - Logger: log, - cancelFunc: cancel, - }, - ACPHandler: acpHandler, - MessageStore: messageStore, - } -} - -func TestTaskLogsAction(t *testing.T) { - ts := NewTestServerWithACP(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createWorkspace(t, client) - - // Create a task first - boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Logs Test Board", - }) - var boardPayload map[string]interface{} - boardResp.ParsePayload(&boardPayload) - boardID := boardPayload["id"].(string) - - colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{ - "board_id": boardID, - "name": "To Do", - "position": 0, - }) - var colPayload map[string]interface{} - colResp.ParsePayload(&colPayload) - columnID := colPayload["id"].(string) - - taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "board_id": boardID, - "column_id": columnID, - "title": "Task With Logs", - }) - var taskPayload map[string]interface{} - taskResp.ParsePayload(&taskPayload) - taskID := taskPayload["id"].(string) - - ctx := context.Background() - - // Test empty logs - t.Run("EmptyLogs", func(t *testing.T) { - resp, err := client.SendRequest("logs-1", ws.ActionTaskLogs, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.Equal(t, taskID, payload["task_id"]) - logs, ok := payload["logs"].([]interface{}) - require.True(t, ok) - assert.Len(t, logs, 0) - assert.Equal(t, float64(0), payload["total"]) - }) - - // Store some ACP messages - now := time.Now().UTC() - messages := []*protocol.Message{ - { - Type: protocol.MessageTypeProgress, - Timestamp: now.Add(-3 * time.Second), - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"progress": 25, "message": "Starting"}, - }, - { - Type: protocol.MessageTypeLog, - Timestamp: now.Add(-2 * time.Second), - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"message": "Processing file1.go", "level": "info"}, - }, - { - Type: protocol.MessageTypeProgress, - Timestamp: now.Add(-1 * time.Second), - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"progress": 50, "message": "Halfway done"}, - }, - } - - for _, msg := range messages { - err := ts.ACPHandler.ProcessMessage(ctx, msg) - require.NoError(t, err) - } - - // Test retrieval with logs - t.Run("RetrieveLogs", func(t *testing.T) { - resp, err := client.SendRequest("logs-2", ws.ActionTaskLogs, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - assert.Equal(t, taskID, payload["task_id"]) - logs, ok := payload["logs"].([]interface{}) - require.True(t, ok) - assert.Len(t, logs, 3) - assert.Equal(t, float64(3), payload["total"]) - - // Verify logs are in chronological order - log0 := logs[0].(map[string]interface{}) - log1 := logs[1].(map[string]interface{}) - log2 := logs[2].(map[string]interface{}) - - assert.Equal(t, "progress", log0["type"]) - assert.Equal(t, "log", log1["type"]) - assert.Equal(t, "progress", log2["type"]) - - // Verify log entries contain required fields - assert.NotEmpty(t, log0["timestamp"]) - assert.Equal(t, "agent-1", log0["agent_id"]) - assert.NotNil(t, log0["data"]) - }) - - // Test with limit parameter - t.Run("RetrieveLogsWithLimit", func(t *testing.T) { - resp, err := client.SendRequest("logs-3", ws.ActionTaskLogs, map[string]interface{}{ - "task_id": taskID, - "limit": 2, - }) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - logs, ok := payload["logs"].([]interface{}) - require.True(t, ok) - assert.Len(t, logs, 2) - assert.Equal(t, float64(2), payload["total"]) - - // Should return the last 2 logs (most recent) - log0 := logs[0].(map[string]interface{}) - log1 := logs[1].(map[string]interface{}) - assert.Equal(t, "log", log0["type"]) - assert.Equal(t, "progress", log1["type"]) - }) - - // Test validation - missing task_id - t.Run("MissingTaskID", func(t *testing.T) { - resp, err := client.SendRequest("logs-4", ws.ActionTaskLogs, map[string]interface{}{}) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeError, resp.Type) - }) -} - -func TestHistoricalLogsOnSubscribe(t *testing.T) { - ts := NewTestServerWithACP(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createWorkspace(t, client) - - // Create a task - boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Subscribe Test Board", - }) - var boardPayload map[string]interface{} - boardResp.ParsePayload(&boardPayload) - boardID := boardPayload["id"].(string) - - colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{ - "board_id": boardID, - "name": "To Do", - "position": 0, - }) - var colPayload map[string]interface{} - colResp.ParsePayload(&colPayload) - columnID := colPayload["id"].(string) - - taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "board_id": boardID, - "column_id": columnID, - "title": "Subscribable Task With History", - }) - var taskPayload map[string]interface{} - taskResp.ParsePayload(&taskPayload) - taskID := taskPayload["id"].(string) - - ctx := context.Background() - - // Store multiple ACP messages of different types - now := time.Now().UTC() - messages := []*protocol.Message{ - { - Type: protocol.MessageTypeProgress, - Timestamp: now.Add(-4 * time.Second), - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"progress": 10, "message": "Initializing"}, - }, - { - Type: protocol.MessageTypeLog, - Timestamp: now.Add(-3 * time.Second), - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"message": "Processing started", "level": "info"}, - }, - { - Type: protocol.MessageTypeProgress, - Timestamp: now.Add(-2 * time.Second), - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"progress": 100, "message": "Complete"}, - }, - { - Type: protocol.MessageTypeResult, - Timestamp: now.Add(-1 * time.Second), - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"summary": "Task completed successfully", "files_changed": 5}, - }, - } - - for _, msg := range messages { - err := ts.ACPHandler.ProcessMessage(ctx, msg) - require.NoError(t, err) - } - - // Subscribe to the task and receive historical logs - t.Run("SubscribeReceivesHistoricalLogs", func(t *testing.T) { - // Use the existing client - the previous requests don't trigger notifications - resp, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - assert.Equal(t, true, payload["success"]) - - // Collect historical log notifications - they are sent immediately after subscribe response - // Note: Some notifications may have been consumed by SendRequest while waiting for the response - receivedNotifications := make([]*ws.Message, 0, 4) - timeout := time.After(2 * time.Second) - collectLoop: - for { - select { - case msg := <-client.messages: - if msg.Type == ws.MessageTypeNotification && - strings.HasPrefix(msg.Action, "acp.") { - receivedNotifications = append(receivedNotifications, msg) - if len(receivedNotifications) >= 4 { - break collectLoop - } - } - case <-timeout: - break collectLoop - } - } - - // Verify we received at least some historical notifications - // Due to the way SendRequest consumes messages, we may not get all 4 - require.GreaterOrEqual(t, len(receivedNotifications), 1, "Should receive at least 1 historical notification") - - // Verify notifications have the correct action format (acp.{type}) - for _, notification := range receivedNotifications { - assert.True(t, strings.HasPrefix(notification.Action, "acp."), - "Notification action should start with 'acp.', got: %s", notification.Action) - - // Verify notification payload structure - var notifPayload map[string]interface{} - err = notification.ParsePayload(¬ifPayload) - require.NoError(t, err) - assert.Equal(t, taskID, notifPayload["task_id"]) - assert.NotNil(t, notifPayload["type"]) - assert.NotNil(t, notifPayload["data"]) - assert.NotNil(t, notifPayload["timestamp"]) - } - - // Unsubscribe - client.SendRequest("unsub-1", ws.ActionTaskUnsubscribe, map[string]interface{}{ - "task_id": taskID, - }) - }) -} - -func TestHistoricalLogsChronologicalOrder(t *testing.T) { - ts := NewTestServerWithACP(t) - defer ts.Close() - - client := NewWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createWorkspace(t, client) - - // Create a task - boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Chrono Test Board", - }) - var boardPayload map[string]interface{} - boardResp.ParsePayload(&boardPayload) - boardID := boardPayload["id"].(string) - - colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{ - "board_id": boardID, - "name": "To Do", - "position": 0, - }) - var colPayload map[string]interface{} - colResp.ParsePayload(&colPayload) - columnID := colPayload["id"].(string) - - taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "board_id": boardID, - "column_id": columnID, - "title": "Chronological Order Test Task", - }) - var taskPayload map[string]interface{} - taskResp.ParsePayload(&taskPayload) - taskID := taskPayload["id"].(string) - - ctx := context.Background() - - // Create messages in chronological order with specific timestamps - // In production, messages arrive in temporal order, so we test that they - // are returned in the same order they were stored - baseTime := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC) - - messagesInOrder := []*protocol.Message{ - { - Type: protocol.MessageTypeProgress, - Timestamp: baseTime.Add(10 * time.Second), // 10:00:10 - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"message": "First message", "order": 1}, - }, - { - Type: protocol.MessageTypeLog, - Timestamp: baseTime.Add(20 * time.Second), // 10:00:20 - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"message": "Second message", "order": 2}, - }, - { - Type: protocol.MessageTypeLog, - Timestamp: baseTime.Add(30 * time.Second), // 10:00:30 - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"message": "Third message", "order": 3}, - }, - { - Type: protocol.MessageTypeResult, - Timestamp: baseTime.Add(40 * time.Second), // 10:00:40 - AgentID: "agent-1", - TaskID: taskID, - Data: map[string]interface{}{"message": "Fourth message", "order": 4}, - }, - } - - for _, msg := range messagesInOrder { - err := ts.ACPHandler.ProcessMessage(ctx, msg) - require.NoError(t, err) - } - - // Retrieve logs via task.logs - t.Run("LogsChronologicalOrder", func(t *testing.T) { - resp, err := client.SendRequest("logs-chrono", ws.ActionTaskLogs, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - err = resp.ParsePayload(&payload) - require.NoError(t, err) - - logs, ok := payload["logs"].([]interface{}) - require.True(t, ok) - require.Len(t, logs, 4) - - // Verify order matches chronological order (same as insertion order here) - for i, log := range logs { - logEntry := log.(map[string]interface{}) - data := logEntry["data"].(map[string]interface{}) - expectedOrder := float64(i + 1) - assert.Equal(t, expectedOrder, data["order"], "Log at index %d should have order %v", i, expectedOrder) - } - - // Verify timestamps are in non-decreasing order - var prevTimestamp string - for i, log := range logs { - logEntry := log.(map[string]interface{}) - currentTimestamp := logEntry["timestamp"].(string) - if i > 0 { - assert.True(t, currentTimestamp >= prevTimestamp, - "Timestamp at index %d should be >= timestamp at index %d", i, i-1) - } - prevTimestamp = currentTimestamp - } - }) -} - // ============================================ // CONCURRENT REQUEST TESTS // ============================================ diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index 733ccdc680..0d13c8e4fb 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -465,7 +465,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { // Create orchestrator service cfg := orchestrator.DefaultServiceConfig() cfg.Scheduler.ProcessInterval = 50 * time.Millisecond // Faster for tests - orchestratorSvc := orchestrator.NewService(cfg, eventBus, nil, agentManager, taskRepoAdapter, log) + orchestratorSvc := orchestrator.NewService(cfg, eventBus, nil, agentManager, taskRepoAdapter, taskRepo, log) // Create WebSocket gateway gateway := gateways.NewGateway(log) diff --git a/apps/backend/internal/orchestrator/acp/handler.go b/apps/backend/internal/orchestrator/acp/handler.go deleted file mode 100644 index 27e021fa7a..0000000000 --- a/apps/backend/internal/orchestrator/acp/handler.go +++ /dev/null @@ -1,174 +0,0 @@ -// Package acp handles ACP message aggregation, storage, and distribution. -package acp - -import ( - "context" - "sync" - "time" - - "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/pkg/acp/protocol" - "go.uber.org/zap" -) - -// MessageStore interface for storing ACP messages -type MessageStore interface { - // Store saves an ACP message to the database - Store(ctx context.Context, msg *protocol.Message) error - - // GetMessages retrieves messages for a task - GetMessages(ctx context.Context, taskID string, limit int, since time.Time) ([]*protocol.Message, error) - - // GetAllMessages retrieves all messages for a task (for historical replay) - GetAllMessages(ctx context.Context, taskID string) ([]*protocol.Message, error) - - // GetLatestProgress retrieves the most recent progress for a task - GetLatestProgress(ctx context.Context, taskID string) (*protocol.ProgressData, error) -} - -// Handler manages ACP message processing -type Handler struct { - store MessageStore - logger *logger.Logger - - // Per-task message buffers for aggregation - buffers map[string]*messageBuffer - mu sync.RWMutex - - // Callbacks for real-time streaming - listeners map[string][]MessageListener - listenerMu sync.RWMutex -} - -// MessageListener is called when a new ACP message arrives for a task -type MessageListener func(msg *protocol.Message) - -// messageBuffer holds recent messages for a task -type messageBuffer struct { - taskID string - messages []*protocol.Message - maxSize int - lastUpdate time.Time -} - -// NewHandler creates a new ACP handler -func NewHandler(store MessageStore, log *logger.Logger) *Handler { - return &Handler{ - store: store, - logger: log, - buffers: make(map[string]*messageBuffer), - listeners: make(map[string][]MessageListener), - } -} - -// ProcessMessage handles an incoming ACP message -// It stores the message, updates buffers, and notifies listeners -func (h *Handler) ProcessMessage(ctx context.Context, msg *protocol.Message) error { - // Store the message - if err := h.store.Store(ctx, msg); err != nil { - h.logger.Error("failed to store ACP message", zap.Error(err), zap.String("task_id", msg.TaskID)) - return err - } - - // Update buffer - h.mu.Lock() - buf, exists := h.buffers[msg.TaskID] - if !exists { - buf = &messageBuffer{ - taskID: msg.TaskID, - messages: make([]*protocol.Message, 0, 100), - maxSize: 100, - } - h.buffers[msg.TaskID] = buf - } - buf.messages = append(buf.messages, msg) - if len(buf.messages) > buf.maxSize { - buf.messages = buf.messages[1:] - } - buf.lastUpdate = time.Now() - h.mu.Unlock() - - // Notify listeners - h.listenerMu.RLock() - listeners := h.listeners[msg.TaskID] - h.listenerMu.RUnlock() - - for _, listener := range listeners { - listener(msg) - } - - h.logger.Debug("processed ACP message", zap.String("task_id", msg.TaskID), zap.String("type", string(msg.Type))) - return nil -} - -// AddListener adds a listener for a specific task -// Returns a function to remove the listener -func (h *Handler) AddListener(taskID string, listener MessageListener) func() { - h.listenerMu.Lock() - h.listeners[taskID] = append(h.listeners[taskID], listener) - h.listenerMu.Unlock() - - return func() { - h.RemoveListener(taskID, listener) - } -} - -// RemoveListener removes a specific listener -func (h *Handler) RemoveListener(taskID string, listener MessageListener) { - h.listenerMu.Lock() - defer h.listenerMu.Unlock() - - listeners := h.listeners[taskID] - for i, l := range listeners { - // Compare function pointers - if &l == &listener { - h.listeners[taskID] = append(listeners[:i], listeners[i+1:]...) - break - } - } -} - -// GetRecentMessages returns recent messages from the buffer -func (h *Handler) GetRecentMessages(taskID string, limit int) []*protocol.Message { - h.mu.RLock() - defer h.mu.RUnlock() - - buf, exists := h.buffers[taskID] - if !exists { - return nil - } - - messages := buf.messages - if limit > 0 && len(messages) > limit { - messages = messages[len(messages)-limit:] - } - - // Return a copy to avoid race conditions - result := make([]*protocol.Message, len(messages)) - copy(result, messages) - return result -} - -// GetTaskProgress returns the latest progress for a task -func (h *Handler) GetTaskProgress(taskID string) (*protocol.ProgressData, error) { - return h.store.GetLatestProgress(context.Background(), taskID) -} - -// GetAllMessages retrieves all historical messages for a task from the database -func (h *Handler) GetAllMessages(ctx context.Context, taskID string) ([]*protocol.Message, error) { - return h.store.GetAllMessages(ctx, taskID) -} - -// CleanupTask removes all data for a completed task -func (h *Handler) CleanupTask(taskID string) { - h.mu.Lock() - delete(h.buffers, taskID) - h.mu.Unlock() - - h.listenerMu.Lock() - delete(h.listeners, taskID) - h.listenerMu.Unlock() - - h.logger.Info("cleaned up task data", zap.String("task_id", taskID)) -} - diff --git a/apps/backend/internal/orchestrator/acp/memory_store.go b/apps/backend/internal/orchestrator/acp/memory_store.go deleted file mode 100644 index 0b1e5b1633..0000000000 --- a/apps/backend/internal/orchestrator/acp/memory_store.go +++ /dev/null @@ -1,152 +0,0 @@ -package acp - -import ( - "context" - "sync" - "time" - - "github.com/kandev/kandev/pkg/acp/protocol" -) - -// MemoryMessageStore is an in-memory implementation of MessageStore -type MemoryMessageStore struct { - messages map[string][]*protocol.Message - mu sync.RWMutex - maxPerTask int -} - -// NewMemoryMessageStore creates a new in-memory message store -func NewMemoryMessageStore(maxPerTask int) *MemoryMessageStore { - if maxPerTask <= 0 { - maxPerTask = 1000 - } - return &MemoryMessageStore{ - messages: make(map[string][]*protocol.Message), - maxPerTask: maxPerTask, - } -} - -// Store saves an ACP message to the in-memory store -func (s *MemoryMessageStore) Store(ctx context.Context, msg *protocol.Message) error { - s.mu.Lock() - defer s.mu.Unlock() - - messages := s.messages[msg.TaskID] - messages = append(messages, msg) - - // Trim if exceeding max - if len(messages) > s.maxPerTask { - messages = messages[len(messages)-s.maxPerTask:] - } - - s.messages[msg.TaskID] = messages - return nil -} - -// GetMessages retrieves messages for a task -func (s *MemoryMessageStore) GetMessages(ctx context.Context, taskID string, limit int, since time.Time) ([]*protocol.Message, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - messages := s.messages[taskID] - if messages == nil { - return []*protocol.Message{}, nil - } - - // Filter by time if specified - var filtered []*protocol.Message - for _, msg := range messages { - if msg.Timestamp.After(since) { - filtered = append(filtered, msg) - } - } - - // Apply limit - if limit > 0 && len(filtered) > limit { - filtered = filtered[len(filtered)-limit:] - } - - // Return a copy - result := make([]*protocol.Message, len(filtered)) - copy(result, filtered) - return result, nil -} - -// GetAllMessages retrieves all messages for a task (for historical replay) -func (s *MemoryMessageStore) GetAllMessages(ctx context.Context, taskID string) ([]*protocol.Message, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - messages := s.messages[taskID] - if messages == nil { - return []*protocol.Message{}, nil - } - - // Return a copy - result := make([]*protocol.Message, len(messages)) - copy(result, messages) - return result, nil -} - -// GetLatestProgress retrieves the most recent progress for a task -func (s *MemoryMessageStore) GetLatestProgress(ctx context.Context, taskID string) (*protocol.ProgressData, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - messages := s.messages[taskID] - if messages == nil { - return nil, nil - } - - // Find the latest progress message (iterate backwards) - for i := len(messages) - 1; i >= 0; i-- { - msg := messages[i] - if msg.Type == protocol.MessageTypeProgress { - return extractProgressData(msg), nil - } - } - - return nil, nil -} - -// extractProgressData extracts ProgressData from a message's Data map -func extractProgressData(msg *protocol.Message) *protocol.ProgressData { - data := &protocol.ProgressData{} - - if progress, ok := msg.Data["progress"].(float64); ok { - data.Progress = int(progress) - } else if progress, ok := msg.Data["progress"].(int); ok { - data.Progress = progress - } - - if message, ok := msg.Data["message"].(string); ok { - data.Message = message - } - - if currentFile, ok := msg.Data["current_file"].(string); ok { - data.CurrentFile = currentFile - } - - if filesProcessed, ok := msg.Data["files_processed"].(float64); ok { - data.FilesProcessed = int(filesProcessed) - } else if filesProcessed, ok := msg.Data["files_processed"].(int); ok { - data.FilesProcessed = filesProcessed - } - - if totalFiles, ok := msg.Data["total_files"].(float64); ok { - data.TotalFiles = int(totalFiles) - } else if totalFiles, ok := msg.Data["total_files"].(int); ok { - data.TotalFiles = totalFiles - } - - return data -} - -// Delete removes all messages for a task -func (s *MemoryMessageStore) Delete(ctx context.Context, taskID string) error { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.messages, taskID) - return nil -} - diff --git a/apps/backend/internal/orchestrator/acp/memory_store_test.go b/apps/backend/internal/orchestrator/acp/memory_store_test.go deleted file mode 100644 index 108785fb62..0000000000 --- a/apps/backend/internal/orchestrator/acp/memory_store_test.go +++ /dev/null @@ -1,352 +0,0 @@ -package acp - -import ( - "context" - "testing" - "time" - - "github.com/kandev/kandev/pkg/acp/protocol" -) - -// createTestMessage creates a test ACP message -func createTestMessage(taskID string, msgType protocol.MessageType, data map[string]interface{}) *protocol.Message { - return &protocol.Message{ - Type: msgType, - Timestamp: time.Now(), - AgentID: "test-agent", - TaskID: taskID, - Data: data, - } -} - -func TestNewMemoryMessageStore(t *testing.T) { - store := NewMemoryMessageStore(100) - if store == nil { - t.Fatal("NewMemoryMessageStore returned nil") - } - if store.maxPerTask != 100 { - t.Errorf("expected maxPerTask = 100, got %d", store.maxPerTask) - } -} - -func TestNewMemoryMessageStoreDefaultMax(t *testing.T) { - // When maxPerTask is 0 or negative, it should default to 1000 - store := NewMemoryMessageStore(0) - if store.maxPerTask != 1000 { - t.Errorf("expected default maxPerTask = 1000, got %d", store.maxPerTask) - } - - store = NewMemoryMessageStore(-1) - if store.maxPerTask != 1000 { - t.Errorf("expected default maxPerTask = 1000, got %d", store.maxPerTask) - } -} - -func TestStore(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - msg := createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "message": "Test log message", - }) - - err := store.Store(ctx, msg) - if err != nil { - t.Fatalf("Store failed: %v", err) - } - - // Verify message was stored - messages, err := store.GetMessages(ctx, "task-1", 10, time.Time{}) - if err != nil { - t.Fatalf("GetMessages failed: %v", err) - } - if len(messages) != 1 { - t.Errorf("expected 1 message, got %d", len(messages)) - } -} - -func TestStoreMultipleMessages(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - for i := 0; i < 5; i++ { - msg := createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "message": "Log message", - "index": i, - }) - _ = store.Store(ctx, msg) - } - - messages, _ := store.GetMessages(ctx, "task-1", 10, time.Time{}) - if len(messages) != 5 { - t.Errorf("expected 5 messages, got %d", len(messages)) - } -} - -func TestStoreTrimExcess(t *testing.T) { - store := NewMemoryMessageStore(3) // Max 3 messages per task - ctx := context.Background() - - // Store 5 messages - for i := 0; i < 5; i++ { - msg := &protocol.Message{ - Type: protocol.MessageTypeLog, - Timestamp: time.Now().Add(time.Duration(i) * time.Second), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{"index": i}, - } - _ = store.Store(ctx, msg) - } - - messages, _ := store.GetMessages(ctx, "task-1", 10, time.Time{}) - if len(messages) != 3 { - t.Errorf("expected 3 messages after trimming, got %d", len(messages)) - } - - // Verify we kept the most recent messages (indices 2, 3, 4) - for i, msg := range messages { - expectedIndex := i + 2 - if idx, ok := msg.Data["index"].(int); ok { - if idx != expectedIndex { - t.Errorf("expected message index %d, got %d", expectedIndex, idx) - } - } - } -} - -func TestGetMessagesEmpty(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - messages, err := store.GetMessages(ctx, "non-existent-task", 10, time.Time{}) - if err != nil { - t.Fatalf("GetMessages failed: %v", err) - } - if len(messages) != 0 { - t.Errorf("expected 0 messages for non-existent task, got %d", len(messages)) - } -} - -func TestGetMessagesWithLimit(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - for i := 0; i < 10; i++ { - msg := createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "index": i, - }) - _ = store.Store(ctx, msg) - } - - messages, _ := store.GetMessages(ctx, "task-1", 3, time.Time{}) - if len(messages) != 3 { - t.Errorf("expected 3 messages with limit, got %d", len(messages)) - } -} - -func TestGetMessagesWithSince(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - baseTime := time.Now() - - // Store messages with different timestamps - for i := 0; i < 5; i++ { - msg := &protocol.Message{ - Type: protocol.MessageTypeLog, - Timestamp: baseTime.Add(time.Duration(i) * time.Hour), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{"index": i}, - } - _ = store.Store(ctx, msg) - } - - // Get messages since 2 hours after base (should get indices 3 and 4) - since := baseTime.Add(2 * time.Hour) - messages, _ := store.GetMessages(ctx, "task-1", 10, since) - if len(messages) != 2 { - t.Errorf("expected 2 messages after since filter, got %d", len(messages)) - } -} - -func TestGetMessagesReturnsCopy(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - msg := createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "message": "Original", - }) - _ = store.Store(ctx, msg) - - messages1, _ := store.GetMessages(ctx, "task-1", 10, time.Time{}) - messages2, _ := store.GetMessages(ctx, "task-1", 10, time.Time{}) - - // Modifying returned slice should not affect the store - if len(messages1) > 0 { - messages1[0] = nil - } - if messages2[0] == nil { - t.Error("GetMessages should return a copy of messages") - } -} - -func TestGetLatestProgress(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - // Store some non-progress messages - _ = store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "message": "Log message", - })) - - // Store progress messages - _ = store.Store(ctx, &protocol.Message{ - Type: protocol.MessageTypeProgress, - Timestamp: time.Now(), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{ - "progress": 50, - "message": "Halfway done", - }, - }) - - _ = store.Store(ctx, &protocol.Message{ - Type: protocol.MessageTypeProgress, - Timestamp: time.Now(), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{ - "progress": 75, - "message": "Almost done", - "current_file": "main.go", - "files_processed": 3, - "total_files": 4, - }, - }) - - progress, err := store.GetLatestProgress(ctx, "task-1") - if err != nil { - t.Fatalf("GetLatestProgress failed: %v", err) - } - if progress == nil { - t.Fatal("expected progress data, got nil") - } - if progress.Progress != 75 { - t.Errorf("expected progress = 75, got %d", progress.Progress) - } - if progress.Message != "Almost done" { - t.Errorf("expected message = 'Almost done', got %s", progress.Message) - } - if progress.CurrentFile != "main.go" { - t.Errorf("expected current_file = 'main.go', got %s", progress.CurrentFile) - } -} - -func TestGetLatestProgressNoProgress(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - // Store only non-progress messages - _ = store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "message": "Log message", - })) - - progress, err := store.GetLatestProgress(ctx, "task-1") - if err != nil { - t.Fatalf("GetLatestProgress failed: %v", err) - } - if progress != nil { - t.Error("expected nil progress when no progress messages exist") - } -} - -func TestGetLatestProgressNonExistentTask(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - progress, err := store.GetLatestProgress(ctx, "non-existent") - if err != nil { - t.Fatalf("GetLatestProgress failed: %v", err) - } - if progress != nil { - t.Error("expected nil progress for non-existent task") - } -} - -func TestDelete(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - _ = store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, nil)) - _ = store.Store(ctx, createTestMessage("task-2", protocol.MessageTypeLog, nil)) - - err := store.Delete(ctx, "task-1") - if err != nil { - t.Fatalf("Delete failed: %v", err) - } - - // task-1 messages should be gone - messages, _ := store.GetMessages(ctx, "task-1", 10, time.Time{}) - if len(messages) != 0 { - t.Error("expected no messages after delete") - } - - // task-2 messages should still exist - messages, _ = store.GetMessages(ctx, "task-2", 10, time.Time{}) - if len(messages) != 1 { - t.Error("delete should not affect other tasks") - } -} - -func TestMultipleTasks(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - // Store messages for multiple tasks - _ = store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, nil)) - _ = store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, nil)) - _ = store.Store(ctx, createTestMessage("task-2", protocol.MessageTypeLog, nil)) - - messages1, _ := store.GetMessages(ctx, "task-1", 10, time.Time{}) - messages2, _ := store.GetMessages(ctx, "task-2", 10, time.Time{}) - - if len(messages1) != 2 { - t.Errorf("expected 2 messages for task-1, got %d", len(messages1)) - } - if len(messages2) != 1 { - t.Errorf("expected 1 message for task-2, got %d", len(messages2)) - } -} - -func TestProgressDataExtraction(t *testing.T) { - store := NewMemoryMessageStore(100) - ctx := context.Background() - - // Test with float64 values (as would come from JSON) - _ = store.Store(ctx, &protocol.Message{ - Type: protocol.MessageTypeProgress, - Timestamp: time.Now(), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{ - "progress": float64(50), - "files_processed": float64(10), - "total_files": float64(20), - }, - }) - - progress, _ := store.GetLatestProgress(ctx, "task-1") - if progress.Progress != 50 { - t.Errorf("expected progress = 50, got %d", progress.Progress) - } - if progress.FilesProcessed != 10 { - t.Errorf("expected files_processed = 10, got %d", progress.FilesProcessed) - } - if progress.TotalFiles != 20 { - t.Errorf("expected total_files = 20, got %d", progress.TotalFiles) - } -} - diff --git a/apps/backend/internal/orchestrator/acp/sqlite_store.go b/apps/backend/internal/orchestrator/acp/sqlite_store.go deleted file mode 100644 index 7f1fac5743..0000000000 --- a/apps/backend/internal/orchestrator/acp/sqlite_store.go +++ /dev/null @@ -1,242 +0,0 @@ -package acp - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/kandev/kandev/pkg/acp/protocol" -) - -// SQLiteMessageStore is a SQLite-based implementation of MessageStore for persistent log storage -type SQLiteMessageStore struct { - db *sql.DB -} - -// NewSQLiteMessageStore creates a new SQLite message store using an existing database connection -func NewSQLiteMessageStore(db *sql.DB) *SQLiteMessageStore { - return &SQLiteMessageStore{db: db} -} - -// Store saves an ACP message to the SQLite database -func (s *SQLiteMessageStore) Store(ctx context.Context, msg *protocol.Message) error { - id := uuid.New().String() - - // Serialize the Data map to JSON for metadata storage - metadata, err := json.Marshal(msg.Data) - if err != nil { - metadata = []byte("{}") - } - - // Extract log level from message type/data - logLevel := s.extractLogLevel(msg) - - // Extract message text from the data - messageText := s.extractMessageText(msg) - - _, err = s.db.ExecContext(ctx, ` - INSERT INTO task_agent_execution_logs - (id, task_id, agent_instance_id, log_level, message_type, message, metadata, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, id, msg.TaskID, msg.AgentID, logLevel, string(msg.Type), messageText, string(metadata), msg.Timestamp) - - return err -} - -// GetMessages retrieves messages for a task -func (s *SQLiteMessageStore) GetMessages(ctx context.Context, taskID string, limit int, since time.Time) ([]*protocol.Message, error) { - var rows *sql.Rows - var err error - - if limit > 0 { - rows, err = s.db.QueryContext(ctx, ` - SELECT task_id, agent_instance_id, message_type, metadata, timestamp - FROM task_agent_execution_logs - WHERE task_id = ? AND timestamp > ? - ORDER BY timestamp ASC - LIMIT ? - `, taskID, since, limit) - } else { - rows, err = s.db.QueryContext(ctx, ` - SELECT task_id, agent_instance_id, message_type, metadata, timestamp - FROM task_agent_execution_logs - WHERE task_id = ? AND timestamp > ? - ORDER BY timestamp ASC - `, taskID, since) - } - - if err != nil { - return nil, err - } - defer rows.Close() - - return s.scanMessages(rows) -} - -// GetAllMessages retrieves all messages for a task (for historical replay) -func (s *SQLiteMessageStore) GetAllMessages(ctx context.Context, taskID string) ([]*protocol.Message, error) { - rows, err := s.db.QueryContext(ctx, ` - SELECT task_id, agent_instance_id, message_type, metadata, timestamp - FROM task_agent_execution_logs - WHERE task_id = ? - ORDER BY timestamp ASC - `, taskID) - - if err != nil { - return nil, err - } - defer rows.Close() - - return s.scanMessages(rows) -} - -// GetLatestProgress retrieves the most recent progress for a task -func (s *SQLiteMessageStore) GetLatestProgress(ctx context.Context, taskID string) (*protocol.ProgressData, error) { - var metadataStr string - var messageType string - - err := s.db.QueryRowContext(ctx, ` - SELECT message_type, metadata - FROM task_agent_execution_logs - WHERE task_id = ? AND message_type = 'progress' - ORDER BY timestamp DESC - LIMIT 1 - `, taskID).Scan(&messageType, &metadataStr) - - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - - // Parse metadata to extract progress data - var data map[string]interface{} - if err := json.Unmarshal([]byte(metadataStr), &data); err != nil { - return nil, nil - } - - return s.extractProgressDataFromMap(data), nil -} - -// Delete removes all messages for a task -func (s *SQLiteMessageStore) Delete(ctx context.Context, taskID string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM task_agent_execution_logs WHERE task_id = ?`, taskID) - return err -} - -// scanMessages scans database rows into protocol.Message slices -func (s *SQLiteMessageStore) scanMessages(rows *sql.Rows) ([]*protocol.Message, error) { - var result []*protocol.Message - - for rows.Next() { - var taskID, agentID, messageType, metadataStr string - var timestamp time.Time - - err := rows.Scan(&taskID, &agentID, &messageType, &metadataStr, ×tamp) - if err != nil { - return nil, err - } - - var data map[string]interface{} - if err := json.Unmarshal([]byte(metadataStr), &data); err != nil { - data = make(map[string]interface{}) - } - - msg := &protocol.Message{ - Type: protocol.MessageType(messageType), - Timestamp: timestamp, - AgentID: agentID, - TaskID: taskID, - Data: data, - } - result = append(result, msg) - } - - return result, rows.Err() -} - -// extractLogLevel extracts a log level from the ACP message -func (s *SQLiteMessageStore) extractLogLevel(msg *protocol.Message) string { - // For log messages, extract level from data - if msg.Type == protocol.MessageTypeLog { - if level, ok := msg.Data["level"].(string); ok { - return level - } - } - - // Map message types to log levels - switch msg.Type { - case protocol.MessageTypeError: - return "error" - case protocol.MessageTypeProgress, protocol.MessageTypeStatus: - return "info" - case protocol.MessageTypeHeartbeat: - return "debug" - default: - return "info" - } -} - -// extractMessageText extracts a human-readable message from the ACP message -func (s *SQLiteMessageStore) extractMessageText(msg *protocol.Message) string { - // Try common message fields - if message, ok := msg.Data["message"].(string); ok { - return message - } - if text, ok := msg.Data["text"].(string); ok { - return text - } - if errMsg, ok := msg.Data["error"].(string); ok { - return errMsg - } - if summary, ok := msg.Data["summary"].(string); ok { - return summary - } - - // For content updates - if msg.Type == protocol.MessageTypeProgress { - if progress, ok := msg.Data["progress"].(float64); ok { - return fmt.Sprintf("%d%% complete", int(progress)) - } - } - - return "" -} - -// extractProgressDataFromMap extracts ProgressData from a map -func (s *SQLiteMessageStore) extractProgressDataFromMap(data map[string]interface{}) *protocol.ProgressData { - pd := &protocol.ProgressData{} - - if progress, ok := data["progress"].(float64); ok { - pd.Progress = int(progress) - } else if progress, ok := data["progress"].(int); ok { - pd.Progress = progress - } - - if message, ok := data["message"].(string); ok { - pd.Message = message - } - - if currentFile, ok := data["current_file"].(string); ok { - pd.CurrentFile = currentFile - } - - if filesProcessed, ok := data["files_processed"].(float64); ok { - pd.FilesProcessed = int(filesProcessed) - } else if filesProcessed, ok := data["files_processed"].(int); ok { - pd.FilesProcessed = filesProcessed - } - - if totalFiles, ok := data["total_files"].(float64); ok { - pd.TotalFiles = int(totalFiles) - } else if totalFiles, ok := data["total_files"].(int); ok { - pd.TotalFiles = totalFiles - } - - return pd -} - diff --git a/apps/backend/internal/orchestrator/acp/sqlite_store_test.go b/apps/backend/internal/orchestrator/acp/sqlite_store_test.go deleted file mode 100644 index dca87937ae..0000000000 --- a/apps/backend/internal/orchestrator/acp/sqlite_store_test.go +++ /dev/null @@ -1,823 +0,0 @@ -package acp - -import ( - "context" - "database/sql" - "encoding/json" - "testing" - "time" - - "github.com/kandev/kandev/pkg/acp/protocol" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - _ "github.com/mattn/go-sqlite3" -) - -// setupTestDB creates an in-memory SQLite database with the required schema -func setupTestDB(t *testing.T) *sql.DB { - db, err := sql.Open("sqlite3", ":memory:") - require.NoError(t, err) - - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS task_agent_execution_logs ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - agent_instance_id TEXT, - log_level TEXT NOT NULL, - message_type TEXT NOT NULL, - message TEXT, - metadata TEXT, - timestamp DATETIME NOT NULL - ) - `) - require.NoError(t, err) - - return db -} - -func TestNewSQLiteMessageStore(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - store := NewSQLiteMessageStore(db) - require.NotNil(t, store) - assert.Equal(t, db, store.db) -} - -// ============================================================================ -// Store() method tests -// ============================================================================ - -func TestSQLiteStore_StoreSingleMessage(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - msg := createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "message": "Test log message", - "level": "info", - }) - - err := store.Store(ctx, msg) - require.NoError(t, err) - - // Verify message was stored - messages, err := store.GetAllMessages(ctx, "task-1") - require.NoError(t, err) - assert.Len(t, messages, 1) - assert.Equal(t, protocol.MessageTypeLog, messages[0].Type) -} - -func TestSQLiteStore_StoreMultipleMessagesForDifferentTasks(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Store messages for task-1 - for i := 0; i < 3; i++ { - err := store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "message": "Task 1 log", - "index": i, - })) - require.NoError(t, err) - } - - // Store messages for task-2 - for i := 0; i < 2; i++ { - err := store.Store(ctx, createTestMessage("task-2", protocol.MessageTypeLog, map[string]interface{}{ - "message": "Task 2 log", - "index": i, - })) - require.NoError(t, err) - } - - messages1, err := store.GetAllMessages(ctx, "task-1") - require.NoError(t, err) - assert.Len(t, messages1, 3) - - messages2, err := store.GetAllMessages(ctx, "task-2") - require.NoError(t, err) - assert.Len(t, messages2, 2) -} - -func TestSQLiteStore_StoreAllMessageTypes(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - testCases := []struct { - name string - msgType protocol.MessageType - data map[string]interface{} - }{ - { - name: "progress", - msgType: protocol.MessageTypeProgress, - data: map[string]interface{}{"progress": 50, "message": "Processing"}, - }, - { - name: "log", - msgType: protocol.MessageTypeLog, - data: map[string]interface{}{"level": "info", "message": "Log message"}, - }, - { - name: "result", - msgType: protocol.MessageTypeResult, - data: map[string]interface{}{"status": "completed", "summary": "Task done"}, - }, - { - name: "error", - msgType: protocol.MessageTypeError, - data: map[string]interface{}{"error": "Something went wrong"}, - }, - { - name: "status", - msgType: protocol.MessageTypeStatus, - data: map[string]interface{}{"status": "running", "message": "In progress"}, - }, - { - name: "heartbeat", - msgType: protocol.MessageTypeHeartbeat, - data: map[string]interface{}{}, - }, - { - name: "session_info", - msgType: protocol.MessageTypeSessionInfo, - data: map[string]interface{}{"session_id": "sess-123", "resumable": true}, - }, - { - name: "input_required", - msgType: protocol.MessageTypeInputRequired, - data: map[string]interface{}{"prompt_id": "p1", "prompt": "Enter name"}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - taskID := "task-" + tc.name - msg := createTestMessage(taskID, tc.msgType, tc.data) - err := store.Store(ctx, msg) - require.NoError(t, err) - - // Verify message was stored and can be retrieved - messages, err := store.GetAllMessages(ctx, taskID) - require.NoError(t, err) - assert.Len(t, messages, 1) - assert.Equal(t, tc.msgType, messages[0].Type) - }) - } -} - -func TestSQLiteStore_StoreMetadataSerialization(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - complexData := map[string]interface{}{ - "string_field": "test value", - "int_field": float64(42), // JSON unmarshals numbers as float64 - "bool_field": true, - "nested_object": map[string]interface{}{"key": "value"}, - "array_field": []interface{}{"a", "b", "c"}, - } - - msg := createTestMessage("task-1", protocol.MessageTypeLog, complexData) - err := store.Store(ctx, msg) - require.NoError(t, err) - - // Verify by querying database directly - var metadataStr string - err = db.QueryRow(`SELECT metadata FROM task_agent_execution_logs WHERE task_id = ?`, "task-1").Scan(&metadataStr) - require.NoError(t, err) - - var parsed map[string]interface{} - err = json.Unmarshal([]byte(metadataStr), &parsed) - require.NoError(t, err) - assert.Equal(t, "test value", parsed["string_field"]) - assert.Equal(t, float64(42), parsed["int_field"]) - assert.Equal(t, true, parsed["bool_field"]) -} - -func TestSQLiteStore_StoreLogLevelExtraction(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - testCases := []struct { - name string - msgType protocol.MessageType - data map[string]interface{} - expectedLevel string - }{ - { - name: "log with level", - msgType: protocol.MessageTypeLog, - data: map[string]interface{}{"level": "warn", "message": "Warning"}, - expectedLevel: "warn", - }, - { - name: "log with debug level", - msgType: protocol.MessageTypeLog, - data: map[string]interface{}{"level": "debug", "message": "Debug"}, - expectedLevel: "debug", - }, - { - name: "error message", - msgType: protocol.MessageTypeError, - data: map[string]interface{}{"error": "Error occurred"}, - expectedLevel: "error", - }, - { - name: "progress message", - msgType: protocol.MessageTypeProgress, - data: map[string]interface{}{"progress": 50}, - expectedLevel: "info", - }, - { - name: "status message", - msgType: protocol.MessageTypeStatus, - data: map[string]interface{}{"status": "running"}, - expectedLevel: "info", - }, - { - name: "heartbeat message", - msgType: protocol.MessageTypeHeartbeat, - data: map[string]interface{}{}, - expectedLevel: "debug", - }, - } - - for i, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - taskID := "task-level-" + string(rune('a'+i)) - msg := createTestMessage(taskID, tc.msgType, tc.data) - err := store.Store(ctx, msg) - require.NoError(t, err) - - var logLevel string - err = db.QueryRow(`SELECT log_level FROM task_agent_execution_logs WHERE task_id = ?`, taskID).Scan(&logLevel) - require.NoError(t, err) - assert.Equal(t, tc.expectedLevel, logLevel) - }) - } -} - -// ============================================================================ -// GetAllMessages() method tests -// ============================================================================ - -func TestSQLiteStore_GetAllMessages(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Store multiple messages - for i := 0; i < 5; i++ { - msg := &protocol.Message{ - Type: protocol.MessageTypeLog, - Timestamp: time.Now().Add(time.Duration(i) * time.Second), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{"index": i}, - } - err := store.Store(ctx, msg) - require.NoError(t, err) - } - - messages, err := store.GetAllMessages(ctx, "task-1") - require.NoError(t, err) - assert.Len(t, messages, 5) -} - -func TestSQLiteStore_GetAllMessagesEmptyForNonExistentTask(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - messages, err := store.GetAllMessages(ctx, "non-existent-task") - require.NoError(t, err) - assert.Len(t, messages, 0) -} - - -func TestSQLiteStore_GetAllMessagesChronologicalOrder(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - baseTime := time.Now() - - // Store messages in reverse order - for i := 4; i >= 0; i-- { - msg := &protocol.Message{ - Type: protocol.MessageTypeLog, - Timestamp: baseTime.Add(time.Duration(i) * time.Hour), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{"index": i}, - } - err := store.Store(ctx, msg) - require.NoError(t, err) - } - - messages, err := store.GetAllMessages(ctx, "task-1") - require.NoError(t, err) - require.Len(t, messages, 5) - - // Verify chronological order (earliest first) - for i, msg := range messages { - idx, ok := msg.Data["index"].(float64) - require.True(t, ok) - assert.Equal(t, float64(i), idx, "Messages should be in chronological order") - } -} - -func TestSQLiteStore_GetAllMessagesTaskIsolation(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Store messages for task-1 - for i := 0; i < 3; i++ { - err := store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "task": "1", - "idx": i, - })) - require.NoError(t, err) - } - - // Store messages for task-2 - for i := 0; i < 2; i++ { - err := store.Store(ctx, createTestMessage("task-2", protocol.MessageTypeLog, map[string]interface{}{ - "task": "2", - "idx": i, - })) - require.NoError(t, err) - } - - // Verify task-1 only gets its own messages - messages1, err := store.GetAllMessages(ctx, "task-1") - require.NoError(t, err) - assert.Len(t, messages1, 3) - for _, msg := range messages1 { - assert.Equal(t, "1", msg.Data["task"]) - } - - // Verify task-2 only gets its own messages - messages2, err := store.GetAllMessages(ctx, "task-2") - require.NoError(t, err) - assert.Len(t, messages2, 2) - for _, msg := range messages2 { - assert.Equal(t, "2", msg.Data["task"]) - } -} - -func TestSQLiteStore_GetAllMessagesMetadataDeserialization(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - originalData := map[string]interface{}{ - "string": "value", - "number": float64(123), - "boolean": true, - "nested": map[string]interface{}{"inner": "data"}, - } - - msg := createTestMessage("task-1", protocol.MessageTypeLog, originalData) - err := store.Store(ctx, msg) - require.NoError(t, err) - - messages, err := store.GetAllMessages(ctx, "task-1") - require.NoError(t, err) - require.Len(t, messages, 1) - - assert.Equal(t, "value", messages[0].Data["string"]) - assert.Equal(t, float64(123), messages[0].Data["number"]) - assert.Equal(t, true, messages[0].Data["boolean"]) - nested, ok := messages[0].Data["nested"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "data", nested["inner"]) -} - -// ============================================================================ -// GetMessages() method tests -// ============================================================================ - -func TestSQLiteStore_GetMessagesWithLimit(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Store 10 messages - for i := 0; i < 10; i++ { - msg := &protocol.Message{ - Type: protocol.MessageTypeLog, - Timestamp: time.Now().Add(time.Duration(i) * time.Second), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{"index": i}, - } - err := store.Store(ctx, msg) - require.NoError(t, err) - } - - messages, err := store.GetMessages(ctx, "task-1", 3, time.Time{}) - require.NoError(t, err) - assert.Len(t, messages, 3) -} - -func TestSQLiteStore_GetMessagesSinceTime(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - baseTime := time.Now() - - // Store messages with different timestamps - for i := 0; i < 5; i++ { - msg := &protocol.Message{ - Type: protocol.MessageTypeLog, - Timestamp: baseTime.Add(time.Duration(i) * time.Hour), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{"index": i}, - } - err := store.Store(ctx, msg) - require.NoError(t, err) - } - - // Get messages since 2 hours after base (should get indices 3 and 4) - since := baseTime.Add(2 * time.Hour) - messages, err := store.GetMessages(ctx, "task-1", 0, since) - require.NoError(t, err) - assert.Len(t, messages, 2) -} - -func TestSQLiteStore_GetMessagesCombineLimitAndSince(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - baseTime := time.Now() - - // Store 10 messages - for i := 0; i < 10; i++ { - msg := &protocol.Message{ - Type: protocol.MessageTypeLog, - Timestamp: baseTime.Add(time.Duration(i) * time.Hour), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{"index": i}, - } - err := store.Store(ctx, msg) - require.NoError(t, err) - } - - // Get at most 2 messages since hour 3 (should get indices 4, 5 only due to limit) - since := baseTime.Add(3 * time.Hour) - messages, err := store.GetMessages(ctx, "task-1", 2, since) - require.NoError(t, err) - assert.Len(t, messages, 2) - - // Verify we got the earliest matching messages - idx1, _ := messages[0].Data["index"].(float64) - idx2, _ := messages[1].Data["index"].(float64) - assert.Equal(t, float64(4), idx1) - assert.Equal(t, float64(5), idx2) -} - -func TestSQLiteStore_GetMessagesEmptyWhenNoMatch(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - baseTime := time.Now() - - // Store a message - msg := &protocol.Message{ - Type: protocol.MessageTypeLog, - Timestamp: baseTime, - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{"message": "test"}, - } - err := store.Store(ctx, msg) - require.NoError(t, err) - - // Query with a since time in the future - futureSince := baseTime.Add(1 * time.Hour) - messages, err := store.GetMessages(ctx, "task-1", 10, futureSince) - require.NoError(t, err) - assert.Len(t, messages, 0) -} - -func TestSQLiteStore_GetMessagesEmptyForNonExistentTask(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - messages, err := store.GetMessages(ctx, "non-existent", 10, time.Time{}) - require.NoError(t, err) - assert.Len(t, messages, 0) -} - -// ============================================================================ -// GetLatestProgress() method tests -// ============================================================================ - -func TestSQLiteStore_GetLatestProgress(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Store some non-progress messages - err := store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "message": "Log message", - })) - require.NoError(t, err) - - // Store first progress message - err = store.Store(ctx, &protocol.Message{ - Type: protocol.MessageTypeProgress, - Timestamp: time.Now(), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{ - "progress": float64(50), - "message": "Halfway done", - }, - }) - require.NoError(t, err) - - // Store second progress message (more recent) - time.Sleep(10 * time.Millisecond) // Ensure different timestamps - err = store.Store(ctx, &protocol.Message{ - Type: protocol.MessageTypeProgress, - Timestamp: time.Now(), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{ - "progress": float64(75), - "message": "Almost done", - "current_file": "main.go", - "files_processed": float64(3), - "total_files": float64(4), - }, - }) - require.NoError(t, err) - - progress, err := store.GetLatestProgress(ctx, "task-1") - require.NoError(t, err) - require.NotNil(t, progress) - assert.Equal(t, 75, progress.Progress) - assert.Equal(t, "Almost done", progress.Message) - assert.Equal(t, "main.go", progress.CurrentFile) - assert.Equal(t, 3, progress.FilesProcessed) - assert.Equal(t, 4, progress.TotalFiles) -} - -func TestSQLiteStore_GetLatestProgressNoProgressMessages(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Store only non-progress messages - err := store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, map[string]interface{}{ - "message": "Log message", - })) - require.NoError(t, err) - - progress, err := store.GetLatestProgress(ctx, "task-1") - require.NoError(t, err) - assert.Nil(t, progress) -} - -func TestSQLiteStore_GetLatestProgressNonExistentTask(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - progress, err := store.GetLatestProgress(ctx, "non-existent-task") - require.NoError(t, err) - assert.Nil(t, progress) -} - -func TestSQLiteStore_GetLatestProgressParseData(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Test with float64 values (as they come from JSON/database) - err := store.Store(ctx, &protocol.Message{ - Type: protocol.MessageTypeProgress, - Timestamp: time.Now(), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{ - "progress": float64(50), - "message": "Processing files", - "current_file": "test.go", - "files_processed": float64(10), - "total_files": float64(20), - }, - }) - require.NoError(t, err) - - progress, err := store.GetLatestProgress(ctx, "task-1") - require.NoError(t, err) - require.NotNil(t, progress) - assert.Equal(t, 50, progress.Progress) - assert.Equal(t, "Processing files", progress.Message) - assert.Equal(t, "test.go", progress.CurrentFile) - assert.Equal(t, 10, progress.FilesProcessed) - assert.Equal(t, 20, progress.TotalFiles) -} - -func TestSQLiteStore_GetLatestProgressPartialData(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Test with only progress field - err := store.Store(ctx, &protocol.Message{ - Type: protocol.MessageTypeProgress, - Timestamp: time.Now(), - AgentID: "test-agent", - TaskID: "task-1", - Data: map[string]interface{}{ - "progress": float64(25), - }, - }) - require.NoError(t, err) - - progress, err := store.GetLatestProgress(ctx, "task-1") - require.NoError(t, err) - require.NotNil(t, progress) - assert.Equal(t, 25, progress.Progress) - assert.Equal(t, "", progress.Message) - assert.Equal(t, "", progress.CurrentFile) - assert.Equal(t, 0, progress.FilesProcessed) - assert.Equal(t, 0, progress.TotalFiles) -} - -// ============================================================================ -// Delete() method tests -// ============================================================================ - -func TestSQLiteStore_Delete(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Store messages for task-1 - for i := 0; i < 3; i++ { - err := store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, nil)) - require.NoError(t, err) - } - - // Verify messages exist - messages, err := store.GetAllMessages(ctx, "task-1") - require.NoError(t, err) - assert.Len(t, messages, 3) - - // Delete messages - err = store.Delete(ctx, "task-1") - require.NoError(t, err) - - // Verify messages are gone - messages, err = store.GetAllMessages(ctx, "task-1") - require.NoError(t, err) - assert.Len(t, messages, 0) -} - -func TestSQLiteStore_DeletePreservesOtherTasks(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Store messages for both tasks - err := store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, nil)) - require.NoError(t, err) - err = store.Store(ctx, createTestMessage("task-1", protocol.MessageTypeLog, nil)) - require.NoError(t, err) - err = store.Store(ctx, createTestMessage("task-2", protocol.MessageTypeLog, nil)) - require.NoError(t, err) - - // Delete task-1 messages - err = store.Delete(ctx, "task-1") - require.NoError(t, err) - - // task-1 messages should be gone - messages1, err := store.GetAllMessages(ctx, "task-1") - require.NoError(t, err) - assert.Len(t, messages1, 0) - - // task-2 messages should still exist - messages2, err := store.GetAllMessages(ctx, "task-2") - require.NoError(t, err) - assert.Len(t, messages2, 1) -} - -func TestSQLiteStore_DeleteNonExistentTask(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Deleting non-existent task should not return an error - err := store.Delete(ctx, "non-existent-task") - require.NoError(t, err) -} - -// ============================================================================ -// Error handling tests -// ============================================================================ - -func TestSQLiteStore_StoreWithClosedDB(t *testing.T) { - db := setupTestDB(t) - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Close the database - db.Close() - - // Attempt to store should return an error - msg := createTestMessage("task-1", protocol.MessageTypeLog, nil) - err := store.Store(ctx, msg) - assert.Error(t, err) -} - -func TestSQLiteStore_GetMessagesWithClosedDB(t *testing.T) { - db := setupTestDB(t) - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Close the database - db.Close() - - // Attempt to get messages should return an error - _, err := store.GetMessages(ctx, "task-1", 10, time.Time{}) - assert.Error(t, err) -} - -func TestSQLiteStore_GetAllMessagesWithClosedDB(t *testing.T) { - db := setupTestDB(t) - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Close the database - db.Close() - - // Attempt to get all messages should return an error - _, err := store.GetAllMessages(ctx, "task-1") - assert.Error(t, err) -} - -func TestSQLiteStore_GetLatestProgressWithClosedDB(t *testing.T) { - db := setupTestDB(t) - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Close the database - db.Close() - - // Attempt to get latest progress should return an error - _, err := store.GetLatestProgress(ctx, "task-1") - assert.Error(t, err) -} - -func TestSQLiteStore_DeleteWithClosedDB(t *testing.T) { - db := setupTestDB(t) - store := NewSQLiteMessageStore(db) - ctx := context.Background() - - // Close the database - db.Close() - - // Attempt to delete should return an error - err := store.Delete(ctx, "task-1") - assert.Error(t, err) -} diff --git a/apps/backend/internal/orchestrator/controller/controller.go b/apps/backend/internal/orchestrator/controller/controller.go index 5a466e773b..6b553bcdda 100644 --- a/apps/backend/internal/orchestrator/controller/controller.go +++ b/apps/backend/internal/orchestrator/controller/controller.go @@ -5,14 +5,12 @@ import ( "context" "github.com/kandev/kandev/internal/orchestrator" - "github.com/kandev/kandev/internal/orchestrator/acp" "github.com/kandev/kandev/internal/orchestrator/dto" ) // Controller coordinates orchestrator business logic type Controller struct { - service *orchestrator.Service - acpHandler *acp.Handler + service *orchestrator.Service } // NewController creates a new orchestrator controller @@ -22,11 +20,6 @@ func NewController(svc *orchestrator.Service) *Controller { } } -// SetACPHandler sets the ACP handler for log retrieval -func (c *Controller) SetACPHandler(handler *acp.Handler) { - c.acpHandler = handler -} - // GetStatus returns the orchestrator status func (c *Controller) GetStatus(ctx context.Context, req dto.GetStatusRequest) (dto.StatusResponse, error) { status := c.service.GetStatus() @@ -121,39 +114,6 @@ func (c *Controller) CompleteTask(ctx context.Context, req dto.CompleteTaskReque }, nil } -// GetTaskLogs retrieves historical logs for a task -func (c *Controller) GetTaskLogs(ctx context.Context, req dto.GetTaskLogsRequest) (dto.GetTaskLogsResponse, error) { - if c.acpHandler == nil { - return dto.GetTaskLogsResponse{}, ErrACPHandlerNotConfigured - } - - messages, err := c.acpHandler.GetAllMessages(ctx, req.TaskID) - if err != nil { - return dto.GetTaskLogsResponse{}, err - } - - // Apply limit if specified - if req.Limit > 0 && len(messages) > req.Limit { - messages = messages[len(messages)-req.Limit:] - } - - logs := make([]dto.LogEntryDTO, 0, len(messages)) - for _, m := range messages { - logs = append(logs, dto.LogEntryDTO{ - Type: string(m.Type), - Timestamp: m.Timestamp.Format("2006-01-02T15:04:05Z"), - AgentID: m.AgentID, - Data: m.Data, - }) - } - - return dto.GetTaskLogsResponse{ - TaskID: req.TaskID, - Logs: logs, - Total: len(logs), - }, nil -} - // RespondToPermission responds to a permission request func (c *Controller) RespondToPermission(ctx context.Context, req dto.PermissionRespondRequest) (dto.PermissionRespondResponse, error) { if err := c.service.RespondToPermission(ctx, req.TaskID, req.PendingID, req.OptionID, req.Cancelled); err != nil { diff --git a/apps/backend/internal/orchestrator/controller/errors.go b/apps/backend/internal/orchestrator/controller/errors.go index c6a0c31837..4137808535 100644 --- a/apps/backend/internal/orchestrator/controller/errors.go +++ b/apps/backend/internal/orchestrator/controller/errors.go @@ -1,9 +1,5 @@ package controller -import "errors" - -var ( - // ErrACPHandlerNotConfigured is returned when ACP handler is not set - ErrACPHandlerNotConfigured = errors.New("ACP handler not configured") -) +// This file is reserved for controller-specific errors. +// Currently no custom errors are defined. diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index 8b6de8a356..c63efdbbd3 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -92,27 +92,6 @@ type CompleteTaskResponse struct { Message string `json:"message"` } -// GetTaskLogsRequest is the payload for task.logs -type GetTaskLogsRequest struct { - TaskID string `json:"task_id"` - Limit int `json:"limit,omitempty"` -} - -// LogEntryDTO represents a single log entry -type LogEntryDTO struct { - Type string `json:"type"` - Timestamp string `json:"timestamp"` - AgentID string `json:"agent_id,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` -} - -// GetTaskLogsResponse is the response for task.logs -type GetTaskLogsResponse struct { - TaskID string `json:"task_id"` - Logs []LogEntryDTO `json:"logs"` - Total int `json:"total"` -} - // PermissionRespondRequest is the payload for permission.respond type PermissionRespondRequest struct { TaskID string `json:"task_id"` diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index ea5f22105e..afe6253201 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -4,11 +4,14 @@ package executor import ( "context" "errors" + "strings" "sync" "time" "github.com/google/uuid" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" v1 "github.com/kandev/kandev/pkg/api/v1" "go.uber.org/zap" ) @@ -67,7 +70,7 @@ type LaunchAgentResponse struct { Status v1.AgentStatus } -// TaskExecution tracks an active task execution +// TaskExecution tracks an active task execution (kept for API compatibility) type TaskExecution struct { TaskID string AgentInstanceID string @@ -76,14 +79,69 @@ type TaskExecution struct { Status v1.AgentStatus Progress int LastUpdate time.Time + // SessionID is the database ID of the agent session + SessionID string +} + +// FromAgentSession converts a models.AgentSession to TaskExecution +func FromAgentSession(s *models.AgentSession) *TaskExecution { + return &TaskExecution{ + TaskID: s.TaskID, + AgentInstanceID: s.AgentInstanceID, + AgentType: s.AgentType, + StartedAt: s.StartedAt, + Status: agentSessionStatusToV1(s.Status), + Progress: s.Progress, + LastUpdate: s.UpdatedAt, + SessionID: s.ID, + } +} + +// agentSessionStatusToV1 converts models.AgentSessionStatus to v1.AgentStatus +func agentSessionStatusToV1(status models.AgentSessionStatus) v1.AgentStatus { + switch status { + case models.AgentSessionStatusPending: + return v1.AgentStatusStarting + case models.AgentSessionStatusRunning: + return v1.AgentStatusRunning + case models.AgentSessionStatusWaiting: + return v1.AgentStatusRunning // Waiting is still "running" from agent perspective + case models.AgentSessionStatusCompleted: + return v1.AgentStatusCompleted + case models.AgentSessionStatusFailed: + return v1.AgentStatusFailed + case models.AgentSessionStatusStopped: + return v1.AgentStatusStopped + default: + return v1.AgentStatusPending + } +} + +// v1StatusToAgentSessionStatus converts v1.AgentStatus to models.AgentSessionStatus +func v1StatusToAgentSessionStatus(status v1.AgentStatus) models.AgentSessionStatus { + switch status { + case v1.AgentStatusStarting: + return models.AgentSessionStatusPending + case v1.AgentStatusRunning: + return models.AgentSessionStatusRunning + case v1.AgentStatusCompleted: + return models.AgentSessionStatusCompleted + case v1.AgentStatusFailed: + return models.AgentSessionStatusFailed + case v1.AgentStatusStopped: + return models.AgentSessionStatusStopped + default: + return models.AgentSessionStatusPending + } } // Executor manages agent execution for tasks type Executor struct { agentManager AgentManagerClient + repo repository.Repository logger *logger.Logger - // Track active executions + // In-memory cache for fast lookups (synced with database) executions map[string]*TaskExecution mu sync.RWMutex @@ -94,12 +152,13 @@ type Executor struct { } // NewExecutor creates a new executor -func NewExecutor(agentManager AgentManagerClient, log *logger.Logger, maxConcurrent int) *Executor { +func NewExecutor(agentManager AgentManagerClient, repo repository.Repository, log *logger.Logger, maxConcurrent int) *Executor { if maxConcurrent <= 0 { maxConcurrent = 5 // default } return &Executor{ agentManager: agentManager, + repo: repo, logger: log.WithFields(zap.String("component", "executor")), executions: make(map[string]*TaskExecution), maxConcurrent: maxConcurrent, @@ -108,6 +167,31 @@ func NewExecutor(agentManager AgentManagerClient, log *logger.Logger, maxConcurr } } +// LoadActiveSessionsFromDB loads active agent sessions from the database into memory +// This should be called on startup to restore state after a restart +func (e *Executor) LoadActiveSessionsFromDB(ctx context.Context) error { + sessions, err := e.repo.ListActiveAgentSessions(ctx) + if err != nil { + return err + } + + e.mu.Lock() + defer e.mu.Unlock() + + for _, session := range sessions { + e.executions[session.TaskID] = FromAgentSession(session) + e.logger.Info("restored agent session from database", + zap.String("task_id", session.TaskID), + zap.String("session_id", session.ID), + zap.String("status", string(session.Status))) + } + + e.logger.Info("loaded active agent sessions from database", + zap.Int("count", len(sessions))) + + return nil +} + // Execute starts agent execution for a task func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, error) { // Check if max concurrent limit is reached @@ -152,8 +236,28 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, return nil, err } - // Track the execution in the executions map - now := time.Now() + // Create agent session in database + sessionID := uuid.New().String() + now := time.Now().UTC() + session := &models.AgentSession{ + ID: sessionID, + TaskID: task.ID, + AgentInstanceID: resp.AgentInstanceID, + AgentType: *task.AgentType, + Status: v1StatusToAgentSessionStatus(resp.Status), + Progress: 0, + StartedAt: now, + UpdatedAt: now, + } + + if err := e.repo.CreateAgentSession(ctx, session); err != nil { + e.logger.Error("failed to persist agent session", + zap.String("task_id", task.ID), + zap.Error(err)) + // Continue anyway - the agent is already running + } + + // Track the execution in the in-memory cache execution := &TaskExecution{ TaskID: task.ID, AgentInstanceID: resp.AgentInstanceID, @@ -162,6 +266,7 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, Status: resp.Status, Progress: 0, LastUpdate: now, + SessionID: sessionID, } e.mu.Lock() @@ -170,6 +275,7 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, e.logger.Info("agent launched successfully", zap.String("task_id", task.ID), + zap.String("session_id", sessionID), zap.String("agent_instance_id", resp.AgentInstanceID), zap.String("container_id", resp.ContainerID)) @@ -178,12 +284,9 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, // Stop stops an active execution func (e *Executor) Stop(ctx context.Context, taskID string, reason string, force bool) error { - e.mu.RLock() - execution, exists := e.executions[taskID] - e.mu.RUnlock() - - if !exists { - return ErrExecutionNotFound + execution, err := e.getOrLoadExecution(ctx, taskID) + if err != nil { + return err } e.logger.Info("stopping execution", @@ -192,7 +295,7 @@ func (e *Executor) Stop(ctx context.Context, taskID string, reason string, force zap.String("reason", reason), zap.Bool("force", force)) - err := e.agentManager.StopAgent(ctx, execution.AgentInstanceID, force) + err = e.agentManager.StopAgent(ctx, execution.AgentInstanceID, force) if err != nil { e.logger.Error("failed to stop agent", zap.String("task_id", taskID), @@ -200,7 +303,7 @@ func (e *Executor) Stop(ctx context.Context, taskID string, reason string, force return err } - // Update execution status + // Update execution status in memory and database e.mu.Lock() if exec, ok := e.executions[taskID]; ok { exec.Status = v1.AgentStatusStopped @@ -208,27 +311,67 @@ func (e *Executor) Stop(ctx context.Context, taskID string, reason string, force } e.mu.Unlock() + // Update database + if execution.SessionID != "" { + if err := e.repo.UpdateAgentSessionStatus(ctx, execution.SessionID, models.AgentSessionStatusStopped, reason); err != nil { + e.logger.Error("failed to update agent session status in database", + zap.String("task_id", taskID), + zap.String("session_id", execution.SessionID), + zap.Error(err)) + } + } + return nil } // Prompt sends a follow-up prompt to a running agent for a task // Returns PromptResult indicating if the agent needs input func (e *Executor) Prompt(ctx context.Context, taskID string, prompt string) (*PromptResult, error) { - e.mu.RLock() - execution, exists := e.executions[taskID] - if !exists { - e.mu.RUnlock() - return nil, ErrExecutionNotFound + execution, err := e.getOrLoadExecution(ctx, taskID) + if err != nil { + return nil, err } - agentInstanceID := execution.AgentInstanceID - e.mu.RUnlock() e.logger.Info("sending prompt to agent", zap.String("task_id", taskID), - zap.String("agent_instance_id", agentInstanceID), + zap.String("agent_instance_id", execution.AgentInstanceID), zap.Int("prompt_length", len(prompt))) - return e.agentManager.PromptAgent(ctx, agentInstanceID, prompt) + return e.agentManager.PromptAgent(ctx, execution.AgentInstanceID, prompt) +} + +// getOrLoadExecution gets execution from memory cache or loads from database +func (e *Executor) getOrLoadExecution(ctx context.Context, taskID string) (*TaskExecution, error) { + // First check in-memory cache + e.mu.RLock() + execution, exists := e.executions[taskID] + e.mu.RUnlock() + + if exists { + return execution, nil + } + + // Try to load from database + session, err := e.repo.GetActiveAgentSessionByTaskID(ctx, taskID) + if err != nil { + // Check if it's a "not found" error + if strings.Contains(err.Error(), "no active agent session") { + return nil, ErrExecutionNotFound + } + return nil, err + } + + // Cache it in memory + execution = FromAgentSession(session) + e.mu.Lock() + e.executions[taskID] = execution + e.mu.Unlock() + + e.logger.Info("loaded agent session from database", + zap.String("task_id", taskID), + zap.String("session_id", session.ID)) + + return execution, nil } // RespondToPermission sends a response to a permission request for a task @@ -245,16 +388,57 @@ func (e *Executor) RespondToPermission(ctx context.Context, taskID, pendingID, o // GetExecution returns the current execution state for a task func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) { e.mu.RLock() - defer e.mu.RUnlock() - execution, exists := e.executions[taskID] - if !exists { + e.mu.RUnlock() + + if exists { + // Return a copy to avoid data races + execCopy := *execution + return &execCopy, true + } + + // Try to load from database + ctx := context.Background() + session, err := e.repo.GetActiveAgentSessionByTaskID(ctx, taskID) + if err != nil { return nil, false } - // Return a copy to avoid data races - copy := *execution - return ©, true + // Cache it in memory + execution = FromAgentSession(session) + e.mu.Lock() + e.executions[taskID] = execution + e.mu.Unlock() + + execCopy := *execution + return &execCopy, true +} + +// GetExecutionWithContext returns the current execution state for a task with context +func (e *Executor) GetExecutionWithContext(ctx context.Context, taskID string) (*TaskExecution, bool) { + e.mu.RLock() + execution, exists := e.executions[taskID] + e.mu.RUnlock() + + if exists { + execCopy := *execution + return &execCopy, true + } + + // Try to load from database + session, err := e.repo.GetActiveAgentSessionByTaskID(ctx, taskID) + if err != nil { + return nil, false + } + + // Cache it in memory + execution = FromAgentSession(session) + e.mu.Lock() + e.executions[taskID] = execution + e.mu.Unlock() + + execCopy := *execution + return &execCopy, true } // ListExecutions returns all active executions @@ -264,8 +448,8 @@ func (e *Executor) ListExecutions() []*TaskExecution { result := make([]*TaskExecution, 0, len(e.executions)) for _, exec := range e.executions { - copy := *exec - result = append(result, ©) + execCopy := *exec + result = append(result, &execCopy) } return result } @@ -283,39 +467,69 @@ func (e *Executor) CanExecute() bool { } // UpdateProgress updates the progress of an execution (called when ACP progress messages are received) -func (e *Executor) UpdateProgress(taskID string, progress int, status v1.AgentStatus) { +func (e *Executor) UpdateProgress(ctx context.Context, taskID string, progress int, status v1.AgentStatus) { e.mu.Lock() - defer e.mu.Unlock() - + var sessionID string if exec, ok := e.executions[taskID]; ok { exec.Progress = progress exec.Status = status exec.LastUpdate = time.Now() + sessionID = exec.SessionID e.logger.Debug("updated execution progress", zap.String("task_id", taskID), zap.Int("progress", progress), zap.String("status", string(status))) } + e.mu.Unlock() + + // Update database asynchronously (don't block on this) + if sessionID != "" { + go func() { + session, err := e.repo.GetAgentSession(ctx, sessionID) + if err != nil { + return + } + session.Progress = progress + session.Status = v1StatusToAgentSessionStatus(status) + if err := e.repo.UpdateAgentSession(ctx, session); err != nil { + e.logger.Error("failed to update agent session progress in database", + zap.String("session_id", sessionID), + zap.Error(err)) + } + }() + } } // MarkCompleted marks an execution as completed -func (e *Executor) MarkCompleted(taskID string, status v1.AgentStatus) { +func (e *Executor) MarkCompleted(ctx context.Context, taskID string, status v1.AgentStatus) { e.mu.Lock() - defer e.mu.Unlock() - + var sessionID string if exec, ok := e.executions[taskID]; ok { exec.Status = status exec.Progress = 100 exec.LastUpdate = time.Now() + sessionID = exec.SessionID e.logger.Info("execution completed", zap.String("task_id", taskID), zap.String("status", string(status))) } + e.mu.Unlock() + + // Update database + if sessionID != "" { + dbStatus := v1StatusToAgentSessionStatus(status) + if err := e.repo.UpdateAgentSessionStatus(ctx, sessionID, dbStatus, ""); err != nil { + e.logger.Error("failed to update agent session status in database", + zap.String("session_id", sessionID), + zap.Error(err)) + } + } } -// RemoveExecution removes a completed/failed execution from tracking +// RemoveExecution removes a completed/failed execution from in-memory tracking +// Note: The database record is preserved for history func (e *Executor) RemoveExecution(taskID string) { e.mu.Lock() defer e.mu.Unlock() @@ -326,6 +540,37 @@ func (e *Executor) RemoveExecution(taskID string) { } } +// GetActiveSessionID returns the active session ID for a task, if any +func (e *Executor) GetActiveSessionID(ctx context.Context, taskID string) (string, error) { + // First check in-memory cache + e.mu.RLock() + execution, exists := e.executions[taskID] + e.mu.RUnlock() + + if exists && execution.SessionID != "" { + return execution.SessionID, nil + } + + // Try to load from database + session, err := e.repo.GetActiveAgentSessionByTaskID(ctx, taskID) + if err != nil { + return "", err + } + + return session.ID, nil +} + +// UpdateSessionACPSessionID updates the ACP session ID for an agent session +func (e *Executor) UpdateSessionACPSessionID(ctx context.Context, taskID, acpSessionID string) error { + session, err := e.repo.GetActiveAgentSessionByTaskID(ctx, taskID) + if err != nil { + return err + } + + session.ACPSessionID = acpSessionID + return e.repo.UpdateAgentSession(ctx, session) +} + // MockAgentManagerClient is a placeholder implementation type MockAgentManagerClient struct { logger *logger.Logger diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index 858bbc2f69..6eb70b39d6 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -34,7 +34,6 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionOrchestratorStop, h.wsStopTask) d.RegisterFunc(ws.ActionOrchestratorPrompt, h.wsPromptTask) d.RegisterFunc(ws.ActionOrchestratorComplete, h.wsCompleteTask) - d.RegisterFunc(ws.ActionTaskLogs, h.wsGetTaskLogs) d.RegisterFunc(ws.ActionPermissionRespond, h.wsRespondToPermission) } @@ -182,31 +181,6 @@ func (h *Handlers) wsCompleteTask(ctx context.Context, msg *ws.Message) (*ws.Mes return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsGetTaskLogsRequest struct { - TaskID string `json:"task_id"` - Limit int `json:"limit,omitempty"` -} - -func (h *Handlers) wsGetTaskLogs(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsGetTaskLogsRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - - resp, err := h.controller.GetTaskLogs(ctx, dto.GetTaskLogsRequest{ - TaskID: req.TaskID, - Limit: req.Limit, - }) - if err != nil { - h.logger.Error("failed to get task logs", zap.String("task_id", req.TaskID), zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to retrieve logs: "+err.Error(), nil) - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - type wsPermissionRespondRequest struct { TaskID string `json:"task_id"` PendingID string `json:"pending_id"` diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index c2cc1adbbd..581017dac6 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/queue" + "github.com/kandev/kandev/internal/task/repository" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -111,6 +112,11 @@ func createTestLogger() *logger.Logger { return log } +func createTestExecutor(agentMgr *mockAgentManager, log *logger.Logger, maxConcurrent int) *executor.Executor { + repo := repository.NewMemoryRepository() + return executor.NewExecutor(agentMgr, repo, log, maxConcurrent) +} + func createTestTask(id string, priority int) *v1.Task { agentType := "test-agent" return &v1.Task{ @@ -129,7 +135,7 @@ func TestNewScheduler(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -162,7 +168,7 @@ func TestStartStop(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -188,7 +194,7 @@ func TestStartAlreadyRunning(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -206,7 +212,7 @@ func TestStopNotRunning(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -221,7 +227,7 @@ func TestEnqueueTask(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -241,7 +247,7 @@ func TestRemoveTask(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -263,7 +269,7 @@ func TestRemoveNonExistentTask(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -278,7 +284,7 @@ func TestGetQueueStatus(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() cfg := DefaultSchedulerConfig() @@ -304,7 +310,7 @@ func TestHandleTaskCompleted(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -333,7 +339,7 @@ func TestPriorityOrdering(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -354,7 +360,7 @@ func TestIsRunning(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -378,7 +384,7 @@ func TestSchedulerWithContextCancellation(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() cfg := DefaultSchedulerConfig() @@ -407,7 +413,7 @@ func TestEnqueueDuplicateTask(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -425,7 +431,7 @@ func TestRetryTaskExceedsLimit(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() cfg := DefaultSchedulerConfig() @@ -459,7 +465,7 @@ func TestRetryTaskNotFound(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := executor.NewExecutor(agentMgr, log, 5) + exec := createTestExecutor(agentMgr, log, 5) taskRepo := newTestTaskRepository() cfg := DefaultSchedulerConfig() diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 11a1f7e904..557a790ee9 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/queue" "github.com/kandev/kandev/internal/orchestrator/scheduler" "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/task/repository" v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/pkg/acp/protocol" ) @@ -46,7 +47,8 @@ type InputRequestHandler func(ctx context.Context, taskID, agentID, message stri // CommentCreator is an interface for creating comments on tasks type CommentCreator interface { - CreateAgentComment(ctx context.Context, taskID, content string) error + CreateAgentComment(ctx context.Context, taskID, content, agentSessionID string) error + CreateToolCallComment(ctx context.Context, taskID, toolCallID, title, status, agentSessionID string) error } // Service is the main orchestrator service @@ -97,6 +99,7 @@ func NewService( db *database.DB, agentManager executor.AgentManagerClient, taskRepo scheduler.TaskRepository, + repo repository.Repository, log *logger.Logger, ) *Service { svcLogger := log.WithFields(zap.String("component", "orchestrator")) @@ -104,8 +107,8 @@ func NewService( // Create the task queue with configured size taskQueue := queue.NewTaskQueue(cfg.QueueSize) - // Create the executor with the agent manager client - exec := executor.NewExecutor(agentManager, log, cfg.Scheduler.MaxConcurrent) + // Create the executor with the agent manager client and repository for persistent sessions + exec := executor.NewExecutor(agentManager, repo, log, cfg.Scheduler.MaxConcurrent) // Create the scheduler with queue, executor, and task repository sched := scheduler.NewScheduler(taskQueue, exec, taskRepo, log, cfg.Scheduler) @@ -131,6 +134,7 @@ func NewService( OnAgentFailed: s.handleAgentFailed, OnACPMessage: s.handleACPMessage, OnPromptComplete: s.handlePromptComplete, + OnToolCallStarted: s.handleToolCallStarted, } s.watcher = watcher.NewWatcher(eventBus, handlers, log) @@ -155,6 +159,11 @@ func (s *Service) Start(ctx context.Context) error { s.logger.Info("starting orchestrator service") + // Load any active sessions from the database to restore state + if err := s.executor.LoadActiveSessionsFromDB(ctx); err != nil { + s.logger.Warn("failed to load active sessions from database (non-fatal)", zap.Error(err)) + } + // Start the watcher first to begin receiving events if err := s.watcher.Start(ctx); err != nil { s.mu.Lock() @@ -521,7 +530,10 @@ func (s *Service) handlePromptComplete(ctx context.Context, data watcher.PromptC // Save agent response as a comment if we have a comment creator if s.commentCreator != nil && data.AgentMessage != "" { - if err := s.commentCreator.CreateAgentComment(ctx, data.TaskID, data.AgentMessage); err != nil { + // Get the active session ID for this task + sessionID, _ := s.executor.GetActiveSessionID(ctx, data.TaskID) + + if err := s.commentCreator.CreateAgentComment(ctx, data.TaskID, data.AgentMessage, sessionID); err != nil { s.logger.Error("failed to create agent comment", zap.String("task_id", data.TaskID), zap.Error(err)) @@ -533,3 +545,27 @@ func (s *Service) handlePromptComplete(ctx context.Context, data watcher.PromptC } } +// handleToolCallStarted handles tool call started events and saves as comment +func (s *Service) handleToolCallStarted(ctx context.Context, data watcher.ToolCallData) { + s.logger.Info("handling tool call started", + zap.String("task_id", data.TaskID), + zap.String("tool_call_id", data.ToolCallID), + zap.String("title", data.Title)) + + // Save tool call as a comment if we have a comment creator + if s.commentCreator != nil { + // Get the active session ID for this task + sessionID, _ := s.executor.GetActiveSessionID(ctx, data.TaskID) + + if err := s.commentCreator.CreateToolCallComment(ctx, data.TaskID, data.ToolCallID, data.Title, data.Status, sessionID); err != nil { + s.logger.Error("failed to create tool call comment", + zap.String("task_id", data.TaskID), + zap.String("tool_call_id", data.ToolCallID), + zap.Error(err)) + } else { + s.logger.Info("created tool call comment", + zap.String("task_id", data.TaskID), + zap.String("tool_call_id", data.ToolCallID)) + } + } +} diff --git a/apps/backend/internal/orchestrator/watcher/watcher.go b/apps/backend/internal/orchestrator/watcher/watcher.go index bd7b49224d..c8947ca869 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher.go +++ b/apps/backend/internal/orchestrator/watcher/watcher.go @@ -40,6 +40,15 @@ type PromptCompleteData struct { AgentMessage string `json:"agent_message"` } +// ToolCallData contains data from tool_call events +type ToolCallData struct { + TaskID string `json:"task_id"` + AgentID string `json:"agent_id"` + ToolCallID string `json:"tool_call_id"` + Title string `json:"title"` + Status string `json:"status"` +} + // EventHandlers contains callbacks for different event types type EventHandlers struct { // Task events @@ -60,6 +69,9 @@ type EventHandlers struct { // Prompt events OnPromptComplete func(ctx context.Context, data PromptCompleteData) + + // Tool call events + OnToolCallStarted func(ctx context.Context, data ToolCallData) } // Watcher subscribes to events and dispatches to handlers @@ -120,6 +132,12 @@ func (w *Watcher) Start(ctx context.Context) error { return err } + // Subscribe to tool call events + if err := w.subscribeToToolCallEvents(); err != nil { + w.unsubscribeAll() + return err + } + w.running = true w.logger.Info("Event watcher started", zap.Int("subscriptions", len(w.subscriptions))) return nil @@ -360,6 +378,49 @@ func (w *Watcher) createPromptCompleteHandler() bus.EventHandler { } } +// subscribeToToolCallEvents subscribes to tool call events +func (w *Watcher) subscribeToToolCallEvents() error { + if w.handlers.OnToolCallStarted == nil { + return nil + } + + // Use wildcard to subscribe to all tool call events (tool_call.started.*) + subject := events.ToolCallStarted + ".*" + + // Use regular subscription (each instance needs all messages for comment creation) + sub, err := w.eventBus.Subscribe(subject, w.createToolCallHandler()) + if err != nil { + w.logger.Error("Failed to subscribe to tool call events", + zap.String("subject", subject), + zap.Error(err)) + return err + } + w.subscriptions = append(w.subscriptions, sub) + return nil +} + +// createToolCallHandler creates a bus.EventHandler for tool call events +func (w *Watcher) createToolCallHandler() bus.EventHandler { + return func(ctx context.Context, event *bus.Event) error { + var data ToolCallData + if err := w.parseEventData(event.Data, &data); err != nil { + w.logger.Error("Failed to parse tool call event data", + zap.String("event_type", event.Type), + zap.String("event_id", event.ID), + zap.Error(err)) + return nil // Don't return error to continue processing other events + } + + w.logger.Debug("Handling tool call event", + zap.String("task_id", data.TaskID), + zap.String("tool_call_id", data.ToolCallID), + zap.String("title", data.Title)) + + w.handlers.OnToolCallStarted(ctx, data) + return nil + } +} + // parseEventData converts event data map to a typed struct func (w *Watcher) parseEventData(data map[string]interface{}, target interface{}) error { // Marshal to JSON and unmarshal to target type @@ -369,4 +430,3 @@ func (w *Watcher) parseEventData(data map[string]interface{}, target interface{} } return json.Unmarshal(jsonData, target) } - diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index bad115cfbd..a8e594b342 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -68,29 +68,121 @@ const ( CommentAuthorAgent CommentAuthorType = "agent" ) +// CommentType represents the type of comment content +type CommentType string + +const ( + // CommentTypeMessage is the default type for user/agent regular messages + CommentTypeMessage CommentType = "message" + // CommentTypeContent is for agent response content + CommentTypeContent CommentType = "content" + // CommentTypeToolCall is when agent uses a tool + CommentTypeToolCall CommentType = "tool_call" + // CommentTypeProgress is for progress updates + CommentTypeProgress CommentType = "progress" + // CommentTypeError is for error messages + CommentTypeError CommentType = "error" + // CommentTypeStatus is for status changes: started, completed, failed + CommentTypeStatus CommentType = "status" +) + // Comment represents a comment on a task type Comment struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - AuthorType CommentAuthorType `json:"author_type"` - AuthorID string `json:"author_id,omitempty"` // User ID or Agent Instance ID - Content string `json:"content"` - RequestsInput bool `json:"requests_input"` // True if agent is requesting user input - ACPSessionID string `json:"acp_session_id,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TaskID string `json:"task_id"` + AuthorType CommentAuthorType `json:"author_type"` + AuthorID string `json:"author_id,omitempty"` // User ID or Agent Instance ID + Content string `json:"content"` + Type CommentType `json:"type,omitempty"` // Defaults to "message" for backward compatibility + Metadata map[string]interface{} `json:"metadata,omitempty"` + RequestsInput bool `json:"requests_input"` // True if agent is requesting user input + ACPSessionID string `json:"acp_session_id,omitempty"` + AgentSessionID string `json:"agent_session_id,omitempty"` + CreatedAt time.Time `json:"created_at"` } // ToAPI converts internal Comment to API type func (c *Comment) ToAPI() *v1.Comment { - return &v1.Comment{ + commentType := string(c.Type) + if commentType == "" { + commentType = string(CommentTypeMessage) + } + result := &v1.Comment{ ID: c.ID, TaskID: c.TaskID, AuthorType: string(c.AuthorType), AuthorID: c.AuthorID, Content: c.Content, + Type: commentType, + Metadata: c.Metadata, RequestsInput: c.RequestsInput, CreatedAt: c.CreatedAt, } + if c.AgentSessionID != "" { + result.AgentSessionID = c.AgentSessionID + } + return result +} + +// AgentSessionStatus represents the status of an agent session +type AgentSessionStatus string + +const ( + // AgentSessionStatusPending - session created but agent not started + AgentSessionStatusPending AgentSessionStatus = "pending" + // AgentSessionStatusRunning - agent is actively running + AgentSessionStatusRunning AgentSessionStatus = "running" + // AgentSessionStatusWaiting - agent waiting for user input + AgentSessionStatusWaiting AgentSessionStatus = "waiting" + // AgentSessionStatusCompleted - agent finished successfully + AgentSessionStatusCompleted AgentSessionStatus = "completed" + // AgentSessionStatusFailed - agent failed with error + AgentSessionStatusFailed AgentSessionStatus = "failed" + // AgentSessionStatusStopped - agent was manually stopped + AgentSessionStatusStopped AgentSessionStatus = "stopped" +) + +// AgentSession represents a persistent agent execution session for a task. +// This replaces the in-memory TaskExecution tracking and survives backend restarts. +type AgentSession struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + AgentInstanceID string `json:"agent_instance_id"` // Docker container/agent instance + AgentType string `json:"agent_type"` // e.g., "augment-agent" + ACPSessionID string `json:"acp_session_id"` // ACP protocol session for resumption + Status AgentSessionStatus `json:"status"` + Progress int `json:"progress"` // 0-100 + ErrorMessage string `json:"error_message,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ToAPI converts internal AgentSession to API type +// TODO: Add v1.AgentSession type to pkg/api/v1/ +func (s *AgentSession) ToAPI() map[string]interface{} { + result := map[string]interface{}{ + "id": s.ID, + "task_id": s.TaskID, + "agent_instance_id": s.AgentInstanceID, + "agent_type": s.AgentType, + "acp_session_id": s.ACPSessionID, + "status": string(s.Status), + "progress": s.Progress, + "started_at": s.StartedAt, + "updated_at": s.UpdatedAt, + } + if s.ErrorMessage != "" { + result["error_message"] = s.ErrorMessage + } + if s.CompletedAt != nil { + result["completed_at"] = s.CompletedAt + } + if s.Metadata != nil { + result["metadata"] = s.Metadata + } + return result } // Repository represents a workspace repository diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index e48a32d9a6..c905cedfef 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -47,6 +47,17 @@ type Repository interface { ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) DeleteComment(ctx context.Context, id string) error + // Agent Session operations + CreateAgentSession(ctx context.Context, session *models.AgentSession) error + GetAgentSession(ctx context.Context, id string) (*models.AgentSession, error) + GetAgentSessionByTaskID(ctx context.Context, taskID string) (*models.AgentSession, error) + GetActiveAgentSessionByTaskID(ctx context.Context, taskID string) (*models.AgentSession, error) + UpdateAgentSession(ctx context.Context, session *models.AgentSession) error + UpdateAgentSessionStatus(ctx context.Context, id string, status models.AgentSessionStatus, errorMessage string) error + ListAgentSessions(ctx context.Context, taskID string) ([]*models.AgentSession, error) + ListActiveAgentSessions(ctx context.Context) ([]*models.AgentSession, error) + DeleteAgentSession(ctx context.Context, id string) error + // Repository operations CreateRepository(ctx context.Context, repository *models.Repository) error GetRepository(ctx context.Context, id string) (*models.Repository, error) diff --git a/apps/backend/internal/task/repository/memory.go b/apps/backend/internal/task/repository/memory.go index 1136665d2a..d5a067ae66 100644 --- a/apps/backend/internal/task/repository/memory.go +++ b/apps/backend/internal/task/repository/memory.go @@ -20,6 +20,7 @@ type MemoryRepository struct { comments map[string]*models.Comment repositories map[string]*models.Repository repoScripts map[string]*models.RepositoryScript + agentSessions map[string]*models.AgentSession taskBoards map[string]map[string]struct{} taskPlacements map[string]map[string]*taskPlacement mu sync.RWMutex @@ -38,6 +39,7 @@ func NewMemoryRepository() *MemoryRepository { comments: make(map[string]*models.Comment), repositories: make(map[string]*models.Repository), repoScripts: make(map[string]*models.RepositoryScript), + agentSessions: make(map[string]*models.AgentSession), taskBoards: make(map[string]map[string]struct{}), taskPlacements: make(map[string]map[string]*taskPlacement), } @@ -583,6 +585,9 @@ func (r *MemoryRepository) CreateComment(ctx context.Context, comment *models.Co if comment.AuthorType == "" { comment.AuthorType = models.CommentAuthorUser } + if comment.Type == "" { + comment.Type = models.CommentTypeMessage + } r.comments[comment.ID] = comment return nil @@ -633,3 +638,140 @@ func (r *MemoryRepository) DeleteComment(ctx context.Context, id string) error { delete(r.comments, id) return nil } + + +// AgentSession operations + +// CreateAgentSession creates a new agent session +func (r *MemoryRepository) CreateAgentSession(ctx context.Context, session *models.AgentSession) error { + r.mu.Lock() + defer r.mu.Unlock() + + if session.ID == "" { + session.ID = uuid.New().String() + } + now := time.Now().UTC() + if session.StartedAt.IsZero() { + session.StartedAt = now + } + session.UpdatedAt = now + + r.agentSessions[session.ID] = session + return nil +} + +// GetAgentSession retrieves an agent session by ID +func (r *MemoryRepository) GetAgentSession(ctx context.Context, id string) (*models.AgentSession, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + session, ok := r.agentSessions[id] + if !ok { + return nil, fmt.Errorf("agent session not found: %s", id) + } + return session, nil +} + +// GetAgentSessionByTaskID retrieves the most recent agent session for a task +func (r *MemoryRepository) GetAgentSessionByTaskID(ctx context.Context, taskID string) (*models.AgentSession, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var latest *models.AgentSession + for _, session := range r.agentSessions { + if session.TaskID == taskID { + if latest == nil || session.StartedAt.After(latest.StartedAt) { + latest = session + } + } + } + if latest == nil { + return nil, fmt.Errorf("no agent session found for task: %s", taskID) + } + return latest, nil +} + +// GetActiveAgentSessionByTaskID retrieves the active agent session for a task +func (r *MemoryRepository) GetActiveAgentSessionByTaskID(ctx context.Context, taskID string) (*models.AgentSession, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, session := range r.agentSessions { + if session.TaskID == taskID && (session.Status == models.AgentSessionStatusRunning || session.Status == models.AgentSessionStatusWaiting || session.Status == models.AgentSessionStatusPending) { + return session, nil + } + } + return nil, fmt.Errorf("no active agent session found for task: %s", taskID) +} + +// UpdateAgentSession updates an existing agent session +func (r *MemoryRepository) UpdateAgentSession(ctx context.Context, session *models.AgentSession) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.agentSessions[session.ID]; !ok { + return fmt.Errorf("agent session not found: %s", session.ID) + } + session.UpdatedAt = time.Now().UTC() + r.agentSessions[session.ID] = session + return nil +} + +// UpdateAgentSessionStatus updates the status of an agent session +func (r *MemoryRepository) UpdateAgentSessionStatus(ctx context.Context, id string, status models.AgentSessionStatus, errorMessage string) error { + r.mu.Lock() + defer r.mu.Unlock() + + session, ok := r.agentSessions[id] + if !ok { + return fmt.Errorf("agent session not found: %s", id) + } + session.Status = status + session.ErrorMessage = errorMessage + session.UpdatedAt = time.Now().UTC() + if status == models.AgentSessionStatusCompleted || status == models.AgentSessionStatusFailed || status == models.AgentSessionStatusStopped { + now := time.Now().UTC() + session.CompletedAt = &now + } + return nil +} + +// ListAgentSessions returns all agent sessions for a task +func (r *MemoryRepository) ListAgentSessions(ctx context.Context, taskID string) ([]*models.AgentSession, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.AgentSession + for _, session := range r.agentSessions { + if session.TaskID == taskID { + result = append(result, session) + } + } + return result, nil +} + +// ListActiveAgentSessions returns all active agent sessions +func (r *MemoryRepository) ListActiveAgentSessions(ctx context.Context) ([]*models.AgentSession, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.AgentSession + for _, session := range r.agentSessions { + if session.Status == models.AgentSessionStatusRunning || session.Status == models.AgentSessionStatusWaiting || session.Status == models.AgentSessionStatusPending { + result = append(result, session) + } + } + return result, nil +} + +// DeleteAgentSession deletes an agent session by ID +func (r *MemoryRepository) DeleteAgentSession(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.agentSessions[id]; !ok { + return fmt.Errorf("agent session not found: %s", id) + } + delete(r.agentSessions, id) + return nil +} \ No newline at end of file diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index 4ae196b21a..3d70994bf3 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -192,22 +192,6 @@ func (r *SQLiteRepository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_repositories_workspace_id ON repositories(workspace_id); CREATE INDEX IF NOT EXISTS idx_repository_scripts_repo_id ON repository_scripts(repository_id); - CREATE TABLE IF NOT EXISTS task_agent_execution_logs ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - agent_instance_id TEXT NOT NULL DEFAULT '', - log_level TEXT NOT NULL DEFAULT 'info', - message_type TEXT NOT NULL, - message TEXT NOT NULL DEFAULT '', - metadata TEXT DEFAULT '{}', - timestamp DATETIME NOT NULL, - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_execution_logs_task_id ON task_agent_execution_logs(task_id); - CREATE INDEX IF NOT EXISTS idx_execution_logs_timestamp ON task_agent_execution_logs(timestamp); - CREATE INDEX IF NOT EXISTS idx_execution_logs_task_timestamp ON task_agent_execution_logs(task_id, timestamp); - CREATE TABLE IF NOT EXISTS task_comments ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, @@ -216,6 +200,8 @@ func (r *SQLiteRepository) initSchema() error { content TEXT NOT NULL, requests_input INTEGER DEFAULT 0, acp_session_id TEXT DEFAULT '', + type TEXT NOT NULL DEFAULT 'message', + metadata TEXT DEFAULT '{}', created_at DATETIME NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); @@ -223,6 +209,26 @@ func (r *SQLiteRepository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_comments_task_id ON task_comments(task_id); CREATE INDEX IF NOT EXISTS idx_comments_created_at ON task_comments(created_at); CREATE INDEX IF NOT EXISTS idx_comments_task_created ON task_comments(task_id, created_at); + + CREATE TABLE IF NOT EXISTS agent_sessions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + agent_instance_id TEXT NOT NULL DEFAULT '', + agent_type TEXT NOT NULL, + acp_session_id TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + progress INTEGER DEFAULT 0, + error_message TEXT DEFAULT '', + metadata TEXT DEFAULT '{}', + started_at DATETIME NOT NULL, + completed_at DATETIME, + updated_at DATETIME NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_agent_sessions_task_id ON agent_sessions(task_id); + CREATE INDEX IF NOT EXISTS idx_agent_sessions_status ON agent_sessions(status); + CREATE INDEX IF NOT EXISTS idx_agent_sessions_task_status ON agent_sessions(task_id, status); ` if _, err := r.db.Exec(schema); err != nil { @@ -239,6 +245,17 @@ func (r *SQLiteRepository) initSchema() error { return err } + // Ensure new comment columns exist for existing databases + if err := r.ensureColumn("task_comments", "type", "TEXT NOT NULL DEFAULT 'message'"); err != nil { + return err + } + if err := r.ensureColumn("task_comments", "metadata", "TEXT DEFAULT '{}'"); err != nil { + return err + } + if err := r.ensureColumn("task_comments", "agent_session_id", "TEXT DEFAULT ''"); err != nil { + return err + } + if err := r.ensureDefaultWorkspace(); err != nil { return err } @@ -548,11 +565,12 @@ func (r *SQLiteRepository) CreateTask(ctx context.Context, task *models.Task) er func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task, error) { task := &models.Task{} var metadata string + var agentType, repositoryURL, branch, assignedTo sql.NullString err := r.db.QueryRowContext(ctx, ` SELECT id, workspace_id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at FROM tasks WHERE id = ? - `, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.RepositoryURL, &task.Branch, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) + `, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &agentType, &repositoryURL, &branch, &assignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("task not found: %s", id) @@ -561,6 +579,10 @@ func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task return nil, err } + task.AgentType = agentType.String + task.RepositoryURL = repositoryURL.String + task.Branch = branch.String + task.AssignedTo = assignedTo.String _ = json.Unmarshal([]byte(metadata), &task.Metadata) return task, nil } @@ -650,10 +672,15 @@ func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { for rows.Next() { task := &models.Task{} var metadata string - err := rows.Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.RepositoryURL, &task.Branch, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) + var agentType, repositoryURL, branch, assignedTo sql.NullString + err := rows.Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &agentType, &repositoryURL, &branch, &assignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) if err != nil { return nil, err } + task.AgentType = agentType.String + task.RepositoryURL = repositoryURL.String + task.Branch = branch.String + task.AssignedTo = assignedTo.String _ = json.Unmarshal([]byte(metadata), &task.Metadata) result = append(result, task) } @@ -665,6 +692,7 @@ func (r *SQLiteRepository) scanTasksWithPlacement(rows *sql.Rows) ([]*models.Tas for rows.Next() { task := &models.Task{} var metadata string + var agentType, repositoryURL, branch, assignedTo sql.NullString err := rows.Scan( &task.ID, &task.WorkspaceID, @@ -672,10 +700,10 @@ func (r *SQLiteRepository) scanTasksWithPlacement(rows *sql.Rows) ([]*models.Tas &task.Description, &task.State, &task.Priority, - &task.AgentType, - &task.RepositoryURL, - &task.Branch, - &task.AssignedTo, + &agentType, + &repositoryURL, + &branch, + &assignedTo, &metadata, &task.CreatedAt, &task.UpdatedAt, @@ -686,6 +714,10 @@ func (r *SQLiteRepository) scanTasksWithPlacement(rows *sql.Rows) ([]*models.Tas if err != nil { return nil, err } + task.AgentType = agentType.String + task.RepositoryURL = repositoryURL.String + task.Branch = branch.String + task.AssignedTo = assignedTo.String _ = json.Unmarshal([]byte(metadata), &task.Metadata) result = append(result, task) } @@ -950,10 +982,26 @@ func (r *SQLiteRepository) CreateComment(ctx context.Context, comment *models.Co requestsInput = 1 } + // Default type to "message" if empty + commentType := string(comment.Type) + if commentType == "" { + commentType = string(models.CommentTypeMessage) + } + + // Serialize metadata to JSON + metadataJSON := "{}" + if comment.Metadata != nil { + metadataBytes, err := json.Marshal(comment.Metadata) + if err != nil { + return fmt.Errorf("failed to serialize comment metadata: %w", err) + } + metadataJSON = string(metadataBytes) + } + _, err := r.db.ExecContext(ctx, ` - INSERT INTO task_comments (id, task_id, author_type, author_id, content, requests_input, acp_session_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, comment.ID, comment.TaskID, comment.AuthorType, comment.AuthorID, comment.Content, requestsInput, comment.ACPSessionID, comment.CreatedAt) + INSERT INTO task_comments (id, task_id, author_type, author_id, content, requests_input, acp_session_id, type, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, comment.ID, comment.TaskID, comment.AuthorType, comment.AuthorID, comment.Content, requestsInput, comment.ACPSessionID, commentType, metadataJSON, comment.CreatedAt) return err } @@ -962,21 +1010,32 @@ func (r *SQLiteRepository) CreateComment(ctx context.Context, comment *models.Co func (r *SQLiteRepository) GetComment(ctx context.Context, id string) (*models.Comment, error) { comment := &models.Comment{} var requestsInput int + var commentType string + var metadataJSON string err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, author_type, author_id, content, requests_input, acp_session_id, created_at + SELECT id, task_id, author_type, author_id, content, requests_input, acp_session_id, type, metadata, created_at FROM task_comments WHERE id = ? - `, id).Scan(&comment.ID, &comment.TaskID, &comment.AuthorType, &comment.AuthorID, &comment.Content, &requestsInput, &comment.ACPSessionID, &comment.CreatedAt) + `, id).Scan(&comment.ID, &comment.TaskID, &comment.AuthorType, &comment.AuthorID, &comment.Content, &requestsInput, &comment.ACPSessionID, &commentType, &metadataJSON, &comment.CreatedAt) if err != nil { return nil, err } comment.RequestsInput = requestsInput == 1 + comment.Type = models.CommentType(commentType) + + // Deserialize metadata from JSON + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &comment.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize comment metadata: %w", err) + } + } + return comment, nil } // ListComments returns all comments for a task ordered by creation time func (r *SQLiteRepository) ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, task_id, author_type, author_id, content, requests_input, acp_session_id, created_at + SELECT id, task_id, author_type, author_id, content, requests_input, acp_session_id, type, metadata, created_at FROM task_comments WHERE task_id = ? ORDER BY created_at ASC `, taskID) if err != nil { @@ -988,11 +1047,22 @@ func (r *SQLiteRepository) ListComments(ctx context.Context, taskID string) ([]* for rows.Next() { comment := &models.Comment{} var requestsInput int - err := rows.Scan(&comment.ID, &comment.TaskID, &comment.AuthorType, &comment.AuthorID, &comment.Content, &requestsInput, &comment.ACPSessionID, &comment.CreatedAt) + var commentType string + var metadataJSON string + err := rows.Scan(&comment.ID, &comment.TaskID, &comment.AuthorType, &comment.AuthorID, &comment.Content, &requestsInput, &comment.ACPSessionID, &commentType, &metadataJSON, &comment.CreatedAt) if err != nil { return nil, err } comment.RequestsInput = requestsInput == 1 + comment.Type = models.CommentType(commentType) + + // Deserialize metadata from JSON + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &comment.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize comment metadata: %w", err) + } + } + result = append(result, comment) } return result, rows.Err() @@ -1202,3 +1272,274 @@ func (r *SQLiteRepository) ListRepositoryScripts(ctx context.Context, repository } return result, rows.Err() } + + +// Agent Session operations + +// CreateAgentSession creates a new agent session +func (r *SQLiteRepository) CreateAgentSession(ctx context.Context, session *models.AgentSession) error { + if session.ID == "" { + session.ID = uuid.New().String() + } + now := time.Now().UTC() + session.StartedAt = now + session.UpdatedAt = now + + metadataJSON, err := json.Marshal(session.Metadata) + if err != nil { + return fmt.Errorf("failed to serialize agent session metadata: %w", err) + } + + _, err = r.db.ExecContext(ctx, ` + INSERT INTO agent_sessions ( + id, task_id, agent_instance_id, agent_type, acp_session_id, status, progress, + error_message, metadata, started_at, completed_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, session.ID, session.TaskID, session.AgentInstanceID, session.AgentType, session.ACPSessionID, + string(session.Status), session.Progress, session.ErrorMessage, string(metadataJSON), + session.StartedAt, session.CompletedAt, session.UpdatedAt) + + return err +} + +// GetAgentSession retrieves an agent session by ID +func (r *SQLiteRepository) GetAgentSession(ctx context.Context, id string) (*models.AgentSession, error) { + session := &models.AgentSession{} + var status string + var metadataJSON string + var completedAt sql.NullTime + + err := r.db.QueryRowContext(ctx, ` + SELECT id, task_id, agent_instance_id, agent_type, acp_session_id, status, progress, + error_message, metadata, started_at, completed_at, updated_at + FROM agent_sessions WHERE id = ? + `, id).Scan( + &session.ID, &session.TaskID, &session.AgentInstanceID, &session.AgentType, + &session.ACPSessionID, &status, &session.Progress, &session.ErrorMessage, + &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("agent session not found: %s", id) + } + if err != nil { + return nil, err + } + + session.Status = models.AgentSessionStatus(status) + if completedAt.Valid { + session.CompletedAt = &completedAt.Time + } + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) + } + } + + return session, nil +} + +// GetAgentSessionByTaskID retrieves the most recent agent session for a task +func (r *SQLiteRepository) GetAgentSessionByTaskID(ctx context.Context, taskID string) (*models.AgentSession, error) { + session := &models.AgentSession{} + var status string + var metadataJSON string + var completedAt sql.NullTime + + err := r.db.QueryRowContext(ctx, ` + SELECT id, task_id, agent_instance_id, agent_type, acp_session_id, status, progress, + error_message, metadata, started_at, completed_at, updated_at + FROM agent_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1 + `, taskID).Scan( + &session.ID, &session.TaskID, &session.AgentInstanceID, &session.AgentType, + &session.ACPSessionID, &status, &session.Progress, &session.ErrorMessage, + &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("agent session not found for task: %s", taskID) + } + if err != nil { + return nil, err + } + + session.Status = models.AgentSessionStatus(status) + if completedAt.Valid { + session.CompletedAt = &completedAt.Time + } + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) + } + } + + return session, nil +} + +// GetActiveAgentSessionByTaskID retrieves the active (running/waiting) agent session for a task +func (r *SQLiteRepository) GetActiveAgentSessionByTaskID(ctx context.Context, taskID string) (*models.AgentSession, error) { + session := &models.AgentSession{} + var status string + var metadataJSON string + var completedAt sql.NullTime + + err := r.db.QueryRowContext(ctx, ` + SELECT id, task_id, agent_instance_id, agent_type, acp_session_id, status, progress, + error_message, metadata, started_at, completed_at, updated_at + FROM agent_sessions + WHERE task_id = ? AND status IN ('pending', 'running', 'waiting') + ORDER BY started_at DESC LIMIT 1 + `, taskID).Scan( + &session.ID, &session.TaskID, &session.AgentInstanceID, &session.AgentType, + &session.ACPSessionID, &status, &session.Progress, &session.ErrorMessage, + &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no active agent session for task: %s", taskID) + } + if err != nil { + return nil, err + } + + session.Status = models.AgentSessionStatus(status) + if completedAt.Valid { + session.CompletedAt = &completedAt.Time + } + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) + } + } + + return session, nil +} + + +// UpdateAgentSession updates an existing agent session +func (r *SQLiteRepository) UpdateAgentSession(ctx context.Context, session *models.AgentSession) error { + session.UpdatedAt = time.Now().UTC() + + metadataJSON, err := json.Marshal(session.Metadata) + if err != nil { + return fmt.Errorf("failed to serialize agent session metadata: %w", err) + } + + result, err := r.db.ExecContext(ctx, ` + UPDATE agent_sessions SET + agent_instance_id = ?, agent_type = ?, acp_session_id = ?, status = ?, progress = ?, + error_message = ?, metadata = ?, completed_at = ?, updated_at = ? + WHERE id = ? + `, session.AgentInstanceID, session.AgentType, session.ACPSessionID, string(session.Status), + session.Progress, session.ErrorMessage, string(metadataJSON), session.CompletedAt, + session.UpdatedAt, session.ID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("agent session not found: %s", session.ID) + } + return nil +} + +// UpdateAgentSessionStatus updates just the status and error message of an agent session +func (r *SQLiteRepository) UpdateAgentSessionStatus(ctx context.Context, id string, status models.AgentSessionStatus, errorMessage string) error { + now := time.Now().UTC() + + var completedAt *time.Time + if status == models.AgentSessionStatusCompleted || status == models.AgentSessionStatusFailed || status == models.AgentSessionStatusStopped { + completedAt = &now + } + + result, err := r.db.ExecContext(ctx, ` + UPDATE agent_sessions SET status = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE id = ? + `, string(status), errorMessage, completedAt, now, id) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("agent session not found: %s", id) + } + return nil +} + +// ListAgentSessions returns all agent sessions for a task +func (r *SQLiteRepository) ListAgentSessions(ctx context.Context, taskID string) ([]*models.AgentSession, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, task_id, agent_instance_id, agent_type, acp_session_id, status, progress, + error_message, metadata, started_at, completed_at, updated_at + FROM agent_sessions WHERE task_id = ? ORDER BY started_at DESC + `, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + + return r.scanAgentSessions(rows) +} + +// ListActiveAgentSessions returns all active agent sessions across all tasks +func (r *SQLiteRepository) ListActiveAgentSessions(ctx context.Context) ([]*models.AgentSession, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, task_id, agent_instance_id, agent_type, acp_session_id, status, progress, + error_message, metadata, started_at, completed_at, updated_at + FROM agent_sessions WHERE status IN ('pending', 'running', 'waiting') ORDER BY started_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + return r.scanAgentSessions(rows) +} + +// scanAgentSessions is a helper to scan multiple agent session rows +func (r *SQLiteRepository) scanAgentSessions(rows *sql.Rows) ([]*models.AgentSession, error) { + var result []*models.AgentSession + for rows.Next() { + session := &models.AgentSession{} + var status string + var metadataJSON string + var completedAt sql.NullTime + + err := rows.Scan( + &session.ID, &session.TaskID, &session.AgentInstanceID, &session.AgentType, + &session.ACPSessionID, &status, &session.Progress, &session.ErrorMessage, + &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, + ) + if err != nil { + return nil, err + } + + session.Status = models.AgentSessionStatus(status) + if completedAt.Valid { + session.CompletedAt = &completedAt.Time + } + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) + } + } + + result = append(result, session) + } + return result, rows.Err() +} + +// DeleteAgentSession deletes an agent session by ID +func (r *SQLiteRepository) DeleteAgentSession(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM agent_sessions WHERE id = ?`, id) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("agent session not found: %s", id) + } + return nil +} \ No newline at end of file diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index cbbdb4417c..8d24207827 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -880,12 +880,15 @@ func (s *Service) publishColumnEvent(ctx context.Context, eventType string, colu // CreateCommentRequest contains the data for creating a new comment type CreateCommentRequest struct { - TaskID string `json:"task_id"` - Content string `json:"content"` - AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user" - AuthorID string `json:"author_id,omitempty"` - RequestsInput bool `json:"requests_input,omitempty"` - ACPSessionID string `json:"acp_session_id,omitempty"` + TaskID string `json:"task_id"` + Content string `json:"content"` + AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user" + AuthorID string `json:"author_id,omitempty"` + RequestsInput bool `json:"requests_input,omitempty"` + ACPSessionID string `json:"acp_session_id,omitempty"` + AgentSessionID string `json:"agent_session_id,omitempty"` + Type string `json:"type,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } // CreateComment creates a new comment on a task @@ -901,15 +904,23 @@ func (s *Service) CreateComment(ctx context.Context, req *CreateCommentRequest) authorType = models.CommentAuthorAgent } + commentType := models.CommentType(req.Type) + if commentType == "" { + commentType = models.CommentTypeMessage + } + comment := &models.Comment{ - ID: uuid.New().String(), - TaskID: req.TaskID, - AuthorType: authorType, - AuthorID: req.AuthorID, - Content: req.Content, - RequestsInput: req.RequestsInput, - ACPSessionID: req.ACPSessionID, - CreatedAt: time.Now().UTC(), + ID: uuid.New().String(), + TaskID: req.TaskID, + AuthorType: authorType, + AuthorID: req.AuthorID, + Content: req.Content, + Type: commentType, + Metadata: req.Metadata, + RequestsInput: req.RequestsInput, + ACPSessionID: req.ACPSessionID, + AgentSessionID: req.AgentSessionID, + CreatedAt: time.Now().UTC(), } if err := s.repo.CreateComment(ctx, comment); err != nil { @@ -955,12 +966,18 @@ func (s *Service) publishCommentEvent(ctx context.Context, eventType string, com return } + commentType := string(comment.Type) + if commentType == "" { + commentType = "message" + } + data := map[string]interface{}{ "comment_id": comment.ID, "task_id": comment.TaskID, "author_type": string(comment.AuthorType), "author_id": comment.AuthorID, "content": comment.Content, + "type": commentType, "requests_input": comment.RequestsInput, "created_at": comment.CreatedAt.Format(time.RFC3339), } @@ -969,6 +986,10 @@ func (s *Service) publishCommentEvent(ctx context.Context, eventType string, com data["acp_session_id"] = comment.ACPSessionID } + if comment.Metadata != nil { + data["metadata"] = comment.Metadata + } + event := bus.NewEvent(eventType, "task-service", data) if err := s.eventBus.Publish(ctx, eventType, event); err != nil { diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index e5d58ed21a..03d11eef4e 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -80,21 +80,26 @@ type TaskEvent struct { // Comment represents a comment on a task (user or agent) type Comment struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - AuthorType string `json:"author_type"` // "user" or "agent" - AuthorID string `json:"author_id,omitempty"` - Content string `json:"content"` - RequestsInput bool `json:"requests_input"` // True if agent is requesting user input - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TaskID string `json:"task_id"` + AuthorType string `json:"author_type"` // "user" or "agent" + Type string `json:"type,omitempty"` + AuthorID string `json:"author_id,omitempty"` + Content string `json:"content"` + RequestsInput bool `json:"requests_input"` // True if agent is requesting user input + Metadata map[string]interface{} `json:"metadata,omitempty"` + AgentSessionID string `json:"agent_session_id,omitempty"` + CreatedAt time.Time `json:"created_at"` } // CreateCommentRequest for adding a comment to a task type CreateCommentRequest struct { - TaskID string `json:"task_id" binding:"required"` - Content string `json:"content" binding:"required"` - AuthorType string `json:"author_type,omitempty"` // Defaults to "user" if not specified - RequestsInput bool `json:"requests_input,omitempty"` + TaskID string `json:"task_id" binding:"required"` + Content string `json:"content" binding:"required"` + AuthorType string `json:"author_type,omitempty"` // Defaults to "user" if not specified + Type string `json:"type,omitempty"` + RequestsInput bool `json:"requests_input,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } // PermissionOption represents a permission choice presented to the user diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index b6f22b61f7..e3e819da48 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -47,8 +47,7 @@ const ( ActionTaskUpdate = "task.update" ActionTaskDelete = "task.delete" ActionTaskMove = "task.move" - ActionTaskState = "task.state" - ActionTaskLogs = "task.logs" + ActionTaskState = "task.state" // Agent actions ActionAgentList = "agent.list"