Merge pull request #6 from kdlbs/feat/agent-session-persistence
feat(backend): Persistent Agent Session Tracking
This commit is contained in:
+23
-1
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
Executable
+74
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(¬ifPayload)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, taskID, notifPayload["task_id"])
|
||||
assert.NotNil(t, notifPayload["type"])
|
||||
assert.NotNil(t, notifPayload["data"])
|
||||
assert.NotNil(t, notifPayload["timestamp"])
|
||||
}
|
||||
|
||||
// Unsubscribe
|
||||
client.SendRequest("unsub-1", ws.ActionTaskUnsubscribe, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestHistoricalLogsChronologicalOrder(t *testing.T) {
|
||||
ts := NewTestServerWithACP(t)
|
||||
defer ts.Close()
|
||||
|
||||
client := NewWSClient(t, ts.Server.URL)
|
||||
defer client.Close()
|
||||
|
||||
workspaceID := createWorkspace(t, client)
|
||||
|
||||
// Create a task
|
||||
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Chrono Test Board",
|
||||
})
|
||||
var boardPayload map[string]interface{}
|
||||
boardResp.ParsePayload(&boardPayload)
|
||||
boardID := boardPayload["id"].(string)
|
||||
|
||||
colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "To Do",
|
||||
"position": 0,
|
||||
})
|
||||
var colPayload map[string]interface{}
|
||||
colResp.ParsePayload(&colPayload)
|
||||
columnID := colPayload["id"].(string)
|
||||
|
||||
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"column_id": columnID,
|
||||
"title": "Chronological Order Test Task",
|
||||
})
|
||||
var taskPayload map[string]interface{}
|
||||
taskResp.ParsePayload(&taskPayload)
|
||||
taskID := taskPayload["id"].(string)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create messages in chronological order with specific timestamps
|
||||
// In production, messages arrive in temporal order, so we test that they
|
||||
// are returned in the same order they were stored
|
||||
baseTime := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
messagesInOrder := []*protocol.Message{
|
||||
{
|
||||
Type: protocol.MessageTypeProgress,
|
||||
Timestamp: baseTime.Add(10 * time.Second), // 10:00:10
|
||||
AgentID: "agent-1",
|
||||
TaskID: taskID,
|
||||
Data: map[string]interface{}{"message": "First message", "order": 1},
|
||||
},
|
||||
{
|
||||
Type: protocol.MessageTypeLog,
|
||||
Timestamp: baseTime.Add(20 * time.Second), // 10:00:20
|
||||
AgentID: "agent-1",
|
||||
TaskID: taskID,
|
||||
Data: map[string]interface{}{"message": "Second message", "order": 2},
|
||||
},
|
||||
{
|
||||
Type: protocol.MessageTypeLog,
|
||||
Timestamp: baseTime.Add(30 * time.Second), // 10:00:30
|
||||
AgentID: "agent-1",
|
||||
TaskID: taskID,
|
||||
Data: map[string]interface{}{"message": "Third message", "order": 3},
|
||||
},
|
||||
{
|
||||
Type: protocol.MessageTypeResult,
|
||||
Timestamp: baseTime.Add(40 * time.Second), // 10:00:40
|
||||
AgentID: "agent-1",
|
||||
TaskID: taskID,
|
||||
Data: map[string]interface{}{"message": "Fourth message", "order": 4},
|
||||
},
|
||||
}
|
||||
|
||||
for _, msg := range messagesInOrder {
|
||||
err := ts.ACPHandler.ProcessMessage(ctx, msg)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Retrieve logs via task.logs
|
||||
t.Run("LogsChronologicalOrder", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("logs-chrono", ws.ActionTaskLogs, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeResponse, resp.Type)
|
||||
|
||||
var payload map[string]interface{}
|
||||
err = resp.ParsePayload(&payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
logs, ok := payload["logs"].([]interface{})
|
||||
require.True(t, ok)
|
||||
require.Len(t, logs, 4)
|
||||
|
||||
// Verify order matches chronological order (same as insertion order here)
|
||||
for i, log := range logs {
|
||||
logEntry := log.(map[string]interface{})
|
||||
data := logEntry["data"].(map[string]interface{})
|
||||
expectedOrder := float64(i + 1)
|
||||
assert.Equal(t, expectedOrder, data["order"], "Log at index %d should have order %v", i, expectedOrder)
|
||||
}
|
||||
|
||||
// Verify timestamps are in non-decreasing order
|
||||
var prevTimestamp string
|
||||
for i, log := range logs {
|
||||
logEntry := log.(map[string]interface{})
|
||||
currentTimestamp := logEntry["timestamp"].(string)
|
||||
if i > 0 {
|
||||
assert.True(t, currentTimestamp >= prevTimestamp,
|
||||
"Timestamp at index %d should be >= timestamp at index %d", i, i-1)
|
||||
}
|
||||
prevTimestamp = currentTimestamp
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONCURRENT REQUEST TESTS
|
||||
// ============================================
|
||||
|
||||
@@ -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, ×tamp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(metadataStr), &data); err != nil {
|
||||
data = make(map[string]interface{})
|
||||
}
|
||||
|
||||
msg := &protocol.Message{
|
||||
Type: protocol.MessageType(messageType),
|
||||
Timestamp: timestamp,
|
||||
AgentID: agentID,
|
||||
TaskID: taskID,
|
||||
Data: data,
|
||||
}
|
||||
result = append(result, msg)
|
||||
}
|
||||
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// extractLogLevel extracts a log level from the ACP message
|
||||
func (s *SQLiteMessageStore) extractLogLevel(msg *protocol.Message) string {
|
||||
// For log messages, extract level from data
|
||||
if msg.Type == protocol.MessageTypeLog {
|
||||
if level, ok := msg.Data["level"].(string); ok {
|
||||
return level
|
||||
}
|
||||
}
|
||||
|
||||
// Map message types to log levels
|
||||
switch msg.Type {
|
||||
case protocol.MessageTypeError:
|
||||
return "error"
|
||||
case protocol.MessageTypeProgress, protocol.MessageTypeStatus:
|
||||
return "info"
|
||||
case protocol.MessageTypeHeartbeat:
|
||||
return "debug"
|
||||
default:
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
|
||||
// extractMessageText extracts a human-readable message from the ACP message
|
||||
func (s *SQLiteMessageStore) extractMessageText(msg *protocol.Message) string {
|
||||
// Try common message fields
|
||||
if message, ok := msg.Data["message"].(string); ok {
|
||||
return message
|
||||
}
|
||||
if text, ok := msg.Data["text"].(string); ok {
|
||||
return text
|
||||
}
|
||||
if errMsg, ok := msg.Data["error"].(string); ok {
|
||||
return errMsg
|
||||
}
|
||||
if summary, ok := msg.Data["summary"].(string); ok {
|
||||
return summary
|
||||
}
|
||||
|
||||
// For content updates
|
||||
if msg.Type == protocol.MessageTypeProgress {
|
||||
if progress, ok := msg.Data["progress"].(float64); ok {
|
||||
return fmt.Sprintf("%d%% complete", int(progress))
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractProgressDataFromMap extracts ProgressData from a map
|
||||
func (s *SQLiteMessageStore) extractProgressDataFromMap(data map[string]interface{}) *protocol.ProgressData {
|
||||
pd := &protocol.ProgressData{}
|
||||
|
||||
if progress, ok := data["progress"].(float64); ok {
|
||||
pd.Progress = int(progress)
|
||||
} else if progress, ok := data["progress"].(int); ok {
|
||||
pd.Progress = progress
|
||||
}
|
||||
|
||||
if message, ok := data["message"].(string); ok {
|
||||
pd.Message = message
|
||||
}
|
||||
|
||||
if currentFile, ok := data["current_file"].(string); ok {
|
||||
pd.CurrentFile = currentFile
|
||||
}
|
||||
|
||||
if filesProcessed, ok := data["files_processed"].(float64); ok {
|
||||
pd.FilesProcessed = int(filesProcessed)
|
||||
} else if filesProcessed, ok := data["files_processed"].(int); ok {
|
||||
pd.FilesProcessed = filesProcessed
|
||||
}
|
||||
|
||||
if totalFiles, ok := data["total_files"].(float64); ok {
|
||||
pd.TotalFiles = int(totalFiles)
|
||||
} else if totalFiles, ok := data["total_files"].(int); ok {
|
||||
pd.TotalFiles = totalFiles
|
||||
}
|
||||
|
||||
return pd
|
||||
}
|
||||
|
||||
@@ -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 ©, true
|
||||
// Cache it in memory
|
||||
execution = FromAgentSession(session)
|
||||
e.mu.Lock()
|
||||
e.executions[taskID] = execution
|
||||
e.mu.Unlock()
|
||||
|
||||
execCopy := *execution
|
||||
return &execCopy, true
|
||||
}
|
||||
|
||||
// GetExecutionWithContext returns the current execution state for a task with context
|
||||
func (e *Executor) GetExecutionWithContext(ctx context.Context, taskID string) (*TaskExecution, bool) {
|
||||
e.mu.RLock()
|
||||
execution, exists := e.executions[taskID]
|
||||
e.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
execCopy := *execution
|
||||
return &execCopy, true
|
||||
}
|
||||
|
||||
// Try to load from database
|
||||
session, err := e.repo.GetActiveAgentSessionByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Cache it in memory
|
||||
execution = FromAgentSession(session)
|
||||
e.mu.Lock()
|
||||
e.executions[taskID] = execution
|
||||
e.mu.Unlock()
|
||||
|
||||
execCopy := *execution
|
||||
return &execCopy, true
|
||||
}
|
||||
|
||||
// ListExecutions returns all active executions
|
||||
@@ -264,8 +448,8 @@ func (e *Executor) ListExecutions() []*TaskExecution {
|
||||
|
||||
result := make([]*TaskExecution, 0, len(e.executions))
|
||||
for _, exec := range e.executions {
|
||||
copy := *exec
|
||||
result = append(result, ©)
|
||||
execCopy := *exec
|
||||
result = append(result, &execCopy)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -283,39 +467,69 @@ func (e *Executor) CanExecute() bool {
|
||||
}
|
||||
|
||||
// UpdateProgress updates the progress of an execution (called when ACP progress messages are received)
|
||||
func (e *Executor) UpdateProgress(taskID string, progress int, status v1.AgentStatus) {
|
||||
func (e *Executor) UpdateProgress(ctx context.Context, taskID string, progress int, status v1.AgentStatus) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
var sessionID string
|
||||
if exec, ok := e.executions[taskID]; ok {
|
||||
exec.Progress = progress
|
||||
exec.Status = status
|
||||
exec.LastUpdate = time.Now()
|
||||
sessionID = exec.SessionID
|
||||
|
||||
e.logger.Debug("updated execution progress",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Int("progress", progress),
|
||||
zap.String("status", string(status)))
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
// Update database asynchronously (don't block on this)
|
||||
if sessionID != "" {
|
||||
go func() {
|
||||
session, err := e.repo.GetAgentSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
session.Progress = progress
|
||||
session.Status = v1StatusToAgentSessionStatus(status)
|
||||
if err := e.repo.UpdateAgentSession(ctx, session); err != nil {
|
||||
e.logger.Error("failed to update agent session progress in database",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// MarkCompleted marks an execution as completed
|
||||
func (e *Executor) MarkCompleted(taskID string, status v1.AgentStatus) {
|
||||
func (e *Executor) MarkCompleted(ctx context.Context, taskID string, status v1.AgentStatus) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
var sessionID string
|
||||
if exec, ok := e.executions[taskID]; ok {
|
||||
exec.Status = status
|
||||
exec.Progress = 100
|
||||
exec.LastUpdate = time.Now()
|
||||
sessionID = exec.SessionID
|
||||
|
||||
e.logger.Info("execution completed",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("status", string(status)))
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
// Update database
|
||||
if sessionID != "" {
|
||||
dbStatus := v1StatusToAgentSessionStatus(status)
|
||||
if err := e.repo.UpdateAgentSessionStatus(ctx, sessionID, dbStatus, ""); err != nil {
|
||||
e.logger.Error("failed to update agent session status in database",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExecution removes a completed/failed execution from tracking
|
||||
// RemoveExecution removes a completed/failed execution from in-memory tracking
|
||||
// Note: The database record is preserved for history
|
||||
func (e *Executor) RemoveExecution(taskID string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
@@ -326,6 +540,37 @@ func (e *Executor) RemoveExecution(taskID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetActiveSessionID returns the active session ID for a task, if any
|
||||
func (e *Executor) GetActiveSessionID(ctx context.Context, taskID string) (string, error) {
|
||||
// First check in-memory cache
|
||||
e.mu.RLock()
|
||||
execution, exists := e.executions[taskID]
|
||||
e.mu.RUnlock()
|
||||
|
||||
if exists && execution.SessionID != "" {
|
||||
return execution.SessionID, nil
|
||||
}
|
||||
|
||||
// Try to load from database
|
||||
session, err := e.repo.GetActiveAgentSessionByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return session.ID, nil
|
||||
}
|
||||
|
||||
// UpdateSessionACPSessionID updates the ACP session ID for an agent session
|
||||
func (e *Executor) UpdateSessionACPSessionID(ctx context.Context, taskID, acpSessionID string) error {
|
||||
session, err := e.repo.GetActiveAgentSessionByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.ACPSessionID = acpSessionID
|
||||
return e.repo.UpdateAgentSession(ctx, session)
|
||||
}
|
||||
|
||||
// MockAgentManagerClient is a placeholder implementation
|
||||
type MockAgentManagerClient struct {
|
||||
logger *logger.Logger
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user