Merge pull request #6 from kdlbs/feat/agent-session-persistence

feat(backend): Persistent Agent Session Tracking
This commit is contained in:
João Salavisa
2026-01-12 02:10:45 +00:00
committed by GitHub
28 changed files with 1514 additions and 2568 deletions
+23 -1
View File
@@ -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"
+141 -41
View File
@@ -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 ""
}
+74
View File
@@ -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}"
+76
View File
@@ -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'));
@@ -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()
+3 -1
View File
@@ -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
@@ -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(&notifPayload)
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
// ============================================
@@ -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)
@@ -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))
}
@@ -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
}
@@ -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)
}
}
@@ -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, &timestamp)
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
}
@@ -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)
}
@@ -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 {
@@ -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.
@@ -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"`
@@ -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 &copy, 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, &copy)
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
@@ -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"`
@@ -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()
+40 -4
View File
@@ -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))
}
}
}
@@ -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)
}
+101 -9
View File
@@ -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
@@ -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)
@@ -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
}
+370 -29
View File
@@ -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
}
+35 -14
View File
@@ -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 {
+16 -11
View File
@@ -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
+1 -2
View File
@@ -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"