diff --git a/apps/backend/NEXT_STEPS.md b/apps/backend/NEXT_STEPS.md index a9b06e32a9..69c4d83134 100644 --- a/apps/backend/NEXT_STEPS.md +++ b/apps/backend/NEXT_STEPS.md @@ -23,7 +23,6 @@ The backend is a unified Go binary (`cmd/kandev/main.go`) that runs: 3. **Session Resumption** - Sessions persist in `~/.augment/sessions/` (mounted into containers) - - Task metadata stores `auggie_session_id` - Follow-up agents can resume with `AUGGIE_SESSION_ID` env var 4. **Real-time Streaming** diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 169fae5475..68fa80f2e1 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -28,8 +28,8 @@ import ( gateways "github.com/kandev/kandev/internal/gateway/websocket" // Agent Manager packages - agentcontroller "github.com/kandev/kandev/internal/agent/controller" "github.com/kandev/kandev/internal/agent/agentctl/launcher" + agentcontroller "github.com/kandev/kandev/internal/agent/controller" "github.com/kandev/kandev/internal/agent/credentials" "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/docker" @@ -50,6 +50,7 @@ import ( // Task Service packages taskcontroller "github.com/kandev/kandev/internal/task/controller" taskhandlers "github.com/kandev/kandev/internal/task/handlers" + "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" taskservice "github.com/kandev/kandev/internal/task/service" usercontroller "github.com/kandev/kandev/internal/user/controller" @@ -202,7 +203,7 @@ func main() { if err != nil { log.Fatal("Failed to load agent discovery config", zap.Error(err)) } - agentSettingsController := agentsettingscontroller.NewController(agentSettingsRepo, discoveryRegistry, log) + agentSettingsController := agentsettingscontroller.NewController(agentSettingsRepo, discoveryRegistry, taskRepo, log) userSvc := userservice.NewService(userRepo, eventBus, log) userCtrl := usercontroller.NewController(userSvc) @@ -360,9 +361,10 @@ func main() { serviceCfg := orchestrator.DefaultServiceConfig() orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, log) + taskSvc.SetExecutionStopper(orchestratorSvc) // Set up comment creator for saving agent responses as comments - orchestratorSvc.SetCommentCreator(&commentCreatorAdapter{svc: taskSvc}) + orchestratorSvc.SetMessageCreator(&messageCreatorAdapter{svc: taskSvc}) // ============================================ // WEBSOCKET GATEWAY (All communication via WebSocket) @@ -379,7 +381,7 @@ func main() { if worktreeMgr != nil { taskController.SetWorktreeLookup(worktreeMgr) // Enable worktree info in task responses } - commentController := taskcontroller.NewCommentController(taskSvc) + messageController := taskcontroller.NewMessageController(taskSvc) repositoryController := taskcontroller.NewRepositoryController(taskSvc) executorController := taskcontroller.NewExecutorController(taskSvc) environmentController := taskcontroller.NewEnvironmentController(taskSvc) @@ -409,33 +411,38 @@ func main() { gateways.RegisterUserNotifications(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 + // Uses messages instead of execution logs - all agent messages are now messages gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) { - comments, err := taskSvc.ListComments(ctx, taskID) + var session *models.AgentSession + session, err = taskRepo.GetActiveAgentSessionByTaskID(ctx, taskID) + if err != nil { + session, err = taskRepo.GetAgentSessionByTaskID(ctx, taskID) + if err != nil { + return nil, nil + } + } + + messages, err := taskSvc.ListMessages(ctx, session.ID) if err != nil { return nil, err } - // 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 + result := make([]*ws.Message, 0, len(messages)) + for _, message := range messages { + action := ws.ActionMessageAdded 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), + "message_id": message.ID, + "agent_session_id": message.AgentSessionID, + "task_id": message.TaskID, + "author_type": string(message.AuthorType), + "author_id": message.AuthorID, + "content": message.Content, + "type": string(message.Type), + "requests_input": message.RequestsInput, + "created_at": message.CreatedAt.Format(time.RFC3339), } - if comment.ACPSessionID != "" { - payload["acp_session_id"] = comment.ACPSessionID - } - if comment.Metadata != nil { - payload["metadata"] = comment.Metadata + if message.Metadata != nil { + payload["metadata"] = message.Metadata } notification, err := ws.NewNotification(action, payload) if err != nil { @@ -445,7 +452,7 @@ func main() { } // Also send current git status if available - session, err := taskRepo.GetActiveAgentSessionByTaskID(ctx, taskID) + session, err = taskRepo.GetActiveAgentSessionByTaskID(ctx, taskID) if err == nil && session != nil && session.Metadata != nil { if gitStatus, ok := session.Metadata["git_status"]; ok { // Add task_id to the git status data @@ -464,7 +471,7 @@ func main() { return result, nil }) - log.Info("Historical logs provider configured for task subscriptions (using comments and git status)") + log.Info("Historical logs provider configured for task subscriptions (using messages and git status)") // NOTE: We no longer create comments for each ACP message chunk. // Instead, the lifecycle manager accumulates message chunks and: @@ -494,70 +501,53 @@ func main() { }) gateway.Hub.BroadcastToTask(taskID, notification) default: - // Skip other message types (session/update, session_info, etc.) + // Skip other message types (session/update, etc.) } }) - // Wire ACP handler to extract session_id from session_info messages and store in task metadata - orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) { - log.Debug("ACP handler received message", - zap.String("task_id", taskID), - zap.String("message_type", string(msg.Type))) - - // Check for session_info message type (custom type from augment-agent) - if msg.Type == "session_info" && msg.Data != nil { - log.Debug("session_info message received, extracting session_id") - if sessionID, ok := msg.Data["session_id"].(string); ok && sessionID != "" { - metadata := map[string]interface{}{ - "auggie_session_id": sessionID, - } - if _, err := taskSvc.UpdateTaskMetadata(context.Background(), taskID, metadata); err != nil { - log.Error("failed to store auggie session_id in task metadata", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Error(err)) - } else { - log.Info("stored auggie session_id in task metadata", - zap.String("task_id", taskID), - zap.String("session_id", sessionID)) - } - } - } - }) - - // Wire input request handler to create agent comments when input is requested + // Wire input request handler to create agent messages when input is requested orchestratorSvc.SetInputRequestHandler(func(ctx context.Context, taskID, agentID, message string) error { - log.Info("agent requesting user input, creating comment", + log.Info("agent requesting user input, creating message", zap.String("task_id", taskID), zap.String("agent_id", agentID)) - // Create a comment from the agent - comment, err := taskSvc.CreateComment(ctx, &taskservice.CreateCommentRequest{ - TaskID: taskID, - Content: message, - AuthorType: "agent", - AuthorID: agentID, - RequestsInput: true, - }) + session, err := taskRepo.GetActiveAgentSessionByTaskID(ctx, taskID) if err != nil { - log.Error("failed to create agent comment", + log.Error("failed to resolve active agent session for input request", zap.String("task_id", taskID), zap.Error(err)) return err } - // Broadcast comment.added notification to task subscribers - notification, _ := ws.NewNotification(ws.ActionCommentAdded, map[string]interface{}{ - "task_id": taskID, - "comment": comment.ToAPI(), - "requests_input": true, + // Create a message from the agent + messageRecord, err := taskSvc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + AgentSessionID: session.ID, + TaskID: taskID, + Content: message, + AuthorType: "agent", + AuthorID: agentID, + RequestsInput: true, + }) + if err != nil { + log.Error("failed to create agent message", + zap.String("task_id", taskID), + zap.Error(err)) + return err + } + + // Broadcast message.added notification to task subscribers + notification, _ := ws.NewNotification(ws.ActionMessageAdded, map[string]interface{}{ + "task_id": taskID, + "agent_session_id": session.ID, + "message": messageRecord.ToAPI(), + "requests_input": true, }) gateway.Hub.BroadcastToTask(taskID, notification) // Also broadcast input.requested notification inputNotification, _ := ws.NewNotification(ws.ActionInputRequested, map[string]interface{}{ "task_id": taskID, - "comment_id": comment.ID, + "message_id": messageRecord.ID, "message": message, }) gateway.Hub.BroadcastToTask(taskID, inputNotification) @@ -570,74 +560,99 @@ func main() { } log.Info("Orchestrator initialized") - // Subscribe to comment.added events and broadcast to WebSocket subscribers - _, err = eventBus.Subscribe(events.CommentAdded, func(ctx context.Context, event *bus.Event) error { + // Subscribe to message.added events and broadcast to WebSocket subscribers + _, err = eventBus.Subscribe(events.MessageAdded, func(ctx context.Context, event *bus.Event) error { data := event.Data + agentSessionID, _ := data["agent_session_id"].(string) taskID, _ := data["task_id"].(string) - if taskID == "" { + if agentSessionID == "" { return nil } 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"], + "task_id": taskID, + "agent_session_id": agentSessionID, + "message_id": data["message_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, err := ws.NewNotification(ws.ActionCommentAdded, payload) + notification, err := ws.NewNotification(ws.ActionMessageAdded, payload) if err != nil { - log.Error("Failed to create comment.added notification", zap.Error(err)) + log.Error("Failed to create message.added notification", zap.Error(err)) return nil } gateway.Hub.BroadcastToTask(taskID, notification) return nil }) if err != nil { - log.Error("Failed to subscribe to comment.added events", zap.Error(err)) + log.Error("Failed to subscribe to message.added events", zap.Error(err)) } else { - log.Info("Subscribed to comment.added events for WebSocket broadcasting") + log.Info("Subscribed to message.added events for WebSocket broadcasting") } - // Subscribe to comment.updated events and broadcast to WebSocket subscribers - _, err = eventBus.Subscribe(events.CommentUpdated, func(ctx context.Context, event *bus.Event) error { + // Subscribe to message.updated events and broadcast to WebSocket subscribers + _, err = eventBus.Subscribe(events.MessageUpdated, func(ctx context.Context, event *bus.Event) error { data := event.Data + agentSessionID, _ := data["agent_session_id"].(string) taskID, _ := data["task_id"].(string) - if taskID == "" { - log.Warn("comment.updated event has no task_id, skipping") + if agentSessionID == "" { + log.Warn("message.updated event has no agent_session_id, skipping") return nil } payload := map[string]interface{}{ - "comment_id": data["comment_id"], - "task_id": taskID, - "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"], + "message_id": data["message_id"], + "agent_session_id": agentSessionID, + "task_id": taskID, + "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, err := ws.NewNotification(ws.ActionCommentUpdated, payload) + notification, err := ws.NewNotification(ws.ActionMessageUpdated, payload) if err != nil { - log.Error("Failed to create comment.updated notification", zap.Error(err)) + log.Error("Failed to create message.updated notification", zap.Error(err)) return nil } gateway.Hub.BroadcastToTask(taskID, notification) return nil }) if err != nil { - log.Error("Failed to subscribe to comment.updated events", zap.Error(err)) + log.Error("Failed to subscribe to message.updated events", zap.Error(err)) } else { - log.Info("Subscribed to comment.updated events for WebSocket broadcasting") + log.Info("Subscribed to message.updated events for WebSocket broadcasting") + } + + // Subscribe to agent_session.state_changed events and broadcast to task subscribers + _, err = eventBus.Subscribe(events.AgentSessionStateChanged, func(ctx context.Context, event *bus.Event) error { + data := event.Data + taskID, _ := data["task_id"].(string) + if taskID == "" { + return nil + } + notification, err := ws.NewNotification(ws.ActionAgentSessionStateChanged, data) + if err != nil { + log.Error("Failed to create agent_session.state_changed notification", zap.Error(err)) + return nil + } + gateway.Hub.BroadcastToTask(taskID, notification) + return nil + }) + if err != nil { + log.Error("Failed to subscribe to agent_session.state_changed events", zap.Error(err)) + } else { + log.Info("Subscribed to agent_session.state_changed events for WebSocket broadcasting") } // Subscribe to git status events and broadcast to WebSocket subscribers @@ -698,10 +713,10 @@ func main() { taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) - taskhandlers.RegisterCommentRoutes( + taskhandlers.RegisterMessageRoutes( router, gateway.Dispatcher, - commentController, + messageController, taskController, &orchestratorAdapter{svc: orchestratorSvc}, log, @@ -990,24 +1005,35 @@ func (a *orchestratorAdapter) PromptTask(ctx context.Context, taskID string, pro }, nil } -// commentCreatorAdapter adapts the task service to the orchestrator.CommentCreator interface -type commentCreatorAdapter struct { +// messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface +type messageCreatorAdapter struct { svc *taskservice.Service } -// CreateAgentComment creates a comment with author_type="agent" -func (a *commentCreatorAdapter) CreateAgentComment(ctx context.Context, taskID, content, agentSessionID string) error { - _, err := a.svc.CreateComment(ctx, &taskservice.CreateCommentRequest{ +// CreateAgentMessage creates a message with author_type="agent" +func (a *messageCreatorAdapter) CreateAgentMessage(ctx context.Context, taskID, content, agentSessionID string) error { + _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + AgentSessionID: agentSessionID, 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, args map[string]interface{}) error { +// CreateUserMessage creates a message with author_type="user" +func (a *messageCreatorAdapter) CreateUserMessage(ctx context.Context, taskID, content, agentSessionID string) error { + _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + AgentSessionID: agentSessionID, + TaskID: taskID, + Content: content, + AuthorType: "user", + }) + return err +} + +// CreateToolCallMessage creates a message for a tool call with type="tool_call" +func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID string, args map[string]interface{}) error { metadata := map[string]interface{}{ "tool_call_id": toolCallID, "title": title, @@ -1024,20 +1050,32 @@ func (a *commentCreatorAdapter) CreateToolCallComment(ctx context.Context, taskI } } - _, err := a.svc.CreateComment(ctx, &taskservice.CreateCommentRequest{ + _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + AgentSessionID: agentSessionID, TaskID: taskID, Content: title, AuthorType: "agent", - AgentSessionID: agentSessionID, Type: "tool_call", Metadata: metadata, }) return err } -// UpdateToolCallComment updates a tool call comment's status -func (a *commentCreatorAdapter) UpdateToolCallComment(ctx context.Context, taskID, toolCallID, status, result string) error { - return a.svc.UpdateToolCallComment(ctx, taskID, toolCallID, status, result) +// UpdateToolCallMessage updates a tool call message's status +func (a *messageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID string) error { + return a.svc.UpdateToolCallMessage(ctx, agentSessionID, toolCallID, status, result) } - +// CreateSessionMessage creates a message for non-chat session updates (status/progress/error/etc). +func (a *messageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType string, metadata map[string]interface{}, requestsInput bool) error { + _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + AgentSessionID: agentSessionID, + TaskID: taskID, + Content: content, + AuthorType: "agent", + Type: messageType, + Metadata: metadata, + RequestsInput: requestsInput, + }) + return err +} diff --git a/apps/backend/dev/seed.sql b/apps/backend/dev/seed.sql index ab4611c7c5..86e43c19cf 100644 --- a/apps/backend/dev/seed.sql +++ b/apps/backend/dev/seed.sql @@ -66,18 +66,18 @@ VALUES ('task-103', 'dev-board-2', 'col-planned', 'Agent type marketplace', 'Browse and install different agent types', 'TODO', 6, 0, '', '$KANDEV_REPO_PATH', '', '{}', 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) +INSERT OR REPLACE INTO task_comments (id, task_id, author_type, author_id, content, type, metadata, requests_input, 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')), + ('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')); + ('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) +INSERT OR REPLACE INTO agent_sessions (id, task_id, agent_instance_id, agent_type, state, 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')); + ('session-001', 'task-005', 'agent-instance-123', 'augment-agent', 'RUNNING', 0.0, '', '{}', datetime('now', '-1 hour'), NULL, datetime('now')); diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 51f163605e..d360aeb9ad 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -654,23 +654,6 @@ func (m *Manager) buildStandaloneEnv(instanceID string, req *LaunchRequest, agen } } - // Check for ACP session resumption - // Only pass session ID if the session file exists locally - // Docker sessions won't exist on standalone hosts and vice versa - if req.Metadata != nil { - if sessionID, ok := req.Metadata["auggie_session_id"].(string); ok && sessionID != "" { - // Check if session file exists - homeDir, _ := os.UserHomeDir() - sessionPath := filepath.Join(homeDir, ".augment", "sessions", sessionID+".json") - if _, err := os.Stat(sessionPath); err == nil { - env["AUGGIE_SESSION_ID"] = sessionID - } else { - // Session file doesn't exist - don't pass the ID - // This happens when switching between Docker and standalone modes - } - } - } - return env } @@ -805,19 +788,6 @@ func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInsta zap.String("instance_id", instance.ID), zap.String("session_id", sessionID)) - // Store session ID in instance - m.mu.Lock() - if inst, exists := m.instances[instance.ID]; exists { - if inst.Metadata == nil { - inst.Metadata = make(map[string]interface{}) - } - inst.Metadata["acp_session_id"] = sessionID - } - m.mu.Unlock() - - // Publish session_info event so the orchestrator can store it in task metadata - m.publishSessionInfo(instance, sessionID) - // 3. Set up updates stream to receive session notifications // Use a ready channel to signal when the stream is connected updatesReady := make(chan struct{}) @@ -1207,41 +1177,6 @@ func (m *Manager) publishSessionUpdate(instance *AgentInstance, update agentctl. } } -// publishSessionInfo publishes a session_info event with the session ID -// This allows the orchestrator to store the session ID in task metadata for resumption -func (m *Manager) publishSessionInfo(instance *AgentInstance, sessionID string) { - if m.eventBus == nil { - return - } - - // Structure must match protocol.Message so it can be parsed correctly - // The session_id goes in the "data" field, not at the top level - data := map[string]interface{}{ - "type": "session_info", - "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": instance.ID, - "task_id": instance.TaskID, - "data": map[string]interface{}{ - "session_id": sessionID, - }, - } - - event := bus.NewEvent(events.ACPMessage, "agent-manager", data) - subject := events.BuildACPSubject(instance.TaskID) - - if err := m.eventBus.Publish(context.Background(), subject, event); err != nil { - m.logger.Error("failed to publish session_info event", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID), - zap.String("session_id", sessionID), - zap.Error(err)) - } else { - m.logger.Info("published session_info event", - zap.String("task_id", instance.TaskID), - zap.String("session_id", sessionID)) - } -} - // handleGitStatusUpdate processes git status updates from the workspace tracker func (m *Manager) handleGitStatusUpdate(instance *AgentInstance, update *agentctl.GitStatusUpdate) { // Store git status in instance metadata @@ -1580,13 +1515,6 @@ func (m *Manager) buildContainerConfig(instanceID string, req *LaunchRequest, ag "GIT_CONFIG_VALUE_0=*", // Trust all directories in the container ) - // Extract auggie_session_id from metadata for session resumption - if req.Metadata != nil { - if sessionID, ok := req.Metadata["auggie_session_id"].(string); ok && sessionID != "" { - env = append(env, fmt.Sprintf("AUGGIE_SESSION_ID=%s", sessionID)) - } - } - // Inject required credentials from credentials manager if m.credsMgr != nil && len(agentConfig.RequiredEnv) > 0 { for _, key := range agentConfig.RequiredEnv { @@ -2181,4 +2109,4 @@ func (m *Manager) RespondToPermissionByTaskID(taskID, pendingID, optionID string } return m.RespondToPermission(instance.ID, pendingID, optionID, cancelled) -} \ No newline at end of file +} diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index f50cf86fbd..6249fcf0c2 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -18,19 +18,26 @@ import ( var ( ErrAgentNotFound = errors.New("agent not found") ErrAgentProfileNotFound = errors.New("agent profile not found") + ErrAgentProfileInUse = errors.New("agent profile is used by an active agent session") ) type Controller struct { - repo store.Repository - discovery *discovery.Registry - logger *logger.Logger + repo store.Repository + discovery *discovery.Registry + sessionChecker SessionChecker + logger *logger.Logger } -func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, log *logger.Logger) *Controller { +type SessionChecker interface { + HasActiveAgentSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) +} + +func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, sessionChecker SessionChecker, log *logger.Logger) *Controller { return &Controller{ - repo: repo, - discovery: discoveryRegistry, - logger: log.WithFields(zap.String("component", "agent-settings-controller")), + repo: repo, + discovery: discoveryRegistry, + sessionChecker: sessionChecker, + logger: log.WithFields(zap.String("component", "agent-settings-controller")), } } @@ -266,6 +273,15 @@ func (c *Controller) DeleteProfile(ctx context.Context, id string) (*dto.AgentPr } return nil, err } + if c.sessionChecker != nil { + active, err := c.sessionChecker.HasActiveAgentSessionsByAgentProfile(ctx, id) + if err != nil { + return nil, err + } + if active { + return nil, ErrAgentProfileInUse + } + } if err := c.repo.DeleteAgentProfile(ctx, id); err != nil { if strings.Contains(err.Error(), "agent profile not found") { return nil, ErrAgentProfileNotFound diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 4797561f70..9235131146 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -254,6 +254,10 @@ func (h *Handlers) httpDeleteProfile(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"}) return } + if err == controller.ErrAgentProfileInUse { + c.JSON(http.StatusConflict, gin.H{"error": "agent profile is used by an active agent session"}) + return + } h.logger.Error("failed to delete profile", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete profile"}) return diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index 64308e63b6..9dbdff6c3d 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -22,4 +22,5 @@ type AgentProfile struct { Plan string `json:"plan"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index e2c8b1ff4f..f0e4771509 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -93,6 +93,7 @@ func (r *SQLiteRepository) initSchema() error { plan TEXT DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, + deleted_at DATETIME, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE ); @@ -101,7 +102,13 @@ func (r *SQLiteRepository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_agent_profiles_agent_id ON agent_profiles(agent_id); ` _, err := r.db.Exec(schema) - return err + if err != nil { + return err + } + if err := ensureColumn(r.db, "agent_profiles", "deleted_at", "DATETIME"); err != nil { + return err + } + return nil } func (r *SQLiteRepository) Close() error { @@ -195,10 +202,10 @@ func (r *SQLiteRepository) CreateAgentProfile(ctx context.Context, profile *mode profile.CreatedAt = now profile.UpdatedAt = now _, err := r.db.ExecContext(ctx, ` - INSERT INTO agent_profiles (id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO agent_profiles (id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, profile.ID, profile.AgentID, profile.Name, profile.Model, boolToInt(profile.AutoApprove), - boolToInt(profile.DangerouslySkipPermissions), profile.Plan, profile.CreatedAt, profile.UpdatedAt) + boolToInt(profile.DangerouslySkipPermissions), profile.Plan, profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt) return err } @@ -207,7 +214,7 @@ func (r *SQLiteRepository) UpdateAgentProfile(ctx context.Context, profile *mode result, err := r.db.ExecContext(ctx, ` UPDATE agent_profiles SET name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, plan = ?, updated_at = ? - WHERE id = ? + WHERE id = ? AND deleted_at IS NULL `, profile.Name, profile.Model, boolToInt(profile.AutoApprove), boolToInt(profile.DangerouslySkipPermissions), profile.Plan, profile.UpdatedAt, profile.ID) if err != nil { @@ -221,7 +228,10 @@ func (r *SQLiteRepository) UpdateAgentProfile(ctx context.Context, profile *mode } func (r *SQLiteRepository) DeleteAgentProfile(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM agent_profiles WHERE id = ?`, id) + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, ` + UPDATE agent_profiles SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL + `, now, now, id) if err != nil { return err } @@ -234,16 +244,16 @@ func (r *SQLiteRepository) DeleteAgentProfile(ctx context.Context, id string) er func (r *SQLiteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) { row := r.db.QueryRowContext(ctx, ` - SELECT id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at - FROM agent_profiles WHERE id = ? + SELECT id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at, deleted_at + FROM agent_profiles WHERE id = ? AND deleted_at IS NULL `, id) return scanAgentProfile(row) } func (r *SQLiteRepository) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at - FROM agent_profiles WHERE agent_id = ? ORDER BY created_at DESC + SELECT id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at, deleted_at + FROM agent_profiles WHERE agent_id = ? AND deleted_at IS NULL ORDER BY created_at DESC `, agentID) if err != nil { return nil, err @@ -301,6 +311,7 @@ func scanAgentProfile(scanner interface { &profile.Plan, &profile.CreatedAt, &profile.UpdatedAt, + &profile.DeletedAt, ); err != nil { return nil, err } @@ -309,6 +320,40 @@ func scanAgentProfile(scanner interface { return profile, nil } +func ensureColumn(db *sql.DB, table, column, definition string) error { + exists, err := columnExists(db, table, column) + if err != nil { + return err + } + if exists { + return nil + } + query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition) + _, err = db.Exec(query) + return err +} + +func columnExists(db *sql.DB, table, column string) (bool, error) { + rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + return false, rows.Err() +} + func boolToInt(value bool) int { if value { return 1 diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 5c630d0764..00dc336cbb 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -32,8 +32,13 @@ const ( // Event types for comments const ( - CommentAdded = "comment.added" - CommentUpdated = "comment.updated" + MessageAdded = "message.added" + MessageUpdated = "message.updated" +) + +// Event types for agent sessions +const ( + AgentSessionStateChanged = "agent_session.state_changed" ) // Event types for repositories diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index 85c5696deb..43e5e4414d 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -83,9 +83,11 @@ func NewTestServer(t *testing.T) *TestServer { taskController := taskcontroller.NewTaskController(taskSvc) executorController := taskcontroller.NewExecutorController(taskSvc) environmentController := taskcontroller.NewEnvironmentController(taskSvc) + repositoryController := taskcontroller.NewRepositoryController(taskSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) + taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) @@ -163,6 +165,24 @@ func createWorkspace(t *testing.T, client *WSClient) string { return payload["id"].(string) } +func createRepository(t *testing.T, client *WSClient, workspaceID string) string { + t.Helper() + + resp, err := client.SendRequest("repo-1", ws.ActionRepositoryCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "Test Repo", + "source_type": "local", + "local_path": "/tmp/repo", + }) + require.NoError(t, err) + + var payload map[string]interface{} + err = resp.ParsePayload(&payload) + require.NoError(t, err) + + return payload["id"].(string) +} + // readPump reads messages from the WebSocket connection func (c *WSClient) readPump() { defer close(c.done) @@ -411,6 +431,7 @@ func TestTaskCRUD(t *testing.T) { defer client.Close() workspaceID := createWorkspace(t, client) + repositoryID := createRepository(t, client, workspaceID) // Create board and column first boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ @@ -447,7 +468,8 @@ func TestTaskCRUD(t *testing.T) { "title": "Test Task", "description": "A test task for integration testing", "priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH) - "repository_url": "/tmp/repo", + "repository_id": repositoryID, + "base_branch": "main", }) require.NoError(t, err) @@ -557,6 +579,7 @@ func TestTaskStateTransitions(t *testing.T) { defer client.Close() workspaceID := createWorkspace(t, client) + repositoryID := createRepository(t, client, workspaceID) // Create board, column, and task boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ @@ -582,7 +605,8 @@ func TestTaskStateTransitions(t *testing.T) { "column_id": columnID, "title": "State Test Task", "description": "Test state transitions", - "repository_url": "/tmp/repo", + "repository_id": repositoryID, + "base_branch": "main", }) var taskPayload map[string]interface{} taskResp.ParsePayload(&taskPayload) @@ -633,6 +657,7 @@ func TestTaskMove(t *testing.T) { defer client.Close() workspaceID := createWorkspace(t, client) + repositoryID := createRepository(t, client, workspaceID) // Create board with two columns boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ @@ -667,7 +692,8 @@ func TestTaskMove(t *testing.T) { "board_id": boardID, "column_id": col1ID, "title": "Movable Task", - "repository_url": "/tmp/repo", + "repository_id": repositoryID, + "base_branch": "main", }) var taskPayload map[string]interface{} taskResp.ParsePayload(&taskPayload) @@ -720,6 +746,7 @@ func TestMultipleClients(t *testing.T) { defer client2.Close() workspaceID := createWorkspace(t, client1) + repositoryID := createRepository(t, client1, workspaceID) // Client 1 creates a board boardResp, err := client1.SendRequest("c1-board-1", ws.ActionBoardCreate, map[string]interface{}{ @@ -780,7 +807,8 @@ func TestMultipleClients(t *testing.T) { "board_id": boardID, "column_id": columnID, "title": "Task by Client 1", - "repository_url": "/tmp/repo", + "repository_id": repositoryID, + "base_branch": "main", }) require.NoError(t, err) @@ -877,6 +905,7 @@ func TestTaskSubscription(t *testing.T) { defer client.Close() workspaceID := createWorkspace(t, client) + repositoryID := createRepository(t, client, workspaceID) // Create a task first boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ @@ -901,7 +930,8 @@ func TestTaskSubscription(t *testing.T) { "board_id": boardID, "column_id": columnID, "title": "Subscribable Task", - "repository_url": "/tmp/repo", + "repository_id": repositoryID, + "base_branch": "main", }) var taskPayload map[string]interface{} taskResp.ParsePayload(&taskPayload) @@ -951,6 +981,7 @@ func TestConcurrentRequests(t *testing.T) { defer client.Close() workspaceID := createWorkspace(t, client) + repositoryID := createRepository(t, client, workspaceID) // Create board and column boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ @@ -992,7 +1023,8 @@ func TestConcurrentRequests(t *testing.T) { "board_id": boardID, "column_id": columnID, "title": "Concurrent Task " + string(rune('0'+idx)), - "repository_url": "/tmp/repo", + "repository_id": repositoryID, + "base_branch": "main", }, ) results <- err diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index ca3af69e45..f193bb4522 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -513,19 +513,6 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { gateway.Hub.BroadcastToTask(taskID, notification) }) - // Wire ACP handler to extract session_id from session_info messages and store in task metadata - // This mirrors the handler in main.go - orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) { - if msg.Type == protocol.MessageTypeSessionInfo && msg.Data != nil { - if sessionID, ok := msg.Data["session_id"].(string); ok && sessionID != "" { - metadata := map[string]interface{}{ - "auggie_session_id": sessionID, - } - taskSvc.UpdateTaskMetadata(context.Background(), taskID, metadata) - } - } - }) - // Start orchestrator require.NoError(t, orchestratorSvc.Start(ctx)) @@ -540,9 +527,11 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { taskController := taskcontroller.NewTaskController(taskSvc) executorController := taskcontroller.NewExecutorController(taskSvc) environmentController := taskcontroller.NewEnvironmentController(taskSvc) + repositoryController := taskcontroller.NewRepositoryController(taskSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) + taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) @@ -573,7 +562,7 @@ func (ts *OrchestratorTestServer) Close() { ts.EventBus.Close() } -// CreateTestTask creates a task with agent_profile_id set for testing +// CreateTestTask creates a task for testing. func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID string, priority int) string { t.Helper() @@ -600,6 +589,13 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st require.NoError(t, err) // Create task with agent profile ID + repository, err := ts.TaskSvc.CreateRepository(context.Background(), &taskservice.CreateRepositoryRequest{ + WorkspaceID: workspace.ID, + Name: "Test Repo", + LocalPath: "/tmp/repo", + }) + require.NoError(t, err) + task, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{ WorkspaceID: workspace.ID, BoardID: board.ID, @@ -607,8 +603,8 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st Title: "Test Task", Description: "This is a test task for the orchestrator", Priority: priority, - AgentProfileID: agentProfileID, - RepositoryURL: "/tmp/repo", + RepositoryID: repository.ID, + BaseBranch: "main", }) require.NoError(t, err) @@ -823,7 +819,8 @@ func TestOrchestratorStartTask(t *testing.T) { // Start task execution resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, + "task_id": taskID, + "agent_profile_id": "test-profile-id", }) require.NoError(t, err) @@ -886,7 +883,8 @@ func TestOrchestratorStopTask(t *testing.T) { // Start task startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, + "task_id": taskID, + "agent_profile_id": "augment-agent", }) require.NoError(t, err) @@ -922,7 +920,8 @@ func TestOrchestratorPromptTask(t *testing.T) { // Start task startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, + "task_id": taskID, + "agent_profile_id": "augment-agent", }) require.NoError(t, err) @@ -961,7 +960,8 @@ func TestOrchestratorCompleteTask(t *testing.T) { // Start task startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, + "task_id": taskID, + "agent_profile_id": "augment-agent", }) require.NoError(t, err) @@ -1011,7 +1011,8 @@ func TestOrchestratorConcurrentTasks(t *testing.T) { defer wg.Done() resp, err := client.SendRequest(fmt.Sprintf("start-%d", idx), ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": tid, + "task_id": tid, + "agent_profile_id": "augment-agent", }) if err != nil { t.Logf("Error starting task %d: %v", idx, err) @@ -1106,7 +1107,8 @@ func TestOrchestratorACPStreaming(t *testing.T) { // Start task _, err = client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, + "task_id": taskID, + "agent_profile_id": "augment-agent", }) require.NoError(t, err) @@ -1147,7 +1149,8 @@ func TestOrchestratorMultipleClients(t *testing.T) { // Client 1 starts the task _, err = client1.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, + "task_id": taskID, + "agent_profile_id": "augment-agent", }) require.NoError(t, err) @@ -1189,7 +1192,8 @@ func TestOrchestratorErrorHandling(t *testing.T) { defer client.Close() resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": "non-existent-task", + "task_id": "non-existent-task", + "agent_profile_id": "augment-agent", }) require.NoError(t, err) @@ -1250,7 +1254,8 @@ func TestOrchestratorAgentFailure(t *testing.T) { defer client.Close() resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, + "task_id": taskID, + "agent_profile_id": "augment-agent", }) require.NoError(t, err) @@ -1276,19 +1281,22 @@ func TestOrchestratorTaskPriority(t *testing.T) { // Start low priority first _, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": lowPriorityTask, + "task_id": lowPriorityTask, + "agent_profile_id": "augment-agent", }) require.NoError(t, err) // Then high priority _, err = client.SendRequest("start-2", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": highPriorityTask, + "task_id": highPriorityTask, + "agent_profile_id": "augment-agent", }) require.NoError(t, err) // Then medium priority _, err = client.SendRequest("start-3", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": medPriorityTask, + "task_id": medPriorityTask, + "agent_profile_id": "augment-agent", }) require.NoError(t, err) @@ -1353,6 +1361,18 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) { colID := colPayload["id"].(string) // 3. Create task with agent_profile_id + repoResp, err := client.SendRequest("repo-1", ws.ActionRepositoryCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "Test Repo", + "source_type": "local", + "local_path": "/tmp/repo", + }) + require.NoError(t, err) + + var repoPayload map[string]interface{} + require.NoError(t, repoResp.ParsePayload(&repoPayload)) + repositoryID := repoPayload["id"].(string) + taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, "board_id": boardID, @@ -1360,8 +1380,8 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) { "title": "Implement feature X", "description": "Create a new feature with tests", "priority": 2, - "agent_profile_id": "test-profile-id", - "repository_url": "/tmp/repo", + "repository_id": repositoryID, + "base_branch": "main", }) require.NoError(t, err) @@ -1375,7 +1395,8 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) { // 5. Start orchestrator startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, + "task_id": taskID, + "agent_profile_id": "test-profile-id", }) require.NoError(t, err) @@ -1408,58 +1429,3 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) { require.NoError(t, getResp.ParsePayload(&getPayload)) assert.Equal(t, "COMPLETED", getPayload["state"].(string)) } - -func TestOrchestratorSessionInfo(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - // Use a known session ID for verification - expectedSessionID := "test-session-abc12345" - - // Configure agent to send session_info message - ts.AgentManager.SetACPMessageFn(func(taskID, instanceID string) []protocol.Message { - return []protocol.Message{ - { - Type: protocol.MessageTypeSessionInfo, - TaskID: taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "session_id": expectedSessionID, - }, - }, - { - Type: protocol.MessageTypeProgress, - TaskID: taskID, - Timestamp: time.Now(), - Data: map[string]interface{}{ - "progress": 100, - "message": "Done", - }, - }, - } - }) - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Start task - _, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - // Wait for execution and event processing - time.Sleep(500 * time.Millisecond) - - // Verify the session_id was stored in task metadata - task, err := ts.TaskRepo.GetTask(context.Background(), taskID) - require.NoError(t, err) - require.NotNil(t, task) - require.NotNil(t, task.Metadata, "task metadata should not be nil") - - storedSessionID, ok := task.Metadata["auggie_session_id"] - require.True(t, ok, "auggie_session_id should be present in task metadata") - assert.Equal(t, expectedSessionID, storedSessionID, "session ID should match what the agent sent") -} diff --git a/apps/backend/internal/orchestrator/controller/controller.go b/apps/backend/internal/orchestrator/controller/controller.go index 246a1f0afc..ecd1a3dc3e 100644 --- a/apps/backend/internal/orchestrator/controller/controller.go +++ b/apps/backend/internal/orchestrator/controller/controller.go @@ -71,7 +71,8 @@ func (c *Controller) StartTask(ctx context.Context, req dto.StartTaskRequest) (d Success: true, TaskID: execution.TaskID, AgentInstanceID: execution.AgentInstanceID, - Status: string(execution.Status), + AgentSessionID: execution.SessionID, + State: string(execution.SessionState), } // Include worktree info if available @@ -152,9 +153,9 @@ func (c *Controller) GetTaskExecution(ctx context.Context, req dto.GetTaskExecut TaskID: execution.TaskID, AgentInstanceID: execution.AgentInstanceID, AgentProfileID: execution.AgentProfileID, - Status: string(execution.Status), + AgentSessionID: execution.SessionID, + State: string(execution.SessionState), Progress: execution.Progress, StartedAt: execution.StartedAt.Format("2006-01-02T15:04:05Z"), }, nil } - diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index c6dfdcdd03..5fc089e0d7 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -52,7 +52,8 @@ type StartTaskResponse struct { Success bool `json:"success"` TaskID string `json:"task_id"` AgentInstanceID string `json:"agent_instance_id"` - Status string `json:"status"` + AgentSessionID string `json:"agent_session_id,omitempty"` + State string `json:"state"` WorktreePath *string `json:"worktree_path,omitempty"` WorktreeBranch *string `json:"worktree_branch,omitempty"` } @@ -107,8 +108,6 @@ type PermissionRespondResponse struct { PendingID string `json:"pending_id"` } - - // GetTaskExecutionRequest is the payload for task.execution type GetTaskExecutionRequest struct { TaskID string `json:"task_id"` @@ -120,8 +119,8 @@ type TaskExecutionResponse struct { TaskID string `json:"task_id"` AgentInstanceID string `json:"agent_instance_id,omitempty"` AgentProfileID string `json:"agent_profile_id,omitempty"` - Status string `json:"status,omitempty"` + AgentSessionID string `json:"agent_session_id,omitempty"` + State string `json:"state,omitempty"` Progress int `json:"progress,omitempty"` StartedAt string `json:"started_at,omitempty"` } - diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index fd12342ddc..586676164c 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -18,9 +18,9 @@ import ( // Common errors var ( - ErrMaxConcurrentReached = errors.New("maximum concurrent executions reached") - ErrNoAgentProfileID = errors.New("task has no agent_profile_id configured") - ErrExecutionNotFound = errors.New("execution not found") + ErrMaxConcurrentReached = errors.New("maximum concurrent executions reached") + ErrNoAgentProfileID = errors.New("task has no agent_profile_id configured") + ErrExecutionNotFound = errors.New("execution not found") ) // PromptResult contains the result of a prompt operation @@ -92,7 +92,7 @@ type TaskExecution struct { AgentInstanceID string AgentProfileID string StartedAt time.Time - Status v1.AgentStatus + SessionState v1.AgentSessionState Progress int LastUpdate time.Time // SessionID is the database ID of the agent session @@ -109,49 +109,16 @@ func FromAgentSession(s *models.AgentSession) *TaskExecution { AgentInstanceID: s.AgentInstanceID, AgentProfileID: s.AgentProfileID, StartedAt: s.StartedAt, - Status: agentSessionStatusToV1(s.Status), + SessionState: agentSessionStateToV1(s.State), 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 - } +// agentSessionStateToV1 converts models.AgentSessionState to v1.AgentSessionState +func agentSessionStateToV1(state models.AgentSessionState) v1.AgentSessionState { + return v1.AgentSessionState(state) } // Executor manages agent execution for tasks @@ -216,7 +183,7 @@ func (e *Executor) LoadActiveSessionsFromDB(ctx context.Context) error { 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))) + zap.String("state", string(session.State))) } e.logger.Info("loaded active agent sessions from database", @@ -251,7 +218,7 @@ func (e *Executor) SyncWithRecoveredInstances(ctx context.Context, instances []R AgentInstanceID: inst.InstanceID, AgentProfileID: inst.AgentProfileID, StartedAt: time.Now(), // We don't know exact start time - Status: v1.AgentStatusRunning, + SessionState: v1.AgentSessionStateRunning, Progress: 0, LastUpdate: time.Now(), } @@ -264,6 +231,11 @@ func (e *Executor) SyncWithRecoveredInstances(ctx context.Context, instances []R // Execute starts agent execution for a task func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, error) { + return e.ExecuteWithProfile(ctx, task, "") +} + +// ExecuteWithProfile starts agent execution for a task using an explicit agent profile. +func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentProfileID string) (*TaskExecution, error) { // Check if max concurrent limit is reached if !e.CanExecute() { e.logger.Warn("max concurrent executions reached", @@ -272,8 +244,7 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, return nil, ErrMaxConcurrentReached } - // Verify task has an agent_profile_id configured - if task.AgentProfileID == nil || *task.AgentProfileID == "" { + if agentProfileID == "" { e.logger.Error("task has no agent_profile_id configured", zap.String("task_id", task.ID)) return nil, ErrNoAgentProfileID } @@ -282,33 +253,53 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, req := &LaunchAgentRequest{ TaskID: task.ID, TaskTitle: task.Title, - AgentProfileID: *task.AgentProfileID, + AgentProfileID: agentProfileID, TaskDescription: task.Description, Priority: task.Priority, Metadata: task.Metadata, } - if task.RepositoryURL != nil { - req.RepositoryURL = *task.RepositoryURL - } - if task.Branch != nil { - req.Branch = *task.Branch + var repositoryPath string + var repositoryID string + var baseBranch string + if task.RepositoryID != nil && *task.RepositoryID != "" { + repositoryID = *task.RepositoryID + repository, err := e.repo.GetRepository(ctx, repositoryID) + if err != nil { + e.logger.Error("failed to load repository for task", + zap.String("task_id", task.ID), + zap.String("repository_id", repositoryID), + zap.Error(err)) + return nil, err + } + repositoryPath = repository.LocalPath + if repositoryPath != "" { + req.RepositoryURL = repositoryPath + } + if task.BaseBranch != nil && *task.BaseBranch != "" { + baseBranch = *task.BaseBranch + } else if repository.DefaultBranch != "" { + baseBranch = repository.DefaultBranch + } + if baseBranch != "" { + req.Branch = baseBranch + } } // Configure worktree if enabled and repository path is available - if e.worktreeEnabled && req.RepositoryURL != "" { + if e.worktreeEnabled && repositoryPath != "" { req.UseWorktree = true - req.RepositoryPath = req.RepositoryURL // RepositoryURL is actually a local path - req.RepositoryID = task.ID // Use task ID as repository identifier for worktree tracking - if req.Branch != "" { - req.BaseBranch = req.Branch + req.RepositoryPath = repositoryPath + req.RepositoryID = repositoryID + if baseBranch != "" { + req.BaseBranch = baseBranch } else { - req.BaseBranch = "main" // Default base branch + req.BaseBranch = "main" } } e.logger.Info("launching agent for task", zap.String("task_id", task.ID), - zap.String("agent_profile_id", *task.AgentProfileID), + zap.String("agent_profile_id", agentProfileID), zap.Bool("use_worktree", req.UseWorktree)) // Call the AgentManager to launch the container @@ -328,8 +319,12 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, TaskID: task.ID, AgentInstanceID: resp.AgentInstanceID, ContainerID: resp.ContainerID, - AgentProfileID: *task.AgentProfileID, - Status: v1StatusToAgentSessionStatus(resp.Status), + AgentProfileID: agentProfileID, + RepositoryID: repositoryID, + BaseBranch: baseBranch, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + State: models.AgentSessionStateStarting, Progress: 0, StartedAt: now, UpdatedAt: now, @@ -346,9 +341,9 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, execution := &TaskExecution{ TaskID: task.ID, AgentInstanceID: resp.AgentInstanceID, - AgentProfileID: *task.AgentProfileID, + AgentProfileID: agentProfileID, StartedAt: now, - Status: resp.Status, + SessionState: v1.AgentSessionStateStarting, Progress: 0, LastUpdate: now, SessionID: sessionID, @@ -396,7 +391,7 @@ func (e *Executor) Stop(ctx context.Context, taskID string, reason string, force // Update execution status in memory and database regardless of agent stop result e.mu.Lock() if exec, ok := e.executions[taskID]; ok { - exec.Status = v1.AgentStatusStopped + exec.SessionState = v1.AgentSessionStateCancelled exec.LastUpdate = time.Now() } delete(e.executions, taskID) // Remove from cache so GetExecution returns false @@ -404,7 +399,7 @@ func (e *Executor) Stop(ctx context.Context, taskID string, reason string, force // Update database if execution.SessionID != "" { - if dbErr := e.repo.UpdateAgentSessionStatus(ctx, execution.SessionID, models.AgentSessionStatusStopped, reason); dbErr != nil { + if dbErr := e.repo.UpdateAgentSessionState(ctx, execution.SessionID, models.AgentSessionStateCancelled, reason); dbErr != nil { e.logger.Error("failed to update agent session status in database", zap.String("task_id", taskID), zap.String("session_id", execution.SessionID), @@ -494,7 +489,7 @@ func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) { // Also update DB if we have a session if execution.SessionID != "" { - _ = e.repo.UpdateAgentSessionStatus(ctx, execution.SessionID, models.AgentSessionStatusStopped, "agent process stopped") + _ = e.repo.UpdateAgentSessionState(ctx, execution.SessionID, models.AgentSessionStateCancelled, "agent process stopped") } return nil, false } @@ -517,7 +512,7 @@ func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) { e.logger.Info("stale agent session detected - agent not running", zap.String("task_id", taskID), zap.String("session_id", session.ID)) - _ = e.repo.UpdateAgentSessionStatus(ctx, session.ID, models.AgentSessionStatusStopped, "agent not running after backend restart") + _ = e.repo.UpdateAgentSessionState(ctx, session.ID, models.AgentSessionStateCancelled, "agent not running after backend restart") return nil, false } @@ -547,7 +542,7 @@ func (e *Executor) GetExecutionWithContext(ctx context.Context, taskID string) ( // Also update DB if we have a session if execution.SessionID != "" { - _ = e.repo.UpdateAgentSessionStatus(ctx, execution.SessionID, models.AgentSessionStatusStopped, "agent process stopped") + _ = e.repo.UpdateAgentSessionState(ctx, execution.SessionID, models.AgentSessionStateCancelled, "agent process stopped") } return nil, false } @@ -567,7 +562,7 @@ func (e *Executor) GetExecutionWithContext(ctx context.Context, taskID string) ( e.logger.Info("stale agent session detected - agent not running", zap.String("task_id", taskID), zap.String("session_id", session.ID)) - _ = e.repo.UpdateAgentSessionStatus(ctx, session.ID, models.AgentSessionStatusStopped, "agent not running after backend restart") + _ = e.repo.UpdateAgentSessionState(ctx, session.ID, models.AgentSessionStateCancelled, "agent not running after backend restart") return nil, false } @@ -607,19 +602,19 @@ func (e *Executor) CanExecute() bool { } // UpdateProgress updates the progress of an execution (called when ACP progress messages are received) -func (e *Executor) UpdateProgress(ctx context.Context, taskID string, progress int, status v1.AgentStatus) { +func (e *Executor) UpdateProgress(ctx context.Context, taskID string, progress int, state v1.AgentSessionState) { e.mu.Lock() var sessionID string if exec, ok := e.executions[taskID]; ok { exec.Progress = progress - exec.Status = status + exec.SessionState = state 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))) + zap.String("state", string(state))) } e.mu.Unlock() @@ -631,7 +626,7 @@ func (e *Executor) UpdateProgress(ctx context.Context, taskID string, progress i return } session.Progress = progress - session.Status = v1StatusToAgentSessionStatus(status) + session.State = models.AgentSessionState(state) 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), @@ -642,25 +637,25 @@ func (e *Executor) UpdateProgress(ctx context.Context, taskID string, progress i } // MarkCompleted marks an execution as completed -func (e *Executor) MarkCompleted(ctx context.Context, taskID string, status v1.AgentStatus) { +func (e *Executor) MarkCompleted(ctx context.Context, taskID string, state v1.AgentSessionState) { e.mu.Lock() var sessionID string if exec, ok := e.executions[taskID]; ok { - exec.Status = status + exec.SessionState = state 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))) + zap.String("state", string(state))) } e.mu.Unlock() // Update database if sessionID != "" { - dbStatus := v1StatusToAgentSessionStatus(status) - if err := e.repo.UpdateAgentSessionStatus(ctx, sessionID, dbStatus, ""); err != nil { + dbState := models.AgentSessionState(state) + if err := e.repo.UpdateAgentSessionState(ctx, sessionID, dbState, ""); err != nil { e.logger.Error("failed to update agent session status in database", zap.String("session_id", sessionID), zap.Error(err)) @@ -668,6 +663,17 @@ func (e *Executor) MarkCompleted(ctx context.Context, taskID string, status v1.A } } +// UpdateExecutionState updates the in-memory execution state for a task. +func (e *Executor) UpdateExecutionState(taskID string, state v1.AgentSessionState) { + e.mu.Lock() + defer e.mu.Unlock() + + if exec, ok := e.executions[taskID]; ok { + exec.SessionState = state + exec.LastUpdate = time.Now() + } +} + // RemoveExecution removes a completed/failed execution from in-memory tracking // Note: The database record is preserved for history func (e *Executor) RemoveExecution(taskID string) { @@ -700,17 +706,6 @@ func (e *Executor) GetActiveSessionID(ctx context.Context, taskID string) (strin return session.ID, nil } -// UpdateSessionACPSessionID updates the ACP session ID for an agent session -func (e *Executor) UpdateSessionACPSessionID(ctx context.Context, taskID, acpSessionID string) error { - session, err := e.repo.GetActiveAgentSessionByTaskID(ctx, taskID) - if err != nil { - return err - } - - session.ACPSessionID = acpSessionID - return e.repo.UpdateAgentSession(ctx, session) -} - // MockAgentManagerClient is a placeholder implementation type MockAgentManagerClient struct { logger *logger.Logger diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index b555095cb4..4425b7805b 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -93,6 +93,9 @@ func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Messag if req.TaskID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) } + if req.AgentProfileID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required", nil) + } resp, err := h.controller.StartTask(ctx, dto.StartTaskRequest{ TaskID: req.TaskID, diff --git a/apps/backend/internal/orchestrator/queue/queue.go b/apps/backend/internal/orchestrator/queue/queue.go index 81a2909775..2b446e30f1 100644 --- a/apps/backend/internal/orchestrator/queue/queue.go +++ b/apps/backend/internal/orchestrator/queue/queue.go @@ -20,7 +20,6 @@ var ( type QueuedTask struct { TaskID string Priority int // Higher priority = processed first - AgentProfileID string QueuedAt time.Time Task *v1.Task // Full task data index int // Index in the heap (used by container/heap) @@ -95,15 +94,9 @@ func (q *TaskQueue) Enqueue(task *v1.Task) error { return ErrQueueFull } - agentProfileID := "" - if task.AgentProfileID != nil { - agentProfileID = *task.AgentProfileID - } - qt := &QueuedTask{ TaskID: task.ID, Priority: task.Priority, - AgentProfileID: agentProfileID, QueuedAt: time.Now(), Task: task, } @@ -213,4 +206,3 @@ func (q *TaskQueue) Clear() { q.taskMap = make(map[string]*QueuedTask) heap.Init(&q.heap) } - diff --git a/apps/backend/internal/orchestrator/queue/queue_test.go b/apps/backend/internal/orchestrator/queue/queue_test.go index d18d6bc315..4da52eca35 100644 --- a/apps/backend/internal/orchestrator/queue/queue_test.go +++ b/apps/backend/internal/orchestrator/queue/queue_test.go @@ -8,16 +8,15 @@ import ( ) // createTestTask creates a task for testing with the given parameters -func createTestTask(id string, priority int, agentProfileID string) *v1.Task { +func createTestTask(id string, priority int) *v1.Task { return &v1.Task{ - ID: id, - BoardID: "test-board", - Title: "Test Task " + id, - Priority: priority, - AgentProfileID: &agentProfileID, - State: v1.TaskStateTODO, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: id, + BoardID: "test-board", + Title: "Test Task " + id, + Priority: priority, + State: v1.TaskStateTODO, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } } @@ -36,7 +35,7 @@ func TestNewTaskQueue(t *testing.T) { func TestEnqueue(t *testing.T) { q := NewTaskQueue(10) - task := createTestTask("task-1", 5, "test-agent") + task := createTestTask("task-1", 5) err := q.Enqueue(task) if err != nil { @@ -50,7 +49,7 @@ func TestEnqueue(t *testing.T) { func TestEnqueueDuplicate(t *testing.T) { q := NewTaskQueue(10) - task := createTestTask("task-1", 5, "test-agent") + task := createTestTask("task-1", 5) _ = q.Enqueue(task) err := q.Enqueue(task) @@ -62,9 +61,9 @@ func TestEnqueueDuplicate(t *testing.T) { func TestEnqueueQueueFull(t *testing.T) { q := NewTaskQueue(2) - _ = q.Enqueue(createTestTask("task-1", 5, "test-agent")) - _ = q.Enqueue(createTestTask("task-2", 5, "test-agent")) - err := q.Enqueue(createTestTask("task-3", 5, "test-agent")) + _ = q.Enqueue(createTestTask("task-1", 5)) + _ = q.Enqueue(createTestTask("task-2", 5)) + err := q.Enqueue(createTestTask("task-3", 5)) if err != ErrQueueFull { t.Errorf("expected ErrQueueFull, got %v", err) @@ -73,7 +72,7 @@ func TestEnqueueQueueFull(t *testing.T) { func TestDequeue(t *testing.T) { q := NewTaskQueue(10) - task := createTestTask("task-1", 5, "test-agent") + task := createTestTask("task-1", 5) _ = q.Enqueue(task) dequeued := q.Dequeue() @@ -101,9 +100,9 @@ func TestPriorityOrdering(t *testing.T) { q := NewTaskQueue(10) // Enqueue tasks with different priorities - _ = q.Enqueue(createTestTask("low", 1, "test-agent")) - _ = q.Enqueue(createTestTask("high", 10, "test-agent")) - _ = q.Enqueue(createTestTask("medium", 5, "test-agent")) + _ = q.Enqueue(createTestTask("low", 1)) + _ = q.Enqueue(createTestTask("high", 10)) + _ = q.Enqueue(createTestTask("medium", 5)) // Dequeue should return highest priority first first := q.Dequeue() @@ -131,7 +130,7 @@ func TestPeek(t *testing.T) { t.Errorf("expected nil from Peek on empty queue") } - _ = q.Enqueue(createTestTask("task-1", 5, "test-agent")) + _ = q.Enqueue(createTestTask("task-1", 5)) peeked = q.Peek() if peeked == nil { @@ -149,8 +148,8 @@ func TestPeek(t *testing.T) { func TestRemove(t *testing.T) { q := NewTaskQueue(10) - _ = q.Enqueue(createTestTask("task-1", 5, "test-agent")) - _ = q.Enqueue(createTestTask("task-2", 3, "test-agent")) + _ = q.Enqueue(createTestTask("task-1", 5)) + _ = q.Enqueue(createTestTask("task-2", 3)) removed := q.Remove("task-1") if !removed { @@ -175,8 +174,8 @@ func TestRemoveNonExistent(t *testing.T) { func TestUpdatePriority(t *testing.T) { q := NewTaskQueue(10) - _ = q.Enqueue(createTestTask("task-1", 1, "test-agent")) - _ = q.Enqueue(createTestTask("task-2", 10, "test-agent")) + _ = q.Enqueue(createTestTask("task-1", 1)) + _ = q.Enqueue(createTestTask("task-2", 10)) // task-2 should be first initially peeked := q.Peek() @@ -208,7 +207,7 @@ func TestUpdatePriorityNonExistent(t *testing.T) { func TestContains(t *testing.T) { q := NewTaskQueue(10) - _ = q.Enqueue(createTestTask("task-1", 5, "test-agent")) + _ = q.Enqueue(createTestTask("task-1", 5)) if !q.Contains("task-1") { t.Error("Contains should return true for existing task") @@ -225,12 +224,12 @@ func TestIsFull(t *testing.T) { t.Error("empty queue should not be full") } - _ = q.Enqueue(createTestTask("task-1", 5, "test-agent")) + _ = q.Enqueue(createTestTask("task-1", 5)) if q.IsFull() { t.Error("queue with 1 item (capacity 2) should not be full") } - _ = q.Enqueue(createTestTask("task-2", 5, "test-agent")) + _ = q.Enqueue(createTestTask("task-2", 5)) if !q.IsFull() { t.Error("queue at capacity should be full") } @@ -239,9 +238,9 @@ func TestIsFull(t *testing.T) { func TestList(t *testing.T) { q := NewTaskQueue(10) - _ = q.Enqueue(createTestTask("task-1", 5, "test-agent")) - _ = q.Enqueue(createTestTask("task-2", 3, "test-agent")) - _ = q.Enqueue(createTestTask("task-3", 7, "test-agent")) + _ = q.Enqueue(createTestTask("task-1", 5)) + _ = q.Enqueue(createTestTask("task-2", 3)) + _ = q.Enqueue(createTestTask("task-3", 7)) list := q.List() if len(list) != 3 { @@ -252,8 +251,8 @@ func TestList(t *testing.T) { func TestClear(t *testing.T) { q := NewTaskQueue(10) - _ = q.Enqueue(createTestTask("task-1", 5, "test-agent")) - _ = q.Enqueue(createTestTask("task-2", 3, "test-agent")) + _ = q.Enqueue(createTestTask("task-1", 5)) + _ = q.Enqueue(createTestTask("task-2", 3)) q.Clear() if q.Len() != 0 { @@ -269,7 +268,7 @@ func TestUnlimitedQueue(t *testing.T) { q := NewTaskQueue(0) for i := 0; i < 100; i++ { - err := q.Enqueue(createTestTask(string(rune('a'+i)), 5, "test-agent")) + err := q.Enqueue(createTestTask(string(rune('a'+i)), 5)) if err != nil { t.Fatalf("Enqueue failed on unlimited queue: %v", err) } @@ -284,11 +283,11 @@ func TestFIFOWithSamePriority(t *testing.T) { q := NewTaskQueue(10) // All tasks have same priority - should be FIFO - _ = q.Enqueue(createTestTask("first", 5, "test-agent")) + _ = q.Enqueue(createTestTask("first", 5)) time.Sleep(1 * time.Millisecond) // Ensure different timestamps - _ = q.Enqueue(createTestTask("second", 5, "test-agent")) + _ = q.Enqueue(createTestTask("second", 5)) time.Sleep(1 * time.Millisecond) - _ = q.Enqueue(createTestTask("third", 5, "test-agent")) + _ = q.Enqueue(createTestTask("third", 5)) first := q.Dequeue() if first.TaskID != "first" { @@ -300,4 +299,3 @@ func TestFIFOWithSamePriority(t *testing.T) { t.Errorf("expected 'second' with FIFO ordering, got %s", second.TaskID) } } - diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index abc6eb00f9..b8ade5b7cc 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -135,16 +135,14 @@ func createTestExecutor(t *testing.T, agentMgr *mockAgentManager, log *logger.Lo } func createTestTask(id string, priority int) *v1.Task { - agentProfileID := "test-agent" return &v1.Task{ - ID: id, - BoardID: "test-board", - Title: "Test Task " + id, - Priority: priority, - AgentProfileID: &agentProfileID, - State: v1.TaskStateTODO, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: id, + BoardID: "test-board", + Title: "Test Task " + id, + Priority: priority, + State: v1.TaskStateTODO, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } } @@ -495,4 +493,3 @@ func TestRetryTaskNotFound(t *testing.T) { t.Error("retry should fail for non-existent task") } } - diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 661952bc84..3bc9f02d06 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -11,14 +11,16 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/executor" "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/models" "github.com/kandev/kandev/internal/task/repository" - v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/pkg/acp/protocol" + v1 "github.com/kandev/kandev/pkg/api/v1" ) // Common errors @@ -46,11 +48,13 @@ func DefaultServiceConfig() ServiceConfig { // InputRequestHandler is called when an agent requests user input type InputRequestHandler func(ctx context.Context, taskID, agentID, message string) error -// CommentCreator is an interface for creating comments on tasks -type CommentCreator interface { - CreateAgentComment(ctx context.Context, taskID, content, agentSessionID string) error - CreateToolCallComment(ctx context.Context, taskID, toolCallID, title, status, agentSessionID string, args map[string]interface{}) error - UpdateToolCallComment(ctx context.Context, taskID, toolCallID, status, result string) error +// MessageCreator is an interface for creating messages on tasks +type MessageCreator interface { + CreateAgentMessage(ctx context.Context, taskID, content, agentSessionID string) error + CreateUserMessage(ctx context.Context, taskID, content, agentSessionID string) error + CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID string, args map[string]interface{}) error + UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID string) error + CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType string, metadata map[string]interface{}, requestsInput bool) error } // Service is the main orchestrator service @@ -75,13 +79,21 @@ type Service struct { // Input request handler (for agent-user conversation) inputRequestHandler InputRequestHandler - // Comment creator for saving agent responses - commentCreator CommentCreator + // Message creator for saving agent responses + messageCreator MessageCreator // Service state mu sync.RWMutex running bool startedAt time.Time + + readyMu sync.Mutex + readyStates map[string]*readyState +} + +type readyState struct { + readySeen bool + promptCompleteSeen bool } // Status contains orchestrator status information @@ -131,6 +143,7 @@ func NewService( executor: exec, scheduler: sched, acpHandlers: make([]func(taskID string, msg *protocol.Message), 0), + readyStates: make(map[string]*readyState), } // Create the watcher with event handlers that wire everything together @@ -150,9 +163,9 @@ func NewService( return s } -// SetCommentCreator sets the comment creator for saving agent responses -func (s *Service) SetCommentCreator(cc CommentCreator) { - s.commentCreator = cc +// SetMessageCreator sets the message creator for saving agent responses +func (s *Service) SetMessageCreator(mc MessageCreator) { + s.messageCreator = mc } // Start starts all orchestrator components @@ -282,6 +295,12 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s zap.String("agent_profile_id", agentProfileID), zap.Int("priority", priority)) + if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil { + s.logger.Warn("failed to update task state to SCHEDULING", + zap.String("task_id", taskID), + zap.Error(err)) + } + // Fetch the task from the repository to get complete task info task, err := s.scheduler.GetTask(ctx, taskID) if err != nil { @@ -291,15 +310,34 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s return nil, err } - // Override agent_profile_id and priority if provided in the request - if agentProfileID != "" { - task.AgentProfileID = &agentProfileID - } + // Override priority if provided in the request if priority > 0 { task.Priority = priority } - return s.executor.Execute(ctx, task) + execution, err := s.executor.ExecuteWithProfile(ctx, task, agentProfileID) + if err != nil { + return nil, err + } + + if execution.SessionID != "" { + s.updateAgentSessionState(ctx, taskID, execution.SessionID, models.AgentSessionStateRunning, "", true) + if s.messageCreator != nil && task.Description != "" { + if err := s.messageCreator.CreateUserMessage(ctx, taskID, task.Description, execution.SessionID); err != nil { + s.logger.Error("failed to create initial user message", + zap.String("task_id", taskID), + zap.Error(err)) + } + } + } + + if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil { + s.logger.Warn("failed to update task state to IN_PROGRESS", + zap.String("task_id", taskID), + zap.Error(err)) + } + + return execution, nil } // StopTask stops agent execution for a task @@ -322,6 +360,9 @@ func (s *Service) PromptTask(ctx context.Context, taskID string, prompt string) s.logger.Info("sending prompt to task agent", zap.String("task_id", taskID), zap.Int("prompt_length", len(prompt))) + if sessionID, err := s.executor.GetActiveSessionID(ctx, taskID); err == nil && sessionID != "" { + s.updateAgentSessionState(ctx, taskID, sessionID, models.AgentSessionStateRunning, "", true) + } result, err := s.executor.Prompt(ctx, taskID, prompt) if err != nil { return nil, err @@ -423,20 +464,7 @@ func (s *Service) handleTaskStateChanged(ctx context.Context, data watcher.TaskE s.logger.Debug("handling task state changed", zap.String("task_id", data.TaskID)) - // Add task to queue if state is TODO and agent_profile_id is set - if data.NewState != nil && *data.NewState == v1.TaskStateTODO { - if data.Task != nil && data.Task.AgentProfileID != nil && *data.Task.AgentProfileID != "" { - if err := s.scheduler.EnqueueTask(data.Task); err != nil { - s.logger.Error("failed to enqueue task on state change", - zap.String("task_id", data.TaskID), - zap.Error(err)) - } else { - s.logger.Info("task enqueued on state change to TODO", - zap.String("task_id", data.TaskID), - zap.String("agent_profile_id", *data.Task.AgentProfileID)) - } - } - } + // No auto-enqueue on TODO; sessions are started explicitly with agent profile. } // handleAgentReady handles agent ready events (prompt completed, ready for follow-up) @@ -448,33 +476,8 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD zap.String("task_id", data.TaskID), zap.String("agent_instance_id", data.AgentInstanceID)) - // Get current task state to determine if we should transition to REVIEW - task, err := s.taskRepo.GetTask(ctx, data.TaskID) - if err != nil { - s.logger.Error("failed to get task for state check", - zap.String("task_id", data.TaskID), - zap.Error(err)) - return - } - - // Only transition to REVIEW if the task is currently IN_PROGRESS - // This implements the forward movement: IN_PROGRESS → (agent finishes) → REVIEW - // This works for both: - // 1. Initial task execution: Task starts IN_PROGRESS, agent finishes → REVIEW - // 2. Follow-up prompts: User message sets IN_PROGRESS, agent finishes → REVIEW - if task.State == v1.TaskStateInProgress { - if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil { - s.logger.Error("failed to update task state to REVIEW", - zap.String("task_id", data.TaskID), - zap.Error(err)) - } else { - s.logger.Info("task moved to REVIEW state", - zap.String("task_id", data.TaskID)) - } - } else { - s.logger.Debug("task not in IN_PROGRESS state, skipping REVIEW transition", - zap.String("task_id", data.TaskID), - zap.String("current_state", string(task.State))) + if s.markAgentReady(data.TaskID) { + s.finalizeAgentReady(data.TaskID, "") } } @@ -537,10 +540,224 @@ func (s *Service) handleACPMessage(ctx context.Context, taskID string, msg *prot s.handleInputRequired(ctx, taskID, msg) } + // Normalize ACP messages into session messages for persistence. + normalized := s.normalizeACPMessage(msg) + if normalized != nil && s.messageCreator != nil { + sessionID, err := s.executor.GetActiveSessionID(ctx, taskID) + if err == nil && sessionID != "" { + if err := s.messageCreator.CreateSessionMessage( + ctx, + taskID, + normalized.content, + sessionID, + normalized.messageType, + normalized.metadata, + normalized.requestsInput, + ); err != nil { + s.logger.Error("failed to create normalized session message", + zap.String("task_id", taskID), + zap.String("message_type", normalized.messageType), + zap.Error(err)) + } + + if normalized.bumpToRunning { + s.updateAgentSessionState(ctx, taskID, sessionID, models.AgentSessionStateRunning, "", false) + } + } + } + // Broadcast to all registered handlers (WebSocket streaming) s.broadcastACP(taskID, msg) } +type normalizedACPMessage struct { + messageType string + content string + metadata map[string]interface{} + requestsInput bool + bumpToRunning bool +} + +func (s *Service) normalizeACPMessage(msg *protocol.Message) *normalizedACPMessage { + if msg == nil { + return nil + } + + switch msg.Type { + case protocol.MessageTypeProgress: + return &normalizedACPMessage{ + messageType: string(v1.MessageTypeProgress), + content: extractACPContent(msg), + metadata: buildACPMetadata(msg), + bumpToRunning: true, + } + case protocol.MessageTypeStatus: + return &normalizedACPMessage{ + messageType: string(v1.MessageTypeStatus), + content: extractACPContent(msg), + metadata: buildACPMetadata(msg), + bumpToRunning: true, + } + case protocol.MessageTypeLog: + return &normalizedACPMessage{ + messageType: string(v1.MessageTypeStatus), + content: extractACPContent(msg), + metadata: buildACPMetadata(msg), + bumpToRunning: true, + } + case protocol.MessageTypeError: + return &normalizedACPMessage{ + messageType: string(v1.MessageTypeError), + content: extractACPContent(msg), + metadata: buildACPMetadata(msg), + bumpToRunning: true, + } + case protocol.MessageTypeInputRequired: + return nil + default: + return nil + } +} + +func extractACPContent(msg *protocol.Message) string { + if msg == nil || msg.Data == nil { + return string(msg.Type) + } + for _, key := range []string{"message", "text", "content", "detail"} { + if value, ok := msg.Data[key].(string); ok && value != "" { + return value + } + } + return string(msg.Type) +} + +func buildACPMetadata(msg *protocol.Message) map[string]interface{} { + if msg == nil { + return nil + } + metadata := map[string]interface{}{ + "provider": "acp", + "provider_type": string(msg.Type), + "provider_agent": msg.AgentID, + } + if msg.Data != nil && len(msg.Data) > 0 { + metadata["provider_data"] = msg.Data + } + return metadata +} + +func (s *Service) updateAgentSessionState(ctx context.Context, taskID, sessionID string, nextState models.AgentSessionState, errorMessage string, allowWakeFromWaiting bool) { + session, err := s.repo.GetAgentSession(ctx, sessionID) + if err != nil { + return + } + if session.State == models.AgentSessionStateWaitingForInput && nextState == models.AgentSessionStateRunning && !allowWakeFromWaiting { + return + } + oldState := session.State + switch session.State { + case models.AgentSessionStateCompleted, models.AgentSessionStateFailed, models.AgentSessionStateCancelled: + return + } + if session.State == nextState { + return + } + if err := s.repo.UpdateAgentSessionState(ctx, sessionID, nextState, errorMessage); err != nil { + s.logger.Error("failed to update agent session state", + zap.String("session_id", sessionID), + zap.String("state", string(nextState)), + zap.Error(err)) + } + s.logger.Info("agent session state updated", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("old_state", string(oldState)), + zap.String("new_state", string(nextState))) + if s.eventBus != nil { + _ = s.eventBus.Publish(ctx, events.AgentSessionStateChanged, bus.NewEvent(events.AgentSessionStateChanged, "agent-session", map[string]interface{}{ + "task_id": taskID, + "agent_session_id": sessionID, + "old_state": string(oldState), + "new_state": string(nextState), + })) + } + if taskID != "" { + s.executor.UpdateExecutionState(taskID, v1.AgentSessionState(nextState)) + } +} + +func (s *Service) markAgentReady(taskID string) bool { + s.readyMu.Lock() + defer s.readyMu.Unlock() + + state, ok := s.readyStates[taskID] + if !ok { + state = &readyState{} + s.readyStates[taskID] = state + } + + state.readySeen = true + readyToFinalize := state.promptCompleteSeen + if readyToFinalize { + delete(s.readyStates, taskID) + } + + return readyToFinalize +} + +func (s *Service) markPromptComplete(taskID string) bool { + s.readyMu.Lock() + defer s.readyMu.Unlock() + + state, ok := s.readyStates[taskID] + if !ok { + state = &readyState{} + s.readyStates[taskID] = state + } + + state.promptCompleteSeen = true + readyToFinalize := state.readySeen + if readyToFinalize { + delete(s.readyStates, taskID) + } + + return readyToFinalize +} + +func (s *Service) finalizeAgentReady(taskID, sessionID string) { + ctx := context.Background() + if sessionID == "" { + sessionID, _ = s.executor.GetActiveSessionID(ctx, taskID) + } + + if sessionID != "" { + s.updateAgentSessionState(ctx, taskID, sessionID, models.AgentSessionStateWaitingForInput, "", false) + } + + task, err := s.taskRepo.GetTask(ctx, taskID) + if err != nil { + s.logger.Error("failed to get task for state check", + zap.String("task_id", taskID), + zap.Error(err)) + return + } + + if task.State == v1.TaskStateInProgress { + if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview); err != nil { + s.logger.Error("failed to update task state to REVIEW", + zap.String("task_id", taskID), + zap.Error(err)) + } else { + s.logger.Info("task moved to REVIEW state", + zap.String("task_id", taskID)) + } + } else { + s.logger.Debug("task not in IN_PROGRESS state, skipping REVIEW transition", + zap.String("task_id", taskID), + zap.String("current_state", string(task.State))) + } +} + // handleInputRequired handles agent input request messages func (s *Service) handleInputRequired(ctx context.Context, taskID string, msg *protocol.Message) { s.logger.Info("agent requesting user input", @@ -562,6 +779,11 @@ func (s *Service) handleInputRequired(ctx context.Context, taskID string, msg *p zap.Error(err)) } + // Update session state to WAITING_FOR_INPUT + if sessionID, err := s.executor.GetActiveSessionID(ctx, taskID); err == nil && sessionID != "" { + s.updateAgentSessionState(ctx, taskID, sessionID, models.AgentSessionStateWaitingForInput, "", false) + } + // Call the input request handler if set if s.inputRequestHandler != nil { if err := s.inputRequestHandler(ctx, taskID, msg.AgentID, message); err != nil { @@ -620,7 +842,7 @@ func (s *Service) handleGitStatusUpdated(ctx context.Context, data watcher.GitSt }() } -// handlePromptComplete handles prompt complete events and saves agent response as comment +// handlePromptComplete handles prompt complete events and saves agent response as message // Note: This is called for EVERY agent message (including intermediate ones before tool calls), // so we should NOT transition task state here. State transitions happen in the comment handler // after the synchronous PromptTask call returns. @@ -629,57 +851,70 @@ func (s *Service) handlePromptComplete(ctx context.Context, data watcher.PromptC zap.String("task_id", data.TaskID), zap.Int("message_length", len(data.AgentMessage))) - // Save agent response as a comment if we have a comment creator - if s.commentCreator != nil && data.AgentMessage != "" { + // Save agent response as a message if we have a message creator + if s.messageCreator != nil && data.AgentMessage != "" { // 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", + if err := s.messageCreator.CreateAgentMessage(ctx, data.TaskID, data.AgentMessage, sessionID); err != nil { + s.logger.Error("failed to create agent message", zap.String("task_id", data.TaskID), zap.Error(err)) } else { - s.logger.Info("created agent comment", + s.logger.Info("created agent message", zap.String("task_id", data.TaskID), zap.Int("message_length", len(data.AgentMessage))) } + + if s.markPromptComplete(data.TaskID) { + s.finalizeAgentReady(data.TaskID, sessionID) + } } } -// handleToolCallStarted handles tool call started events and saves as comment +// handleToolCallStarted handles tool call started events and saves as message 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 { + // Save tool call as a message if we have a message creator + if s.messageCreator != 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, data.Args); err != nil { - s.logger.Error("failed to create tool call comment", + if err := s.messageCreator.CreateToolCallMessage(ctx, data.TaskID, data.ToolCallID, data.Title, data.Status, sessionID, data.Args); err != nil { + s.logger.Error("failed to create tool call message", zap.String("task_id", data.TaskID), zap.String("tool_call_id", data.ToolCallID), zap.Error(err)) } else { - s.logger.Info("created tool call comment", + s.logger.Info("created tool call message", zap.String("task_id", data.TaskID), zap.String("tool_call_id", data.ToolCallID)) } + + if sessionID != "" { + s.updateAgentSessionState(ctx, data.TaskID, sessionID, models.AgentSessionStateRunning, "", false) + } } } -// handleToolCallComplete handles tool call complete events and updates the comment +// handleToolCallComplete handles tool call complete events and updates the message func (s *Service) handleToolCallComplete(ctx context.Context, data watcher.ToolCallCompleteData) { - // Update tool call comment status if we have a comment creator - if s.commentCreator != nil { - if err := s.commentCreator.UpdateToolCallComment(ctx, data.TaskID, data.ToolCallID, data.Status, data.Result); err != nil { - s.logger.Error("failed to update tool call comment", + // Update tool call message status if we have a message creator + if s.messageCreator != nil { + sessionID, _ := s.executor.GetActiveSessionID(ctx, data.TaskID) + if err := s.messageCreator.UpdateToolCallMessage(ctx, data.TaskID, data.ToolCallID, data.Status, data.Result, sessionID); err != nil { + s.logger.Error("failed to update tool call message", zap.String("task_id", data.TaskID), zap.String("tool_call_id", data.ToolCallID), zap.Error(err)) } + + if sessionID != "" { + s.updateAgentSessionState(ctx, data.TaskID, sessionID, models.AgentSessionStateRunning, "", false) + } } } diff --git a/apps/backend/internal/task/controller/board.go b/apps/backend/internal/task/controller/board.go index 11904cacae..477f4b3e01 100644 --- a/apps/backend/internal/task/controller/board.go +++ b/apps/backend/internal/task/controller/board.go @@ -2,6 +2,7 @@ package controller import ( "context" + "fmt" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/service" @@ -149,3 +150,47 @@ func (c *BoardController) GetSnapshot(ctx context.Context, req dto.GetBoardSnaps } return resp, nil } + +func (c *BoardController) GetWorkspaceSnapshot(ctx context.Context, req dto.GetWorkspaceBoardSnapshotRequest) (dto.BoardSnapshotDTO, error) { + boardID := req.BoardID + if boardID == "" { + boards, err := c.service.ListBoards(ctx, req.WorkspaceID) + if err != nil { + return dto.BoardSnapshotDTO{}, err + } + if len(boards) == 0 { + return dto.BoardSnapshotDTO{}, fmt.Errorf("no boards found for workspace: %s", req.WorkspaceID) + } + boardID = boards[0].ID + } + + board, err := c.service.GetBoard(ctx, boardID) + if err != nil { + return dto.BoardSnapshotDTO{}, err + } + if board.WorkspaceID != req.WorkspaceID { + return dto.BoardSnapshotDTO{}, fmt.Errorf("board does not belong to workspace: %s", req.WorkspaceID) + } + + columns, err := c.service.ListColumns(ctx, boardID) + if err != nil { + return dto.BoardSnapshotDTO{}, err + } + tasks, err := c.service.ListTasks(ctx, boardID) + if err != nil { + return dto.BoardSnapshotDTO{}, err + } + + resp := dto.BoardSnapshotDTO{ + Board: dto.FromBoard(board), + Columns: make([]dto.ColumnDTO, 0, len(columns)), + Tasks: make([]dto.TaskDTO, 0, len(tasks)), + } + for _, column := range columns { + resp.Columns = append(resp.Columns, dto.FromColumn(column)) + } + for _, task := range tasks { + resp.Tasks = append(resp.Tasks, dto.FromTask(task)) + } + return resp, nil +} diff --git a/apps/backend/internal/task/controller/comment.go b/apps/backend/internal/task/controller/comment.go deleted file mode 100644 index 1ea66920a1..0000000000 --- a/apps/backend/internal/task/controller/comment.go +++ /dev/null @@ -1,79 +0,0 @@ -package controller - -import ( - "context" - - "github.com/kandev/kandev/internal/task/dto" - "github.com/kandev/kandev/internal/task/service" - v1 "github.com/kandev/kandev/pkg/api/v1" -) - -type CommentController struct { - service *service.Service -} - -func NewCommentController(svc *service.Service) *CommentController { - return &CommentController{service: svc} -} - -func (c *CommentController) ListComments(ctx context.Context, req dto.ListCommentsRequest) (dto.ListCommentsResponse, error) { - comments, hasMore, err := c.service.ListCommentsPaginated(ctx, service.ListCommentsRequest{ - TaskID: req.TaskID, - Limit: req.Limit, - Before: req.Before, - After: req.After, - Sort: req.Sort, - }) - if err != nil { - return dto.ListCommentsResponse{}, err - } - result := make([]*v1.Comment, 0, len(comments)) - for _, comment := range comments { - result = append(result, comment.ToAPI()) - } - cursor := "" - if len(result) > 0 { - cursor = result[len(result)-1].ID - } - return dto.ListCommentsResponse{ - Comments: result, - Total: len(result), - HasMore: hasMore, - Cursor: cursor, - }, nil -} - -func (c *CommentController) ListAllComments(ctx context.Context, taskID string) (dto.ListCommentsResponse, error) { - comments, err := c.service.ListComments(ctx, taskID) - if err != nil { - return dto.ListCommentsResponse{}, err - } - result := make([]*v1.Comment, 0, len(comments)) - for _, comment := range comments { - result = append(result, comment.ToAPI()) - } - return dto.ListCommentsResponse{ - Comments: result, - Total: len(result), - HasMore: false, - Cursor: "", - }, nil -} - -func (c *CommentController) CreateComment(ctx context.Context, req dto.CreateCommentRequest) (*v1.Comment, error) { - comment, err := c.service.CreateComment(ctx, &service.CreateCommentRequest{ - TaskID: req.TaskID, - Content: req.Content, - AuthorType: req.AuthorType, - AuthorID: req.AuthorID, - Type: req.Type, - RequestsInput: req.RequestsInput, - Metadata: req.Metadata, - ACPSessionID: req.ACPSessionID, - AgentSessionID: req.AgentSessionID, - }) - if err != nil { - return nil, err - } - return comment.ToAPI(), nil -} diff --git a/apps/backend/internal/task/controller/environment.go b/apps/backend/internal/task/controller/environment.go index 490a28116a..7c02f5ef9e 100644 --- a/apps/backend/internal/task/controller/environment.go +++ b/apps/backend/internal/task/controller/environment.go @@ -2,6 +2,7 @@ package controller import ( "context" + "errors" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/service" @@ -70,6 +71,9 @@ func (c *EnvironmentController) UpdateEnvironment(ctx context.Context, req dto.U func (c *EnvironmentController) DeleteEnvironment(ctx context.Context, req dto.DeleteEnvironmentRequest) (dto.SuccessResponse, error) { if err := c.service.DeleteEnvironment(ctx, req.ID); err != nil { + if errors.Is(err, service.ErrActiveAgentSessions) { + return dto.SuccessResponse{}, ErrActiveAgentSessions + } return dto.SuccessResponse{}, err } return dto.SuccessResponse{Success: true}, nil diff --git a/apps/backend/internal/task/controller/errors.go b/apps/backend/internal/task/controller/errors.go new file mode 100644 index 0000000000..14b51c3314 --- /dev/null +++ b/apps/backend/internal/task/controller/errors.go @@ -0,0 +1,5 @@ +package controller + +import "errors" + +var ErrActiveAgentSessions = errors.New("active agent sessions exist") diff --git a/apps/backend/internal/task/controller/executor.go b/apps/backend/internal/task/controller/executor.go index 5d4814b2ee..c4c9568aa5 100644 --- a/apps/backend/internal/task/controller/executor.go +++ b/apps/backend/internal/task/controller/executor.go @@ -2,6 +2,7 @@ package controller import ( "context" + "errors" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/service" @@ -67,6 +68,9 @@ func (c *ExecutorController) UpdateExecutor(ctx context.Context, req dto.UpdateE func (c *ExecutorController) DeleteExecutor(ctx context.Context, req dto.DeleteExecutorRequest) (dto.SuccessResponse, error) { if err := c.service.DeleteExecutor(ctx, req.ID); err != nil { + if errors.Is(err, service.ErrActiveAgentSessions) { + return dto.SuccessResponse{}, ErrActiveAgentSessions + } return dto.SuccessResponse{}, err } return dto.SuccessResponse{Success: true}, nil diff --git a/apps/backend/internal/task/controller/message.go b/apps/backend/internal/task/controller/message.go new file mode 100644 index 0000000000..5b26db6992 --- /dev/null +++ b/apps/backend/internal/task/controller/message.go @@ -0,0 +1,78 @@ +package controller + +import ( + "context" + + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type MessageController struct { + service *service.Service +} + +func NewMessageController(svc *service.Service) *MessageController { + return &MessageController{service: svc} +} + +func (c *MessageController) ListMessages(ctx context.Context, req dto.ListMessagesRequest) (dto.ListMessagesResponse, error) { + messages, hasMore, err := c.service.ListMessagesPaginated(ctx, service.ListMessagesRequest{ + AgentSessionID: req.AgentSessionID, + Limit: req.Limit, + Before: req.Before, + After: req.After, + Sort: req.Sort, + }) + if err != nil { + return dto.ListMessagesResponse{}, err + } + result := make([]*v1.Message, 0, len(messages)) + for _, message := range messages { + result = append(result, message.ToAPI()) + } + cursor := "" + if len(result) > 0 { + cursor = result[len(result)-1].ID + } + return dto.ListMessagesResponse{ + Messages: result, + Total: len(result), + HasMore: hasMore, + Cursor: cursor, + }, nil +} + +func (c *MessageController) ListAllMessages(ctx context.Context, sessionID string) (dto.ListMessagesResponse, error) { + messages, err := c.service.ListMessages(ctx, sessionID) + if err != nil { + return dto.ListMessagesResponse{}, err + } + result := make([]*v1.Message, 0, len(messages)) + for _, message := range messages { + result = append(result, message.ToAPI()) + } + return dto.ListMessagesResponse{ + Messages: result, + Total: len(result), + HasMore: false, + Cursor: "", + }, nil +} + +func (c *MessageController) CreateMessage(ctx context.Context, req dto.CreateMessageRequest) (*v1.Message, error) { + message, err := c.service.CreateMessage(ctx, &service.CreateMessageRequest{ + AgentSessionID: req.AgentSessionID, + TaskID: req.TaskID, + Content: req.Content, + AuthorType: req.AuthorType, + AuthorID: req.AuthorID, + Type: req.Type, + RequestsInput: req.RequestsInput, + Metadata: req.Metadata, + }) + if err != nil { + return nil, err + } + return message.ToAPI(), nil +} diff --git a/apps/backend/internal/task/controller/repository.go b/apps/backend/internal/task/controller/repository.go index cdff841b3b..ee2ff8fe79 100644 --- a/apps/backend/internal/task/controller/repository.go +++ b/apps/backend/internal/task/controller/repository.go @@ -2,6 +2,7 @@ package controller import ( "context" + "errors" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/service" @@ -79,6 +80,9 @@ func (c *RepositoryController) UpdateRepository(ctx context.Context, req dto.Upd func (c *RepositoryController) DeleteRepository(ctx context.Context, req dto.DeleteRepositoryRequest) (dto.SuccessResponse, error) { if err := c.service.DeleteRepository(ctx, req.ID); err != nil { + if errors.Is(err, service.ErrActiveAgentSessions) { + return dto.SuccessResponse{}, ErrActiveAgentSessions + } return dto.SuccessResponse{}, err } return dto.SuccessResponse{Success: true}, nil diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index 5841849e9d..2621536c2a 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -73,9 +73,8 @@ func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskReque Description: req.Description, Priority: req.Priority, State: req.State, - AgentProfileID: req.AgentProfileID, - RepositoryURL: req.RepositoryURL, - Branch: req.Branch, + RepositoryID: req.RepositoryID, + BaseBranch: req.BaseBranch, AssignedTo: req.AssignedTo, Position: req.Position, Metadata: req.Metadata, @@ -92,7 +91,8 @@ func (c *TaskController) UpdateTask(ctx context.Context, req dto.UpdateTaskReque Description: req.Description, Priority: req.Priority, State: req.State, - AgentProfileID: req.AgentProfileID, + RepositoryID: req.RepositoryID, + BaseBranch: req.BaseBranch, AssignedTo: req.AssignedTo, Position: req.Position, Metadata: req.Metadata, diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index df1ac67276..af498fd443 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -100,9 +100,8 @@ type TaskDTO struct { Description string `json:"description"` State v1.TaskState `json:"state"` Priority int `json:"priority"` - AgentProfileID *string `json:"agent_profile_id,omitempty"` - RepositoryURL *string `json:"repository_url,omitempty"` - Branch *string `json:"branch,omitempty"` + RepositoryID *string `json:"repository_id,omitempty"` + BaseBranch *string `json:"base_branch,omitempty"` AssignedAgentID *string `json:"assigned_agent_id,omitempty"` Position int `json:"position"` CreatedAt time.Time `json:"created_at"` @@ -119,8 +118,8 @@ type BoardSnapshotDTO struct { Tasks []TaskDTO `json:"tasks"` } -type ListCommentsResponse struct { - Comments []*v1.Comment `json:"comments"` +type ListMessagesResponse struct { + Messages []*v1.Message `json:"messages"` Total int `json:"total"` HasMore bool `json:"has_more"` Cursor string `json:"cursor"` @@ -318,17 +317,13 @@ func FromLocalRepository(repo service.LocalRepository) LocalRepositoryDTO { } func FromTask(task *models.Task) TaskDTO { - var agentProfileID *string - if task.AgentProfileID != "" { - agentProfileID = &task.AgentProfileID + var repositoryID *string + if task.RepositoryID != "" { + repositoryID = &task.RepositoryID } - var repositoryURL *string - if task.RepositoryURL != "" { - repositoryURL = &task.RepositoryURL - } - var branch *string - if task.Branch != "" { - branch = &task.Branch + var baseBranch *string + if task.BaseBranch != "" { + baseBranch = &task.BaseBranch } var assignedAgentID *string if task.AssignedTo != "" { @@ -344,9 +339,8 @@ func FromTask(task *models.Task) TaskDTO { Description: task.Description, State: task.State, Priority: task.Priority, - AgentProfileID: agentProfileID, - RepositoryURL: repositoryURL, - Branch: branch, + RepositoryID: repositoryID, + BaseBranch: baseBranch, AssignedAgentID: assignedAgentID, Position: task.Position, CreatedAt: task.CreatedAt, diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 2ef89d7cb7..a9fe6fe3b2 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -220,6 +220,11 @@ type GetBoardSnapshotRequest struct { BoardID string } +type GetWorkspaceBoardSnapshotRequest struct { + WorkspaceID string + BoardID string +} + type ListTasksRequest struct { BoardID string } @@ -236,9 +241,8 @@ type CreateTaskRequest struct { Description string Priority int State *v1.TaskState - AgentProfileID string - RepositoryURL string - Branch string + RepositoryID string + BaseBranch string AssignedTo string Position int Metadata map[string]interface{} @@ -250,7 +254,8 @@ type UpdateTaskRequest struct { Description *string Priority *int State *v1.TaskState - AgentProfileID *string + RepositoryID *string + BaseBranch *string AssignedTo *string Position *int Metadata map[string]interface{} @@ -260,24 +265,23 @@ type DeleteTaskRequest struct { ID string } -type ListCommentsRequest struct { - TaskID string +type ListMessagesRequest struct { + AgentSessionID string Limit int Before string After string Sort string } -type CreateCommentRequest struct { - TaskID string +type CreateMessageRequest struct { + AgentSessionID string Content string AuthorType string AuthorID string Type string RequestsInput bool Metadata map[string]interface{} - ACPSessionID string - AgentSessionID string + TaskID string } type MoveTaskRequest struct { diff --git a/apps/backend/internal/task/handlers/board_handlers.go b/apps/backend/internal/task/handlers/board_handlers.go index 7f460a817e..e7835d12fb 100644 --- a/apps/backend/internal/task/handlers/board_handlers.go +++ b/apps/backend/internal/task/handlers/board_handlers.go @@ -36,6 +36,7 @@ func (h *BoardHandlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") api.GET("/boards", h.httpListBoards) api.GET("/workspaces/:id/boards", h.httpListBoardsByWorkspace) + api.GET("/workspaces/:id/board-snapshot", h.httpGetWorkspaceBoardSnapshot) api.GET("/boards/:id", h.httpGetBoard) api.GET("/boards/:id/columns", h.httpListColumns) api.GET("/boards/:id/snapshot", h.httpGetBoardSnapshot) @@ -248,6 +249,24 @@ func (h *BoardHandlers) httpGetBoardSnapshot(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *BoardHandlers) httpGetWorkspaceBoardSnapshot(c *gin.Context) { + workspaceID := c.Param("id") + if workspaceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace id is required"}) + return + } + boardID := c.Query("board_id") + resp, err := h.controller.GetWorkspaceSnapshot(c.Request.Context(), dto.GetWorkspaceBoardSnapshotRequest{ + WorkspaceID: workspaceID, + BoardID: boardID, + }) + if err != nil { + handleNotFound(c, h.logger, err, "board not found") + return + } + c.JSON(http.StatusOK, resp) +} + // WS handlers func (h *BoardHandlers) wsListBoards(ctx context.Context, msg *ws.Message) (*ws.Message, error) { diff --git a/apps/backend/internal/task/handlers/environment_handlers.go b/apps/backend/internal/task/handlers/environment_handlers.go index f9b2b83262..a2e0e049a4 100644 --- a/apps/backend/internal/task/handlers/environment_handlers.go +++ b/apps/backend/internal/task/handlers/environment_handlers.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "errors" "net/http" "github.com/gin-gonic/gin" @@ -141,6 +142,10 @@ func (h *EnvironmentHandlers) httpUpdateEnvironment(c *gin.Context) { func (h *EnvironmentHandlers) httpDeleteEnvironment(c *gin.Context) { resp, err := h.controller.DeleteEnvironment(c.Request.Context(), dto.DeleteEnvironmentRequest{ID: c.Param("id")}) if err != nil { + if errors.Is(err, controller.ErrActiveAgentSessions) { + c.JSON(http.StatusConflict, gin.H{"error": "environment is used by an active agent session"}) + return + } h.logger.Error("failed to delete environment", zap.Error(err)) c.JSON(http.StatusBadRequest, gin.H{"error": "environment not deleted"}) return @@ -262,6 +267,9 @@ func (h *EnvironmentHandlers) wsDeleteEnvironment(ctx context.Context, msg *ws.M resp, err := h.controller.DeleteEnvironment(ctx, dto.DeleteEnvironmentRequest{ID: req.ID}) if err != nil { h.logger.Error("failed to delete environment", zap.Error(err)) + if errors.Is(err, controller.ErrActiveAgentSessions) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "environment is used by an active agent session", nil) + } return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete environment", nil) } return ws.NewResponse(msg.ID, msg.Action, resp) diff --git a/apps/backend/internal/task/handlers/executor_handlers.go b/apps/backend/internal/task/handlers/executor_handlers.go index 079f5f2eac..83c0bbee3a 100644 --- a/apps/backend/internal/task/handlers/executor_handlers.go +++ b/apps/backend/internal/task/handlers/executor_handlers.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "errors" "net/http" "github.com/gin-gonic/gin" @@ -144,6 +145,10 @@ func (h *ExecutorHandlers) httpUpdateExecutor(c *gin.Context) { func (h *ExecutorHandlers) httpDeleteExecutor(c *gin.Context) { resp, err := h.controller.DeleteExecutor(c.Request.Context(), dto.DeleteExecutorRequest{ID: c.Param("id")}) if err != nil { + if errors.Is(err, controller.ErrActiveAgentSessions) { + c.JSON(http.StatusConflict, gin.H{"error": "executor is used by an active agent session"}) + return + } h.logger.Error("failed to delete executor", zap.Error(err)) c.JSON(http.StatusBadRequest, gin.H{"error": "executor not deleted"}) return @@ -268,6 +273,9 @@ func (h *ExecutorHandlers) wsDeleteExecutor(ctx context.Context, msg *ws.Message resp, err := h.controller.DeleteExecutor(ctx, dto.DeleteExecutorRequest{ID: req.ID}) if err != nil { h.logger.Error("failed to delete executor", zap.Error(err)) + if errors.Is(err, controller.ErrActiveAgentSessions) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "executor is used by an active agent session", nil) + } return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete executor", nil) } return ws.NewResponse(msg.ID, msg.Action, resp) diff --git a/apps/backend/internal/task/handlers/comment_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go similarity index 53% rename from apps/backend/internal/task/handlers/comment_handlers.go rename to apps/backend/internal/task/handlers/message_handlers.go index e5bcead56c..8e4468b654 100644 --- a/apps/backend/internal/task/handlers/comment_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -27,45 +27,45 @@ type OrchestratorService interface { PromptTask(ctx context.Context, taskID string, prompt string) (*PromptResult, error) } -// CommentHandlers handles WebSocket requests for comments -type CommentHandlers struct { - commentController *controller.CommentController +// MessageHandlers handles WebSocket requests for messages +type MessageHandlers struct { + messageController *controller.MessageController taskController *controller.TaskController - orchestrator OrchestratorService - logger *logger.Logger + orchestrator OrchestratorService + logger *logger.Logger } -// NewCommentHandlers creates a new CommentHandlers instance -func NewCommentHandlers(commentCtrl *controller.CommentController, taskCtrl *controller.TaskController, orchestrator OrchestratorService, log *logger.Logger) *CommentHandlers { - return &CommentHandlers{ - commentController: commentCtrl, +// NewMessageHandlers creates a new MessageHandlers instance +func NewMessageHandlers(messageCtrl *controller.MessageController, taskCtrl *controller.TaskController, orchestrator OrchestratorService, log *logger.Logger) *MessageHandlers { + return &MessageHandlers{ + messageController: messageCtrl, taskController: taskCtrl, - orchestrator: orchestrator, - logger: log.WithFields(zap.String("component", "task-comment-handlers")), + orchestrator: orchestrator, + logger: log.WithFields(zap.String("component", "task-message-handlers")), } } -// RegisterCommentRoutes registers comment WebSocket handlers -func RegisterCommentRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, commentCtrl *controller.CommentController, taskCtrl *controller.TaskController, orchestrator OrchestratorService, log *logger.Logger) { - handlers := NewCommentHandlers(commentCtrl, taskCtrl, orchestrator, log) +// RegisterMessageRoutes registers message HTTP + WebSocket handlers +func RegisterMessageRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, messageCtrl *controller.MessageController, taskCtrl *controller.TaskController, orchestrator OrchestratorService, log *logger.Logger) { + handlers := NewMessageHandlers(messageCtrl, taskCtrl, orchestrator, log) handlers.registerHTTP(router) handlers.registerWS(dispatcher) } -func (h *CommentHandlers) registerHTTP(router *gin.Engine) { +func (h *MessageHandlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") - api.GET("/tasks/:id/comments", h.httpListComments) + api.GET("/agent-sessions/:id/messages", h.httpListMessages) } -func (h *CommentHandlers) registerWS(dispatcher *ws.Dispatcher) { - dispatcher.RegisterFunc(ws.ActionCommentAdd, h.wsAddComment) - dispatcher.RegisterFunc(ws.ActionCommentList, h.wsListComments) +func (h *MessageHandlers) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc(ws.ActionMessageAdd, h.wsAddMessage) + dispatcher.RegisterFunc(ws.ActionMessageList, h.wsListMessages) } -func (h *CommentHandlers) httpListComments(c *gin.Context) { - taskID := c.Param("id") - if taskID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "task id is required"}) +func (h *MessageHandlers) httpListMessages(c *gin.Context) { + sessionID := c.Param("id") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent session id is required"}) return } before := c.Query("before") @@ -89,23 +89,23 @@ func (h *CommentHandlers) httpListComments(c *gin.Context) { } var ( - resp dto.ListCommentsResponse + resp dto.ListMessagesResponse err error ) if paginated { - resp, err = h.commentController.ListComments(c.Request.Context(), dto.ListCommentsRequest{ - TaskID: taskID, - Limit: limit, - Before: before, - After: after, - Sort: sort, + resp, err = h.messageController.ListMessages(c.Request.Context(), dto.ListMessagesRequest{ + AgentSessionID: sessionID, + Limit: limit, + Before: before, + After: after, + Sort: sort, }) } else { - resp, err = h.commentController.ListAllComments(c.Request.Context(), taskID) + resp, err = h.messageController.ListAllMessages(c.Request.Context(), sessionID) } if err != nil { - h.logger.Error("failed to list comments", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list comments"}) + h.logger.Error("failed to list messages", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"}) return } c.JSON(http.StatusOK, resp) @@ -113,17 +113,21 @@ func (h *CommentHandlers) httpListComments(c *gin.Context) { // WS handlers -type wsAddCommentRequest struct { - TaskID string `json:"task_id"` - Content string `json:"content"` - AuthorID string `json:"author_id,omitempty"` +type wsAddMessageRequest struct { + TaskID string `json:"task_id"` + AgentSessionID string `json:"agent_session_id"` + Content string `json:"content"` + AuthorID string `json:"author_id,omitempty"` } -func (h *CommentHandlers) wsAddComment(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsAddCommentRequest +func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsAddMessageRequest if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } + if req.AgentSessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_session_id is required", nil) + } if req.TaskID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) } @@ -139,91 +143,76 @@ func (h *CommentHandlers) wsAddComment(ctx context.Context, msg *ws.Message) (*w } // If task is in REVIEW state, transition back to IN_PROGRESS - // This implements the backward movement in the review cycle: - // REVIEW → (user sends follow-up) → IN_PROGRESS - // The transition back to REVIEW happens in handleAgentReady when the agent finishes if task.State == v1.TaskStateReview { nextState := v1.TaskStateInProgress if _, err := h.taskController.UpdateTask(ctx, dto.UpdateTaskRequest{ID: req.TaskID, State: &nextState}); err != nil { h.logger.Error("failed to transition task from REVIEW to IN_PROGRESS", zap.String("task_id", req.TaskID), zap.Error(err)) - // Continue anyway - the comment should still be created } else { h.logger.Info("task transitioned from REVIEW to IN_PROGRESS", zap.String("task_id", req.TaskID)) } } - comment, err := h.commentController.CreateComment(ctx, dto.CreateCommentRequest{ - TaskID: req.TaskID, - Content: req.Content, - AuthorType: "user", - AuthorID: req.AuthorID, + message, err := h.messageController.CreateMessage(ctx, dto.CreateMessageRequest{ + AgentSessionID: req.AgentSessionID, + TaskID: req.TaskID, + Content: req.Content, + AuthorType: "user", + AuthorID: req.AuthorID, }) if err != nil { - h.logger.Error("failed to create comment", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create comment", nil) + h.logger.Error("failed to create message", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create message", nil) } - // Auto-forward comment as prompt to running agent if orchestrator is available - // The agent's response will be saved as a comment via the prompt_complete event - // Note: State transition to REVIEW happens via handleAgentReady when the agent finishes + // Auto-forward message as prompt to running agent if orchestrator is available if h.orchestrator != nil { _, err := h.orchestrator.PromptTask(ctx, req.TaskID, req.Content) if err != nil { - h.logger.Warn("failed to forward comment as prompt to agent", + h.logger.Warn("failed to forward message as prompt to agent", zap.String("task_id", req.TaskID), zap.Error(err)) } } - return ws.NewResponse(msg.ID, msg.Action, comment) + return ws.NewResponse(msg.ID, msg.Action, message) } -type wsListCommentsRequest struct { - TaskID string `json:"task_id"` - Limit int `json:"limit"` - Before string `json:"before"` - After string `json:"after"` - Sort string `json:"sort"` +type wsListMessagesRequest struct { + AgentSessionID string `json:"agent_session_id"` + Limit int `json:"limit"` + Before string `json:"before"` + After string `json:"after"` + Sort string `json:"sort"` } -func (h *CommentHandlers) wsListComments(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsListCommentsRequest +func (h *MessageHandlers) wsListMessages(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsListMessagesRequest 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) + if req.AgentSessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_session_id is required", nil) } if req.Before != "" && req.After != "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "only one of before or after can be set", nil) } - sort := strings.ToLower(strings.TrimSpace(req.Sort)) - if sort != "" && sort != "asc" && sort != "desc" { + if req.Sort != "" && req.Sort != "asc" && req.Sort != "desc" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "sort must be asc or desc", nil) } - var ( - resp dto.ListCommentsResponse - err error - ) - if req.Limit != 0 || req.Before != "" || req.After != "" || sort != "" { - resp, err = h.commentController.ListComments(ctx, dto.ListCommentsRequest{ - TaskID: req.TaskID, - Limit: req.Limit, - Before: req.Before, - After: req.After, - Sort: sort, - }) - } else { - resp, err = h.commentController.ListAllComments(ctx, req.TaskID) - } + resp, err := h.messageController.ListMessages(ctx, dto.ListMessagesRequest{ + AgentSessionID: req.AgentSessionID, + Limit: req.Limit, + Before: req.Before, + After: req.After, + Sort: req.Sort, + }) if err != nil { - h.logger.Error("failed to list comments", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list comments", nil) + h.logger.Error("failed to list messages", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list messages", nil) } - return ws.NewResponse(msg.ID, msg.Action, resp) } diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index dd9f32d4fd..13985eaa1b 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -219,6 +219,10 @@ func (h *RepositoryHandlers) httpUpdateRepository(c *gin.Context) { func (h *RepositoryHandlers) httpDeleteRepository(c *gin.Context) { if _, err := h.controller.DeleteRepository(c.Request.Context(), dto.DeleteRepositoryRequest{ID: c.Param("id")}); err != nil { + if errors.Is(err, controller.ErrActiveAgentSessions) { + c.JSON(http.StatusConflict, gin.H{"error": "repository is used by an active agent session"}) + return + } handleNotFound(c, h.logger, err, "repository not found") return } @@ -441,6 +445,9 @@ func (h *RepositoryHandlers) wsDeleteRepository(ctx context.Context, msg *ws.Mes } if _, err := h.controller.DeleteRepository(ctx, dto.DeleteRepositoryRequest{ID: req.ID}); err != nil { h.logger.Error("failed to delete repository", zap.Error(err)) + if errors.Is(err, controller.ErrActiveAgentSessions) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository is used by an active agent session", nil) + } return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Repository not found", nil) } return ws.NewResponse(msg.ID, msg.Action, gin.H{"deleted": true}) diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 7bcc9a73cc..26e913c392 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -80,9 +80,8 @@ type httpCreateTaskRequest struct { Description string `json:"description,omitempty"` Priority int `json:"priority,omitempty"` State *v1.TaskState `json:"state,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - RepositoryURL string `json:"repository_url,omitempty"` - Branch string `json:"branch,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` AssignedTo string `json:"assigned_to,omitempty"` Position int `json:"position,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -98,8 +97,8 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) return } - if body.RepositoryURL == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "repository_url is required"}) + if body.RepositoryID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id is required"}) return } resp, err := h.controller.CreateTask(c.Request.Context(), dto.CreateTaskRequest{ @@ -110,9 +109,8 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { Description: body.Description, Priority: body.Priority, State: body.State, - AgentProfileID: body.AgentProfileID, - RepositoryURL: body.RepositoryURL, - Branch: body.Branch, + RepositoryID: body.RepositoryID, + BaseBranch: body.BaseBranch, AssignedTo: body.AssignedTo, Position: body.Position, Metadata: body.Metadata, @@ -129,7 +127,8 @@ type httpUpdateTaskRequest struct { Description *string `json:"description,omitempty"` Priority *int `json:"priority,omitempty"` State *v1.TaskState `json:"state,omitempty"` - AgentProfileID *string `json:"agent_profile_id,omitempty"` + RepositoryID *string `json:"repository_id,omitempty"` + BaseBranch *string `json:"base_branch,omitempty"` AssignedTo *string `json:"assigned_to,omitempty"` Position *int `json:"position,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -147,7 +146,8 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { Description: body.Description, Priority: body.Priority, State: body.State, - AgentProfileID: body.AgentProfileID, + RepositoryID: body.RepositoryID, + BaseBranch: body.BaseBranch, AssignedTo: body.AssignedTo, Position: body.Position, Metadata: body.Metadata, @@ -228,9 +228,8 @@ type wsCreateTaskRequest struct { Description string `json:"description,omitempty"` Priority int `json:"priority,omitempty"` State *v1.TaskState `json:"state,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - RepositoryURL string `json:"repository_url,omitempty"` - Branch string `json:"branch,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` AssignedTo string `json:"assigned_to,omitempty"` Position int `json:"position,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -253,8 +252,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M if req.Title == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil) } - if req.RepositoryURL == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_url is required", nil) + if req.RepositoryID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id is required", nil) } resp, err := h.controller.CreateTask(ctx, dto.CreateTaskRequest{ @@ -265,9 +264,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M Description: req.Description, Priority: req.Priority, State: req.State, - AgentProfileID: req.AgentProfileID, - RepositoryURL: req.RepositoryURL, - Branch: req.Branch, + RepositoryID: req.RepositoryID, + BaseBranch: req.BaseBranch, AssignedTo: req.AssignedTo, Position: req.Position, Metadata: req.Metadata, @@ -305,7 +303,8 @@ type wsUpdateTaskRequest struct { Description *string `json:"description,omitempty"` Priority *int `json:"priority,omitempty"` State *v1.TaskState `json:"state,omitempty"` - AgentProfileID *string `json:"agent_profile_id,omitempty"` + RepositoryID *string `json:"repository_id,omitempty"` + BaseBranch *string `json:"base_branch,omitempty"` AssignedTo *string `json:"assigned_to,omitempty"` Position *int `json:"position,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -326,7 +325,8 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M Description: req.Description, Priority: req.Priority, State: req.State, - AgentProfileID: req.AgentProfileID, + RepositoryID: req.RepositoryID, + BaseBranch: req.BaseBranch, AssignedTo: req.AssignedTo, Position: req.Position, Metadata: req.Metadata, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 0a6c54ebda..e602f778a3 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -8,22 +8,21 @@ import ( // Task represents a task in the database type Task struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - ColumnID string `json:"column_id"` - Title string `json:"title"` - Description string `json:"description"` - State v1.TaskState `json:"state"` - Priority int `json:"priority"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - RepositoryURL string `json:"repository_url,omitempty"` - Branch string `json:"branch,omitempty"` - AssignedTo string `json:"assigned_to,omitempty"` - Position int `json:"position"` // Order within column - Metadata map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description"` + State v1.TaskState `json:"state"` + Priority int `json:"priority"` + RepositoryID string `json:"repository_id,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + AssignedTo string `json:"assigned_to,omitempty"` + Position int `json:"position"` // Order within column + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Board represents a Kanban board @@ -61,108 +60,115 @@ type Column struct { UpdatedAt time.Time `json:"updated_at"` } -// CommentAuthorType represents who authored a comment -type CommentAuthorType string +// MessageAuthorType represents who authored a message +type MessageAuthorType string const ( - // CommentAuthorUser indicates a comment from a human user - CommentAuthorUser CommentAuthorType = "user" - // CommentAuthorAgent indicates a comment from an AI agent - CommentAuthorAgent CommentAuthorType = "agent" + // MessageAuthorUser indicates a message from a human user + MessageAuthorUser MessageAuthorType = "user" + // MessageAuthorAgent indicates a message from an AI agent + MessageAuthorAgent MessageAuthorType = "agent" ) -// CommentType represents the type of comment content -type CommentType string +// MessageType represents the type of message content +type MessageType 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" + // MessageTypeMessage is the default type for user/agent regular messages + MessageTypeMessage MessageType = "message" + // MessageTypeContent is for agent response content + MessageTypeContent MessageType = "content" + // MessageTypeToolCall is when agent uses a tool + MessageTypeToolCall MessageType = "tool_call" + // MessageTypeProgress is for progress updates + MessageTypeProgress MessageType = "progress" + // MessageTypeError is for error messages + MessageTypeError MessageType = "error" + // MessageTypeStatus is for status changes: started, completed, failed + MessageTypeStatus MessageType = "status" ) -// Comment represents a comment on a task -type Comment struct { +// Message represents a message in an agent session +type Message struct { ID string `json:"id"` - TaskID string `json:"task_id"` - AuthorType CommentAuthorType `json:"author_type"` + AgentSessionID string `json:"agent_session_id"` + TaskID string `json:"task_id,omitempty"` + AuthorType MessageAuthorType `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 + Type MessageType `json:"type,omitempty"` // Defaults to "message" 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 { - commentType := string(c.Type) - if commentType == "" { - commentType = string(CommentTypeMessage) +// ToAPI converts internal Message to API type +func (m *Message) ToAPI() *v1.Message { + messageType := string(m.Type) + if messageType == "" { + messageType = string(MessageTypeMessage) } - 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 + result := &v1.Message{ + ID: m.ID, + AgentSessionID: m.AgentSessionID, + TaskID: m.TaskID, + AuthorType: string(m.AuthorType), + AuthorID: m.AuthorID, + Content: m.Content, + Type: messageType, + Metadata: m.Metadata, + RequestsInput: m.RequestsInput, + CreatedAt: m.CreatedAt, } return result } -// AgentSessionStatus represents the status of an agent session -type AgentSessionStatus string +// AgentSessionState represents the state of an agent session +type AgentSessionState 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" + // AgentSessionStateCreated - session created but agent not started + AgentSessionStateCreated AgentSessionState = "CREATED" + // AgentSessionStateStarting - agent is starting up + AgentSessionStateStarting AgentSessionState = "STARTING" + // AgentSessionStateRunning - agent is actively running + AgentSessionStateRunning AgentSessionState = "RUNNING" + // AgentSessionStateWaitingForInput - agent waiting for user input + AgentSessionStateWaitingForInput AgentSessionState = "WAITING_FOR_INPUT" + // AgentSessionStateCompleted - agent finished successfully + AgentSessionStateCompleted AgentSessionState = "COMPLETED" + // AgentSessionStateFailed - agent failed with error + AgentSessionStateFailed AgentSessionState = "FAILED" + // AgentSessionStateCancelled - agent was manually stopped + AgentSessionStateCancelled AgentSessionState = "CANCELLED" ) // 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 - ContainerID string `json:"container_id"` // Docker container ID for cleanup - AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used - ACPSessionID string `json:"acp_session_id"` // ACP protocol session for resumption - ExecutorID string `json:"executor_id"` - EnvironmentID string `json:"environment_id"` - 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"` + ID string `json:"id"` + TaskID string `json:"task_id"` + AgentInstanceID string `json:"agent_instance_id"` // Docker container/agent instance + ContainerID string `json:"container_id"` // Docker container ID for cleanup + AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used + ExecutorID string `json:"executor_id"` + EnvironmentID string `json:"environment_id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + WorktreeID string `json:"worktree_id"` + WorktreePath string `json:"worktree_path"` + WorktreeBranch string `json:"worktree_branch"` + AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` + ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` + EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` + RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"` + State AgentSessionState `json:"state"` + 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 @@ -174,10 +180,14 @@ func (s *AgentSession) ToAPI() map[string]interface{} { "agent_instance_id": s.AgentInstanceID, "container_id": s.ContainerID, "agent_profile_id": s.AgentProfileID, - "acp_session_id": s.ACPSessionID, "executor_id": s.ExecutorID, "environment_id": s.EnvironmentID, - "status": string(s.Status), + "repository_id": s.RepositoryID, + "base_branch": s.BaseBranch, + "worktree_id": s.WorktreeID, + "worktree_path": s.WorktreePath, + "worktree_branch": s.WorktreeBranch, + "state": string(s.State), "progress": s.Progress, "started_at": s.StartedAt, "updated_at": s.UpdatedAt, @@ -191,6 +201,18 @@ func (s *AgentSession) ToAPI() map[string]interface{} { if s.Metadata != nil { result["metadata"] = s.Metadata } + if s.AgentProfileSnapshot != nil { + result["agent_profile_snapshot"] = s.AgentProfileSnapshot + } + if s.ExecutorSnapshot != nil { + result["executor_snapshot"] = s.ExecutorSnapshot + } + if s.EnvironmentSnapshot != nil { + result["environment_snapshot"] = s.EnvironmentSnapshot + } + if s.RepositorySnapshot != nil { + result["repository_snapshot"] = s.RepositorySnapshot + } return result } @@ -204,15 +226,16 @@ type Repository struct { // populated after the repo is cloned/synced on the agent host. LocalPath string `json:"local_path"` // Provider fields describe the upstream source (e.g. github/gitlab) for future syncing. - Provider string `json:"provider"` - ProviderRepoID string `json:"provider_repo_id"` - ProviderOwner string `json:"provider_owner"` - ProviderName string `json:"provider_name"` - DefaultBranch string `json:"default_branch"` - SetupScript string `json:"setup_script"` - CleanupScript string `json:"cleanup_script"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } // RepositoryScript represents a custom script for a repository @@ -261,6 +284,7 @@ type Executor struct { Config map[string]string `json:"config,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } // EnvironmentKind represents the runtime type for environments. @@ -287,23 +311,19 @@ type Environment struct { BuildConfig map[string]string `json:"build_config,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } // ToAPI converts internal Task to API type func (t *Task) ToAPI() *v1.Task { - var agentProfileID *string - if t.AgentProfileID != "" { - agentProfileID = &t.AgentProfileID + var repositoryID *string + if t.RepositoryID != "" { + repositoryID = &t.RepositoryID } - var repositoryURL *string - if t.RepositoryURL != "" { - repositoryURL = &t.RepositoryURL - } - - var branch *string - if t.Branch != "" { - branch = &t.Branch + var baseBranch *string + if t.BaseBranch != "" { + baseBranch = &t.BaseBranch } var assignedAgentID *string @@ -319,9 +339,8 @@ func (t *Task) ToAPI() *v1.Task { Description: t.Description, State: t.State, Priority: t.Priority, - AgentProfileID: agentProfileID, - RepositoryURL: repositoryURL, - Branch: branch, + RepositoryID: repositoryID, + BaseBranch: baseBranch, AssignedAgentID: assignedAgentID, CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, diff --git a/apps/backend/internal/task/models/models_test.go b/apps/backend/internal/task/models/models_test.go index bd625d6c7a..5d82459a74 100644 --- a/apps/backend/internal/task/models/models_test.go +++ b/apps/backend/internal/task/models/models_test.go @@ -45,7 +45,8 @@ func TestTaskStructInitialization(t *testing.T) { Description: "A test task description", State: v1.TaskStateTODO, Priority: 5, - AgentProfileID: "coding", + RepositoryID: "repo-123", + BaseBranch: "main", AssignedTo: "agent-001", Position: 1, Metadata: map[string]interface{}{"key": "value"}, @@ -77,8 +78,11 @@ func TestTaskStructInitialization(t *testing.T) { if task.Priority != 5 { t.Errorf("expected Priority 5, got %d", task.Priority) } - if task.AgentProfileID != "coding" { - t.Errorf("expected AgentProfileID 'coding', got %s", task.AgentProfileID) + if task.RepositoryID != "repo-123" { + t.Errorf("expected RepositoryID 'repo-123', got %s", task.RepositoryID) + } + if task.BaseBranch != "main" { + t.Errorf("expected BaseBranch 'main', got %s", task.BaseBranch) } if task.AssignedTo != "agent-001" { t.Errorf("expected AssignedTo 'agent-001', got %s", task.AssignedTo) @@ -156,7 +160,8 @@ func TestTaskToAPI(t *testing.T) { Description: "A test task description", State: v1.TaskStateInProgress, Priority: 3, - AgentProfileID: "coding", + RepositoryID: "repo-123", + BaseBranch: "main", AssignedTo: "agent-001", Position: 2, Metadata: map[string]interface{}{"key": "value"}, @@ -187,8 +192,11 @@ func TestTaskToAPI(t *testing.T) { if apiTask.Priority != task.Priority { t.Errorf("expected Priority %d, got %d", task.Priority, apiTask.Priority) } - if apiTask.AgentProfileID == nil || *apiTask.AgentProfileID != task.AgentProfileID { - t.Errorf("expected AgentProfileID %s, got %v", task.AgentProfileID, apiTask.AgentProfileID) + if apiTask.RepositoryID == nil || *apiTask.RepositoryID != task.RepositoryID { + t.Errorf("expected RepositoryID %s, got %v", task.RepositoryID, apiTask.RepositoryID) + } + if apiTask.BaseBranch == nil || *apiTask.BaseBranch != task.BaseBranch { + t.Errorf("expected BaseBranch %s, got %v", task.BaseBranch, apiTask.BaseBranch) } if apiTask.AssignedAgentID == nil || *apiTask.AssignedAgentID != task.AssignedTo { t.Errorf("expected AssignedAgentID %s, got %v", task.AssignedTo, apiTask.AssignedAgentID) @@ -209,7 +217,8 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) { Description: "A test task description", State: v1.TaskStateTODO, Priority: 0, - AgentProfileID: "", + RepositoryID: "", + BaseBranch: "", AssignedTo: "", Position: 0, Metadata: nil, @@ -219,8 +228,11 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) { apiTask := task.ToAPI() - if apiTask.AgentProfileID != nil { - t.Errorf("expected AgentProfileID nil, got %v", apiTask.AgentProfileID) + if apiTask.RepositoryID != nil { + t.Errorf("expected RepositoryID nil, got %v", apiTask.RepositoryID) + } + if apiTask.BaseBranch != nil { + t.Errorf("expected BaseBranch nil, got %v", apiTask.BaseBranch) } if apiTask.AssignedAgentID != nil { t.Errorf("expected AssignedAgentID nil, got %v", apiTask.AssignedAgentID) diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index f7a3f98bc4..9a645e5c30 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -7,7 +7,7 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) -type ListCommentsOptions struct { +type ListMessagesOptions struct { Limit int Before string After string @@ -49,14 +49,14 @@ type Repository interface { DeleteColumn(ctx context.Context, id string) error ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) - // Comment operations - CreateComment(ctx context.Context, comment *models.Comment) error - GetComment(ctx context.Context, id string) (*models.Comment, error) - GetCommentByToolCallID(ctx context.Context, taskID, toolCallID string) (*models.Comment, error) - UpdateComment(ctx context.Context, comment *models.Comment) error - ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) - ListCommentsPaginated(ctx context.Context, taskID string, opts ListCommentsOptions) ([]*models.Comment, bool, error) - DeleteComment(ctx context.Context, id string) error + // Message operations + CreateMessage(ctx context.Context, message *models.Message) error + GetMessage(ctx context.Context, id string) (*models.Message, error) + GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) + UpdateMessage(ctx context.Context, message *models.Message) error + ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) + ListMessagesPaginated(ctx context.Context, sessionID string, opts ListMessagesOptions) ([]*models.Message, bool, error) + DeleteMessage(ctx context.Context, id string) error // Agent Session operations CreateAgentSession(ctx context.Context, session *models.AgentSession) error @@ -64,9 +64,13 @@ type Repository interface { 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 + UpdateAgentSessionState(ctx context.Context, id string, state models.AgentSessionState, errorMessage string) error ListAgentSessions(ctx context.Context, taskID string) ([]*models.AgentSession, error) ListActiveAgentSessions(ctx context.Context) ([]*models.AgentSession, error) + HasActiveAgentSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) + HasActiveAgentSessionsByExecutor(ctx context.Context, executorID string) (bool, error) + HasActiveAgentSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) + HasActiveAgentSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) DeleteAgentSession(ctx context.Context, id string) error // Repository operations diff --git a/apps/backend/internal/task/repository/memory.go b/apps/backend/internal/task/repository/memory.go index b785a59528..98bf01ed32 100644 --- a/apps/backend/internal/task/repository/memory.go +++ b/apps/backend/internal/task/repository/memory.go @@ -19,7 +19,7 @@ type MemoryRepository struct { tasks map[string]*models.Task boards map[string]*models.Board columns map[string]*models.Column - comments map[string]*models.Comment + messages map[string]*models.Message repositories map[string]*models.Repository repoScripts map[string]*models.RepositoryScript agentSessions map[string]*models.AgentSession @@ -40,7 +40,7 @@ func NewMemoryRepository() *MemoryRepository { tasks: make(map[string]*models.Task), boards: make(map[string]*models.Board), columns: make(map[string]*models.Column), - comments: make(map[string]*models.Comment), + messages: make(map[string]*models.Message), repositories: make(map[string]*models.Repository), repoScripts: make(map[string]*models.RepositoryScript), agentSessions: make(map[string]*models.AgentSession), @@ -243,15 +243,9 @@ func (r *MemoryRepository) ListTasks(ctx context.Context, boardID string) ([]*mo var result []*models.Task for _, task := range r.tasks { - placement, ok := r.getTaskPlacement(task.ID, boardID) - if !ok { - continue + if task.BoardID == boardID { + result = append(result, task) } - copy := *task - copy.BoardID = placement.boardID - copy.ColumnID = placement.columnID - copy.Position = placement.position - result = append(result, ©) } return result, nil } @@ -263,19 +257,8 @@ func (r *MemoryRepository) ListTasksByColumn(ctx context.Context, columnID strin var result []*models.Task for _, task := range r.tasks { - placements, ok := r.taskPlacements[task.ID] - if !ok { - continue - } - for _, placement := range placements { - if placement.columnID != columnID { - continue - } - copy := *task - copy.BoardID = placement.boardID - copy.ColumnID = placement.columnID - copy.Position = placement.position - result = append(result, ©) + if task.ColumnID == columnID { + result = append(result, task) } } return result, nil @@ -300,9 +283,15 @@ func (r *MemoryRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.tasks[taskID]; !ok { + task, ok := r.tasks[taskID] + if !ok { return fmt.Errorf("task not found: %s", taskID) } + task.BoardID = boardID + task.ColumnID = columnID + task.Position = position + task.UpdatedAt = time.Now().UTC() + r.tasks[taskID] = task r.addTaskPlacement(taskID, boardID, columnID, position) return nil } @@ -312,9 +301,17 @@ func (r *MemoryRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boar r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.tasks[taskID]; !ok { + task, ok := r.tasks[taskID] + if !ok { return fmt.Errorf("task not found: %s", taskID) } + if task.BoardID == boardID { + task.BoardID = "" + task.ColumnID = "" + task.Position = 0 + task.UpdatedAt = time.Now().UTC() + r.tasks[taskID] = task + } if placements, ok := r.taskPlacements[taskID]; ok { delete(placements, boardID) } @@ -497,7 +494,7 @@ func (r *MemoryRepository) GetRepository(ctx context.Context, id string) (*model defer r.mu.RUnlock() repository, ok := r.repositories[id] - if !ok { + if !ok || repository.DeletedAt != nil { return nil, fmt.Errorf("repository not found: %s", id) } return repository, nil @@ -507,7 +504,7 @@ func (r *MemoryRepository) UpdateRepository(ctx context.Context, repository *mod r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.repositories[repository.ID]; !ok { + if existing, ok := r.repositories[repository.ID]; !ok || existing.DeletedAt != nil { return fmt.Errorf("repository not found: %s", repository.ID) } repository.UpdatedAt = time.Now().UTC() @@ -519,15 +516,14 @@ func (r *MemoryRepository) DeleteRepository(ctx context.Context, id string) erro r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.repositories[id]; !ok { + repository, ok := r.repositories[id] + if !ok || repository.DeletedAt != nil { return fmt.Errorf("repository not found: %s", id) } - delete(r.repositories, id) - for scriptID, script := range r.repoScripts { - if script.RepositoryID == id { - delete(r.repoScripts, scriptID) - } - } + now := time.Now().UTC() + repository.DeletedAt = &now + repository.UpdatedAt = now + r.repositories[id] = repository return nil } @@ -537,7 +533,7 @@ func (r *MemoryRepository) ListRepositories(ctx context.Context, workspaceID str result := make([]*models.Repository, 0) for _, repository := range r.repositories { - if repository.WorkspaceID == workspaceID { + if repository.WorkspaceID == workspaceID && repository.DeletedAt == nil { result = append(result, repository) } } @@ -623,6 +619,13 @@ func (r *MemoryRepository) addTaskPlacement(taskID, boardID, columnID string, po } } +func isSessionActive(status models.AgentSessionState) bool { + return status == models.AgentSessionStateCreated || + status == models.AgentSessionStateRunning || + status == models.AgentSessionStateWaitingForInput || + status == models.AgentSessionStateStarting +} + func (r *MemoryRepository) getTaskPlacement(taskID, boardID string) (*taskPlacement, bool) { placements, ok := r.taskPlacements[taskID] if !ok { @@ -632,83 +635,83 @@ func (r *MemoryRepository) getTaskPlacement(taskID, boardID string) (*taskPlacem return placement, ok } -// Comment operations +// Message operations -// CreateComment creates a new comment -func (r *MemoryRepository) CreateComment(ctx context.Context, comment *models.Comment) error { +// CreateMessage creates a new message +func (r *MemoryRepository) CreateMessage(ctx context.Context, message *models.Message) error { r.mu.Lock() defer r.mu.Unlock() - if comment.ID == "" { - comment.ID = uuid.New().String() + if message.ID == "" { + message.ID = uuid.New().String() } - if comment.CreatedAt.IsZero() { - comment.CreatedAt = time.Now().UTC() + if message.CreatedAt.IsZero() { + message.CreatedAt = time.Now().UTC() } - if comment.AuthorType == "" { - comment.AuthorType = models.CommentAuthorUser + if message.AuthorType == "" { + message.AuthorType = models.MessageAuthorUser } - if comment.Type == "" { - comment.Type = models.CommentTypeMessage + if message.Type == "" { + message.Type = models.MessageTypeMessage } - r.comments[comment.ID] = comment + r.messages[message.ID] = message return nil } -// GetComment retrieves a comment by ID -func (r *MemoryRepository) GetComment(ctx context.Context, id string) (*models.Comment, error) { +// GetMessage retrieves a message by ID +func (r *MemoryRepository) GetMessage(ctx context.Context, id string) (*models.Message, error) { r.mu.RLock() defer r.mu.RUnlock() - comment, ok := r.comments[id] + message, ok := r.messages[id] if !ok { - return nil, fmt.Errorf("comment not found: %s", id) + return nil, fmt.Errorf("message not found: %s", id) } - return comment, nil + return message, nil } -// GetCommentByToolCallID retrieves a comment by task ID and tool_call_id in metadata. -func (r *MemoryRepository) GetCommentByToolCallID(ctx context.Context, taskID, toolCallID string) (*models.Comment, error) { +// GetMessageByToolCallID retrieves a message by session ID and tool_call_id in metadata. +func (r *MemoryRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { r.mu.RLock() defer r.mu.RUnlock() - for _, comment := range r.comments { - if comment.TaskID != taskID || comment.Metadata == nil { + for _, message := range r.messages { + if message.AgentSessionID != sessionID || message.Metadata == nil { continue } - if value, ok := comment.Metadata["tool_call_id"]; ok { + if value, ok := message.Metadata["tool_call_id"]; ok { if toolCallIDValue, ok := value.(string); ok && toolCallIDValue == toolCallID { - return comment, nil + return message, nil } } } - return nil, fmt.Errorf("comment not found: %s", toolCallID) + return nil, fmt.Errorf("message not found: %s", toolCallID) } -// UpdateComment updates an existing comment. -func (r *MemoryRepository) UpdateComment(ctx context.Context, comment *models.Comment) error { +// UpdateMessage updates an existing message. +func (r *MemoryRepository) UpdateMessage(ctx context.Context, message *models.Message) error { r.mu.Lock() defer r.mu.Unlock() - existing, ok := r.comments[comment.ID] + existing, ok := r.messages[message.ID] if !ok { - return fmt.Errorf("comment not found: %s", comment.ID) + return fmt.Errorf("message not found: %s", message.ID) } - comment.CreatedAt = existing.CreatedAt - r.comments[comment.ID] = comment + message.CreatedAt = existing.CreatedAt + r.messages[message.ID] = message return nil } -// ListComments returns all comments for a task ordered by creation time. -func (r *MemoryRepository) ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) { +// ListMessages returns all messages for a session ordered by creation time. +func (r *MemoryRepository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { r.mu.RLock() defer r.mu.RUnlock() - var result []*models.Comment - for _, comment := range r.comments { - if comment.TaskID == taskID { - result = append(result, comment) + var result []*models.Message + for _, message := range r.messages { + if message.AgentSessionID == sessionID { + result = append(result, message) } } sort.Slice(result, func(i, j int) bool { @@ -720,8 +723,8 @@ func (r *MemoryRepository) ListComments(ctx context.Context, taskID string) ([]* return result, nil } -// ListCommentsPaginated returns comments for a task ordered by creation time with pagination. -func (r *MemoryRepository) ListCommentsPaginated(ctx context.Context, taskID string, opts ListCommentsOptions) ([]*models.Comment, bool, error) { +// ListMessagesPaginated returns messages for a session ordered by creation time with pagination. +func (r *MemoryRepository) ListMessagesPaginated(ctx context.Context, sessionID string, opts ListMessagesOptions) ([]*models.Message, bool, error) { limit := opts.Limit if limit < 0 { limit = 0 @@ -730,32 +733,32 @@ func (r *MemoryRepository) ListCommentsPaginated(ctx context.Context, taskID str r.mu.RLock() defer r.mu.RUnlock() - var cursor *models.Comment + var cursor *models.Message if opts.Before != "" { - comment, ok := r.comments[opts.Before] + message, ok := r.messages[opts.Before] if !ok { - return nil, false, fmt.Errorf("comment not found: %s", opts.Before) + return nil, false, fmt.Errorf("message not found: %s", opts.Before) } - if comment.TaskID != taskID { - return nil, false, fmt.Errorf("comment cursor not found: %s", opts.Before) + if message.AgentSessionID != sessionID { + return nil, false, fmt.Errorf("message cursor not found: %s", opts.Before) } - cursor = comment + cursor = message } if opts.After != "" { - comment, ok := r.comments[opts.After] + message, ok := r.messages[opts.After] if !ok { - return nil, false, fmt.Errorf("comment not found: %s", opts.After) + return nil, false, fmt.Errorf("message not found: %s", opts.After) } - if comment.TaskID != taskID { - return nil, false, fmt.Errorf("comment cursor not found: %s", opts.After) + if message.AgentSessionID != sessionID { + return nil, false, fmt.Errorf("message cursor not found: %s", opts.After) } - cursor = comment + cursor = message } - var result []*models.Comment - for _, comment := range r.comments { - if comment.TaskID == taskID { - result = append(result, comment) + var result []*models.Message + for _, message := range r.messages { + if message.AgentSessionID == sessionID { + result = append(result, message) } } sort.Slice(result, func(i, j int) bool { @@ -767,14 +770,14 @@ func (r *MemoryRepository) ListCommentsPaginated(ctx context.Context, taskID str if cursor != nil { filtered := result[:0] - for _, comment := range result { + for _, message := range result { if opts.Before != "" { - if comment.CreatedAt.Before(cursor.CreatedAt) || (comment.CreatedAt.Equal(cursor.CreatedAt) && comment.ID < cursor.ID) { - filtered = append(filtered, comment) + if message.CreatedAt.Before(cursor.CreatedAt) || (message.CreatedAt.Equal(cursor.CreatedAt) && message.ID < cursor.ID) { + filtered = append(filtered, message) } } else if opts.After != "" { - if comment.CreatedAt.After(cursor.CreatedAt) || (comment.CreatedAt.Equal(cursor.CreatedAt) && comment.ID > cursor.ID) { - filtered = append(filtered, comment) + if message.CreatedAt.After(cursor.CreatedAt) || (message.CreatedAt.Equal(cursor.CreatedAt) && message.ID > cursor.ID) { + filtered = append(filtered, message) } } } @@ -795,15 +798,15 @@ func (r *MemoryRepository) ListCommentsPaginated(ctx context.Context, taskID str return result, hasMore, nil } -// DeleteComment deletes a comment by ID -func (r *MemoryRepository) DeleteComment(ctx context.Context, id string) error { +// DeleteMessage deletes a message by ID +func (r *MemoryRepository) DeleteMessage(ctx context.Context, id string) error { r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.comments[id]; !ok { - return fmt.Errorf("comment not found: %s", id) + if _, ok := r.messages[id]; !ok { + return fmt.Errorf("message not found: %s", id) } - delete(r.comments, id) + delete(r.messages, id) return nil } @@ -821,6 +824,9 @@ func (r *MemoryRepository) CreateAgentSession(ctx context.Context, session *mode if session.StartedAt.IsZero() { session.StartedAt = now } + if session.State == "" { + session.State = models.AgentSessionStateCreated + } session.UpdatedAt = now r.agentSessions[session.ID] = session @@ -864,7 +870,7 @@ func (r *MemoryRepository) GetActiveAgentSessionByTaskID(ctx context.Context, ta 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) { + if session.TaskID == taskID && (session.State == models.AgentSessionStateCreated || session.State == models.AgentSessionStateStarting || session.State == models.AgentSessionStateRunning || session.State == models.AgentSessionStateWaitingForInput) { return session, nil } } @@ -884,8 +890,8 @@ func (r *MemoryRepository) UpdateAgentSession(ctx context.Context, session *mode 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 { +// UpdateAgentSessionState updates the status of an agent session +func (r *MemoryRepository) UpdateAgentSessionState(ctx context.Context, id string, status models.AgentSessionState, errorMessage string) error { r.mu.Lock() defer r.mu.Unlock() @@ -893,10 +899,10 @@ func (r *MemoryRepository) UpdateAgentSessionStatus(ctx context.Context, id stri if !ok { return fmt.Errorf("agent session not found: %s", id) } - session.Status = status + session.State = status session.ErrorMessage = errorMessage session.UpdatedAt = time.Now().UTC() - if status == models.AgentSessionStatusCompleted || status == models.AgentSessionStatusFailed || status == models.AgentSessionStatusStopped { + if status == models.AgentSessionStateCompleted || status == models.AgentSessionStateFailed || status == models.AgentSessionStateCancelled { now := time.Now().UTC() session.CompletedAt = &now } @@ -924,13 +930,68 @@ func (r *MemoryRepository) ListActiveAgentSessions(ctx context.Context) ([]*mode var result []*models.AgentSession for _, session := range r.agentSessions { - if session.Status == models.AgentSessionStatusRunning || session.Status == models.AgentSessionStatusWaiting || session.Status == models.AgentSessionStatusPending { + if session.State == models.AgentSessionStateCreated || session.State == models.AgentSessionStateStarting || session.State == models.AgentSessionStateRunning || session.State == models.AgentSessionStateWaitingForInput { result = append(result, session) } } return result, nil } +func (r *MemoryRepository) HasActiveAgentSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, session := range r.agentSessions { + if session.AgentProfileID == agentProfileID && isSessionActive(session.State) { + return true, nil + } + } + return false, nil +} + +func (r *MemoryRepository) HasActiveAgentSessionsByExecutor(ctx context.Context, executorID string) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, session := range r.agentSessions { + if session.ExecutorID == executorID && isSessionActive(session.State) { + return true, nil + } + } + return false, nil +} + +func (r *MemoryRepository) HasActiveAgentSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, session := range r.agentSessions { + if session.EnvironmentID == environmentID && isSessionActive(session.State) { + return true, nil + } + } + return false, nil +} + +func (r *MemoryRepository) HasActiveAgentSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, session := range r.agentSessions { + if !isSessionActive(session.State) { + continue + } + task, ok := r.tasks[session.TaskID] + if !ok { + continue + } + if task.RepositoryID == repositoryID { + return true, nil + } + } + return false, nil +} + // DeleteAgentSession deletes an agent session by ID func (r *MemoryRepository) DeleteAgentSession(ctx context.Context, id string) error { r.mu.Lock() @@ -965,7 +1026,7 @@ func (r *MemoryRepository) GetExecutor(ctx context.Context, id string) (*models. defer r.mu.RUnlock() executor, ok := r.executors[id] - if !ok { + if !ok || executor.DeletedAt != nil { return nil, fmt.Errorf("executor not found: %s", id) } return executor, nil @@ -975,7 +1036,7 @@ func (r *MemoryRepository) UpdateExecutor(ctx context.Context, executor *models. r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.executors[executor.ID]; !ok { + if existing, ok := r.executors[executor.ID]; !ok || existing.DeletedAt != nil { return fmt.Errorf("executor not found: %s", executor.ID) } executor.UpdatedAt = time.Now().UTC() @@ -987,10 +1048,14 @@ func (r *MemoryRepository) DeleteExecutor(ctx context.Context, id string) error r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.executors[id]; !ok { + executor, ok := r.executors[id] + if !ok || executor.DeletedAt != nil { return fmt.Errorf("executor not found: %s", id) } - delete(r.executors, id) + now := time.Now().UTC() + executor.DeletedAt = &now + executor.UpdatedAt = now + r.executors[id] = executor return nil } @@ -1000,7 +1065,9 @@ func (r *MemoryRepository) ListExecutors(ctx context.Context) ([]*models.Executo result := make([]*models.Executor, 0, len(r.executors)) for _, executor := range r.executors { - result = append(result, executor) + if executor.DeletedAt == nil { + result = append(result, executor) + } } return result, nil } @@ -1026,7 +1093,7 @@ func (r *MemoryRepository) GetEnvironment(ctx context.Context, id string) (*mode defer r.mu.RUnlock() environment, ok := r.environments[id] - if !ok { + if !ok || environment.DeletedAt != nil { return nil, fmt.Errorf("environment not found: %s", id) } return environment, nil @@ -1036,7 +1103,7 @@ func (r *MemoryRepository) UpdateEnvironment(ctx context.Context, environment *m r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.environments[environment.ID]; !ok { + if existing, ok := r.environments[environment.ID]; !ok || existing.DeletedAt != nil { return fmt.Errorf("environment not found: %s", environment.ID) } environment.UpdatedAt = time.Now().UTC() @@ -1048,10 +1115,14 @@ func (r *MemoryRepository) DeleteEnvironment(ctx context.Context, id string) err r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.environments[id]; !ok { + environment, ok := r.environments[id] + if !ok || environment.DeletedAt != nil { return fmt.Errorf("environment not found: %s", id) } - delete(r.environments, id) + now := time.Now().UTC() + environment.DeletedAt = &now + environment.UpdatedAt = now + r.environments[id] = environment return nil } @@ -1061,7 +1132,9 @@ func (r *MemoryRepository) ListEnvironments(ctx context.Context) ([]*models.Envi result := make([]*models.Environment, 0, len(r.environments)) for _, environment := range r.environments { - result = append(result, environment) + if environment.DeletedAt == nil { + result = append(result, environment) + } } return result, nil } diff --git a/apps/backend/internal/task/repository/memory_test.go b/apps/backend/internal/task/repository/memory_test.go index da1659aec9..db66623bb4 100644 --- a/apps/backend/internal/task/repository/memory_test.go +++ b/apps/backend/internal/task/repository/memory_test.go @@ -348,25 +348,38 @@ func TestMemoryRepository_ListTasksByColumn(t *testing.T) { } } -// Comment CRUD tests +// Message CRUD tests -func TestMemoryRepository_CommentCRUD(t *testing.T) { +func setupMemoryTestSession(ctx context.Context, repo *MemoryRepository, taskID string) string { + session := &models.AgentSession{ + ID: "session-123", + TaskID: taskID, + AgentProfileID: "profile-123", + State: models.AgentSessionStateStarting, + } + _ = repo.CreateAgentSession(ctx, session) + return session.ID +} + +func TestMemoryRepository_MessageCRUD(t *testing.T) { repo := NewMemoryRepository() ctx := context.Background() // Create a task first task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) + sessionID := setupMemoryTestSession(ctx, repo, task.ID) // Create comment - comment := &models.Comment{ - TaskID: "task-123", - AuthorType: models.CommentAuthorUser, - AuthorID: "user-123", - Content: "This is a test comment", - RequestsInput: false, + comment := &models.Message{ + AgentSessionID: sessionID, + TaskID: "task-123", + AuthorType: models.MessageAuthorUser, + AuthorID: "user-123", + Content: "This is a test comment", + RequestsInput: false, } - if err := repo.CreateComment(ctx, comment); err != nil { + if err := repo.CreateMessage(ctx, comment); err != nil { t.Fatalf("failed to create comment: %v", err) } if comment.ID == "" { @@ -377,56 +390,57 @@ func TestMemoryRepository_CommentCRUD(t *testing.T) { } // Get comment - retrieved, err := repo.GetComment(ctx, comment.ID) + retrieved, err := repo.GetMessage(ctx, comment.ID) if err != nil { t.Fatalf("failed to get comment: %v", err) } if retrieved.Content != "This is a test comment" { t.Errorf("expected content 'This is a test comment', got %s", retrieved.Content) } - if retrieved.AuthorType != models.CommentAuthorUser { + if retrieved.AuthorType != models.MessageAuthorUser { t.Errorf("expected author type 'user', got %s", retrieved.AuthorType) } // Delete comment - if err := repo.DeleteComment(ctx, comment.ID); err != nil { + if err := repo.DeleteMessage(ctx, comment.ID); err != nil { t.Fatalf("failed to delete comment: %v", err) } - _, err = repo.GetComment(ctx, comment.ID) + _, err = repo.GetMessage(ctx, comment.ID) if err == nil { t.Error("expected comment to be deleted") } } -func TestMemoryRepository_CommentNotFound(t *testing.T) { +func TestMemoryRepository_MessageNotFound(t *testing.T) { repo := NewMemoryRepository() ctx := context.Background() - _, err := repo.GetComment(ctx, "nonexistent") + _, err := repo.GetMessage(ctx, "nonexistent") if err == nil { t.Error("expected error for nonexistent comment") } - err = repo.DeleteComment(ctx, "nonexistent") + err = repo.DeleteMessage(ctx, "nonexistent") if err == nil { t.Error("expected error for deleting nonexistent comment") } } -func TestMemoryRepository_ListComments(t *testing.T) { +func TestMemoryRepository_ListMessages(t *testing.T) { repo := NewMemoryRepository() ctx := context.Background() // Create a task task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) + sessionID := setupMemoryTestSession(ctx, repo, task.ID) // Create multiple comments - _ = repo.CreateComment(ctx, &models.Comment{ID: "comment-1", TaskID: "task-123", AuthorType: models.CommentAuthorUser, Content: "Comment 1"}) - _ = repo.CreateComment(ctx, &models.Comment{ID: "comment-2", TaskID: "task-123", AuthorType: models.CommentAuthorAgent, Content: "Comment 2"}) - _ = repo.CreateComment(ctx, &models.Comment{ID: "comment-3", TaskID: "task-456", AuthorType: models.CommentAuthorUser, Content: "Comment 3"}) + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-1", AgentSessionID: sessionID, TaskID: "task-123", AuthorType: models.MessageAuthorUser, Content: "Comment 1"}) + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-2", AgentSessionID: sessionID, TaskID: "task-123", AuthorType: models.MessageAuthorAgent, Content: "Comment 2"}) + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-3", AgentSessionID: "session-456", TaskID: "task-456", AuthorType: models.MessageAuthorUser, Content: "Comment 3"}) - comments, err := repo.ListComments(ctx, "task-123") + comments, err := repo.ListMessages(ctx, sessionID) if err != nil { t.Fatalf("failed to list comments: %v", err) } @@ -435,37 +449,41 @@ func TestMemoryRepository_ListComments(t *testing.T) { } } -func TestMemoryRepository_ListCommentsPagination(t *testing.T) { +func TestMemoryRepository_ListMessagesPagination(t *testing.T) { repo := NewMemoryRepository() ctx := context.Background() task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) + sessionID := setupMemoryTestSession(ctx, repo, task.ID) baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - _ = repo.CreateComment(ctx, &models.Comment{ - ID: "comment-1", - TaskID: "task-123", - AuthorType: models.CommentAuthorUser, - Content: "Comment 1", - CreatedAt: baseTime.Add(-2 * time.Minute), + _ = repo.CreateMessage(ctx, &models.Message{ + ID: "comment-1", + AgentSessionID: sessionID, + TaskID: "task-123", + AuthorType: models.MessageAuthorUser, + Content: "Comment 1", + CreatedAt: baseTime.Add(-2 * time.Minute), }) - _ = repo.CreateComment(ctx, &models.Comment{ - ID: "comment-2", - TaskID: "task-123", - AuthorType: models.CommentAuthorUser, - Content: "Comment 2", - CreatedAt: baseTime.Add(-1 * time.Minute), + _ = repo.CreateMessage(ctx, &models.Message{ + ID: "comment-2", + AgentSessionID: sessionID, + TaskID: "task-123", + AuthorType: models.MessageAuthorUser, + Content: "Comment 2", + CreatedAt: baseTime.Add(-1 * time.Minute), }) - _ = repo.CreateComment(ctx, &models.Comment{ - ID: "comment-3", - TaskID: "task-123", - AuthorType: models.CommentAuthorUser, - Content: "Comment 3", - CreatedAt: baseTime, + _ = repo.CreateMessage(ctx, &models.Message{ + ID: "comment-3", + AgentSessionID: sessionID, + TaskID: "task-123", + AuthorType: models.MessageAuthorUser, + Content: "Comment 3", + CreatedAt: baseTime, }) - comments, hasMore, err := repo.ListCommentsPaginated(ctx, "task-123", ListCommentsOptions{ + comments, hasMore, err := repo.ListMessagesPaginated(ctx, sessionID, ListMessagesOptions{ Limit: 2, Sort: "desc", }) @@ -483,32 +501,30 @@ func TestMemoryRepository_ListCommentsPagination(t *testing.T) { } } -func TestMemoryRepository_CommentWithRequestsInput(t *testing.T) { +func TestMemoryRepository_MessageWithRequestsInput(t *testing.T) { repo := NewMemoryRepository() ctx := context.Background() // Create a task task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) + sessionID := setupMemoryTestSession(ctx, repo, task.ID) // Create agent comment requesting input - comment := &models.Comment{ - TaskID: "task-123", - AuthorType: models.CommentAuthorAgent, - AuthorID: "agent-123", - Content: "What should I do next?", - RequestsInput: true, - ACPSessionID: "session-abc", + comment := &models.Message{ + AgentSessionID: sessionID, + TaskID: "task-123", + AuthorType: models.MessageAuthorAgent, + AuthorID: "agent-123", + Content: "What should I do next?", + RequestsInput: true, } - if err := repo.CreateComment(ctx, comment); err != nil { + if err := repo.CreateMessage(ctx, comment); err != nil { t.Fatalf("failed to create comment: %v", err) } - retrieved, _ := repo.GetComment(ctx, comment.ID) + retrieved, _ := repo.GetMessage(ctx, comment.ID) if !retrieved.RequestsInput { t.Error("expected RequestsInput to be true") } - if retrieved.ACPSessionID != "session-abc" { - t.Errorf("expected ACPSessionID 'session-abc', got %s", retrieved.ACPSessionID) - } } diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index 5fa3e1c3f9..db61f2f7bd 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -105,7 +105,8 @@ func (r *SQLiteRepository) initSchema() error { is_system INTEGER NOT NULL DEFAULT 0, config TEXT DEFAULT '{}', created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + updated_at DATETIME NOT NULL, + deleted_at DATETIME ); CREATE TABLE IF NOT EXISTS environments ( @@ -118,7 +119,8 @@ func (r *SQLiteRepository) initSchema() error { dockerfile TEXT DEFAULT '', build_config TEXT DEFAULT '{}', created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + updated_at DATETIME NOT NULL, + deleted_at DATETIME ); CREATE TABLE IF NOT EXISTS boards ( @@ -151,9 +153,8 @@ func (r *SQLiteRepository) initSchema() error { description TEXT DEFAULT '', state TEXT DEFAULT 'TODO', priority INTEGER DEFAULT 0, - agent_profile_id TEXT DEFAULT '', - repository_url TEXT DEFAULT '', - branch TEXT DEFAULT '', + repository_id TEXT DEFAULT '', + base_branch TEXT DEFAULT '', assigned_to TEXT DEFAULT '', position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', @@ -178,6 +179,7 @@ func (r *SQLiteRepository) initSchema() error { cleanup_script TEXT DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, + deleted_at DATETIME, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE ); @@ -192,51 +194,29 @@ func (r *SQLiteRepository) initSchema() error { FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE ); - CREATE TABLE IF NOT EXISTS task_boards ( - task_id TEXT NOT NULL, - board_id TEXT NOT NULL, - PRIMARY KEY (task_id, board_id), - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, - FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS task_columns ( - task_id TEXT NOT NULL, - board_id TEXT NOT NULL, - column_id TEXT NOT NULL, - position INTEGER DEFAULT 0, - PRIMARY KEY (task_id, board_id), - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, - FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE, - FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_tasks_board_id ON tasks(board_id); CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id); CREATE INDEX IF NOT EXISTS idx_columns_board_id ON columns(board_id); - CREATE INDEX IF NOT EXISTS idx_task_boards_board_id ON task_boards(board_id); - CREATE INDEX IF NOT EXISTS idx_task_columns_board_id ON task_columns(board_id); - CREATE INDEX IF NOT EXISTS idx_task_columns_column_id ON task_columns(column_id); 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_comments ( + CREATE TABLE IF NOT EXISTS task_messages ( id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, + agent_session_id TEXT NOT NULL, + task_id TEXT DEFAULT '', author_type TEXT NOT NULL DEFAULT 'user', author_id TEXT DEFAULT '', 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 + FOREIGN KEY (agent_session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE ); - 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 INDEX IF NOT EXISTS idx_messages_session_id ON task_messages(agent_session_id); + CREATE INDEX IF NOT EXISTS idx_messages_created_at ON task_messages(created_at); + CREATE INDEX IF NOT EXISTS idx_messages_session_created ON task_messages(agent_session_id, created_at); CREATE TABLE IF NOT EXISTS agent_sessions ( id TEXT PRIMARY KEY, @@ -244,10 +224,18 @@ func (r *SQLiteRepository) initSchema() error { agent_instance_id TEXT NOT NULL DEFAULT '', container_id TEXT NOT NULL DEFAULT '', agent_profile_id TEXT NOT NULL, - acp_session_id TEXT DEFAULT '', executor_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', + repository_id TEXT DEFAULT '', + base_branch TEXT DEFAULT '', + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + agent_profile_snapshot TEXT DEFAULT '{}', + executor_snapshot TEXT DEFAULT '{}', + environment_snapshot TEXT DEFAULT '{}', + repository_snapshot TEXT DEFAULT '{}', + state TEXT NOT NULL DEFAULT 'CREATED', progress INTEGER DEFAULT 0, error_message TEXT DEFAULT '', metadata TEXT DEFAULT '{}', @@ -258,8 +246,8 @@ func (r *SQLiteRepository) initSchema() error { ); 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); + CREATE INDEX IF NOT EXISTS idx_agent_sessions_state ON agent_sessions(state); + CREATE INDEX IF NOT EXISTS idx_agent_sessions_task_state ON agent_sessions(task_id, state); ` if _, err := r.db.Exec(schema); err != nil { @@ -287,15 +275,24 @@ func (r *SQLiteRepository) initSchema() error { if err := r.ensureColumn("environments", "is_system", "INTEGER NOT NULL DEFAULT 0"); err != nil { return err } + if err := r.ensureColumn("repositories", "deleted_at", "DATETIME"); err != nil { + return err + } + if err := r.ensureColumn("executors", "deleted_at", "DATETIME"); err != nil { + return err + } + if err := r.ensureColumn("environments", "deleted_at", "DATETIME"); err != nil { + return err + } - // Ensure new comment columns exist for existing databases - if err := r.ensureColumn("task_comments", "type", "TEXT NOT NULL DEFAULT 'message'"); err != nil { + // Ensure new message columns exist for existing databases + if err := r.ensureColumn("task_messages", "type", "TEXT NOT NULL DEFAULT 'message'"); err != nil { return err } - if err := r.ensureColumn("task_comments", "metadata", "TEXT DEFAULT '{}'"); err != nil { + if err := r.ensureColumn("task_messages", "metadata", "TEXT DEFAULT '{}'"); err != nil { return err } - if err := r.ensureColumn("task_comments", "agent_session_id", "TEXT DEFAULT ''"); err != nil { + if err := r.ensureColumn("task_messages", "agent_session_id", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } @@ -309,6 +306,9 @@ func (r *SQLiteRepository) initSchema() error { if err := r.ensureColumn("agent_sessions", "environment_id", "TEXT DEFAULT ''"); err != nil { return err } + if err := r.ensureColumn("agent_sessions", "state", "TEXT NOT NULL DEFAULT 'CREATED'"); err != nil { + return err + } if err := r.ensureDefaultWorkspace(); err != nil { return err @@ -318,10 +318,6 @@ func (r *SQLiteRepository) initSchema() error { return err } - if err := r.backfillTaskMappings(); err != nil { - return err - } - if err := r.ensureWorkspaceIndexes(); err != nil { return err } @@ -794,21 +790,14 @@ func (r *SQLiteRepository) CreateTask(ctx context.Context, task *models.Task) er } _, err = tx.ExecContext(ctx, ` - INSERT INTO tasks (id, workspace_id, board_id, column_id, title, description, state, priority, agent_profile_id, repository_url, branch, assigned_to, position, metadata, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, task.ID, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentProfileID, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt) + INSERT INTO tasks (id, workspace_id, board_id, column_id, title, description, state, priority, repository_id, base_branch, assigned_to, position, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, task.ID, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.RepositoryID, task.BaseBranch, task.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt) if err != nil { tx.Rollback() return err } - if task.BoardID != "" { - if err := r.addTaskToBoardTx(ctx, tx, task.ID, task.BoardID, task.ColumnID, task.Position); err != nil { - tx.Rollback() - return err - } - } - return tx.Commit() } @@ -816,12 +805,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 agentProfileID, repositoryURL, branch, assignedTo sql.NullString + var repositoryID, baseBranch, assignedTo sql.NullString err := r.db.QueryRowContext(ctx, ` - SELECT id, workspace_id, board_id, column_id, title, description, state, priority, agent_profile_id, repository_url, branch, assigned_to, position, metadata, created_at, updated_at + SELECT id, workspace_id, board_id, column_id, title, description, state, priority, repository_id, base_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, &agentProfileID, &repositoryURL, &branch, &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, &repositoryID, &baseBranch, &assignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("task not found: %s", id) @@ -830,9 +819,8 @@ func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task return nil, err } - task.AgentProfileID = agentProfileID.String - task.RepositoryURL = repositoryURL.String - task.Branch = branch.String + task.RepositoryID = repositoryID.String + task.BaseBranch = baseBranch.String task.AssignedTo = assignedTo.String _ = json.Unmarshal([]byte(metadata), &task.Metadata) return task, nil @@ -848,9 +836,9 @@ func (r *SQLiteRepository) UpdateTask(ctx context.Context, task *models.Task) er } result, err := r.db.ExecContext(ctx, ` - UPDATE tasks SET workspace_id = ?, board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, agent_profile_id = ?, repository_url = ?, branch = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ? + UPDATE tasks SET workspace_id = ?, board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, repository_id = ?, base_branch = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ? WHERE id = ? - `, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentProfileID, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID) + `, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.RepositoryID, task.BaseBranch, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID) if err != nil { return err } @@ -859,12 +847,6 @@ func (r *SQLiteRepository) UpdateTask(ctx context.Context, task *models.Task) er if rows == 0 { return fmt.Errorf("task not found: %s", task.ID) } - if task.BoardID != "" { - if err := r.AddTaskToBoard(ctx, task.ID, task.BoardID, task.ColumnID, task.Position); err != nil { - return err - } - } - return nil } @@ -885,70 +867,62 @@ func (r *SQLiteRepository) DeleteTask(ctx context.Context, id string) error { // ListTasks returns all tasks for a board func (r *SQLiteRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT t.id, t.workspace_id, t.title, t.description, t.state, t.priority, t.agent_profile_id, t.repository_url, t.branch, t.assigned_to, t.metadata, t.created_at, t.updated_at, - tc.board_id, tc.column_id, tc.position - FROM tasks t - INNER JOIN task_columns tc ON tc.task_id = t.id - WHERE tc.board_id = ? - ORDER BY tc.position + SELECT id, workspace_id, board_id, column_id, title, description, state, priority, repository_id, base_branch, assigned_to, position, metadata, created_at, updated_at + FROM tasks + WHERE board_id = ? + ORDER BY position `, boardID) if err != nil { return nil, err } defer rows.Close() - return r.scanTasksWithPlacement(rows) + return r.scanTasks(rows) } // ListTasksByColumn returns all tasks in a column func (r *SQLiteRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT t.id, t.workspace_id, t.title, t.description, t.state, t.priority, t.agent_profile_id, t.repository_url, t.branch, t.assigned_to, t.metadata, t.created_at, t.updated_at, - tc.board_id, tc.column_id, tc.position - FROM tasks t - INNER JOIN task_columns tc ON tc.task_id = t.id - WHERE tc.column_id = ? ORDER BY tc.position + SELECT id, workspace_id, board_id, column_id, title, description, state, priority, repository_id, base_branch, assigned_to, position, metadata, created_at, updated_at + FROM tasks + WHERE column_id = ? ORDER BY position `, columnID) if err != nil { return nil, err } defer rows.Close() - return r.scanTasksWithPlacement(rows) + return r.scanTasks(rows) } - - -func (r *SQLiteRepository) scanTasksWithPlacement(rows *sql.Rows) ([]*models.Task, error) { +func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { var result []*models.Task for rows.Next() { task := &models.Task{} var metadata string - var agentProfileID, repositoryURL, branch, assignedTo sql.NullString + var repositoryID, baseBranch, assignedTo sql.NullString err := rows.Scan( &task.ID, &task.WorkspaceID, + &task.BoardID, + &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, - &agentProfileID, - &repositoryURL, - &branch, + &repositoryID, + &baseBranch, &assignedTo, + &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt, - &task.BoardID, - &task.ColumnID, - &task.Position, ) if err != nil { return nil, err } - task.AgentProfileID = agentProfileID.String - task.RepositoryURL = repositoryURL.String - task.Branch = branch.String + task.RepositoryID = repositoryID.String + task.BaseBranch = baseBranch.String task.AssignedTo = assignedTo.String _ = json.Unmarshal([]byte(metadata), &task.Metadata) result = append(result, task) @@ -973,54 +947,23 @@ func (r *SQLiteRepository) UpdateTaskState(ctx context.Context, id string, state // AddTaskToBoard adds a task to a board with placement func (r *SQLiteRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error { _, err := r.db.ExecContext(ctx, ` - INSERT OR IGNORE INTO task_boards (task_id, board_id) VALUES (?, ?) - `, taskID, boardID) - if err != nil { - return err - } - _, err = r.db.ExecContext(ctx, ` - INSERT INTO task_columns (task_id, board_id, column_id, position) - VALUES (?, ?, ?, ?) - ON CONFLICT(task_id, board_id) DO UPDATE SET column_id = excluded.column_id, position = excluded.position - `, taskID, boardID, columnID, position) - if err != nil { - return err - } - // Also update the column_id in the tasks table so GetTask returns the correct column - _, err = r.db.ExecContext(ctx, ` - UPDATE tasks SET column_id = ?, position = ?, updated_at = ? WHERE id = ? - `, columnID, position, time.Now().UTC(), taskID) + UPDATE tasks SET board_id = ?, column_id = ?, position = ?, updated_at = ? WHERE id = ? + `, boardID, columnID, position, time.Now().UTC(), taskID) return err } // RemoveTaskFromBoard removes a task from a board func (r *SQLiteRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error { - if _, err := r.db.ExecContext(ctx, `DELETE FROM task_columns WHERE task_id = ? AND board_id = ?`, taskID, boardID); err != nil { - return err - } - _, err := r.db.ExecContext(ctx, `DELETE FROM task_boards WHERE task_id = ? AND board_id = ?`, taskID, boardID) + _, err := r.db.ExecContext(ctx, ` + UPDATE tasks SET board_id = '', column_id = '', position = 0, updated_at = ? WHERE id = ? AND board_id = ? + `, time.Now().UTC(), taskID, boardID) return err } func (r *SQLiteRepository) addTaskToBoardTx(ctx context.Context, tx *sql.Tx, taskID, boardID, columnID string, position int) error { - if _, err := tx.ExecContext(ctx, ` - INSERT OR IGNORE INTO task_boards (task_id, board_id) VALUES (?, ?) - `, taskID, boardID); err != nil { - return err - } - - if _, err := tx.ExecContext(ctx, ` - INSERT INTO task_columns (task_id, board_id, column_id, position) - VALUES (?, ?, ?, ?) - ON CONFLICT(task_id, board_id) DO UPDATE SET column_id = excluded.column_id, position = excluded.position - `, taskID, boardID, columnID, position); err != nil { - return err - } - - // Also update the column_id in the tasks table so GetTask returns the correct column _, err := tx.ExecContext(ctx, ` - UPDATE tasks SET column_id = ?, position = ?, updated_at = ? WHERE id = ? - `, columnID, position, time.Now().UTC(), taskID) + UPDATE tasks SET board_id = ?, column_id = ?, position = ?, updated_at = ? WHERE id = ? + `, boardID, columnID, position, time.Now().UTC(), taskID) return err } @@ -1224,107 +1167,103 @@ func (r *SQLiteRepository) ListColumns(ctx context.Context, boardID string) ([]* return result, rows.Err() } -// Comment operations +// Message operations -// CreateComment creates a new comment -func (r *SQLiteRepository) CreateComment(ctx context.Context, comment *models.Comment) error { - if comment.ID == "" { - comment.ID = uuid.New().String() +// CreateMessage creates a new message +func (r *SQLiteRepository) CreateMessage(ctx context.Context, message *models.Message) error { + if message.ID == "" { + message.ID = uuid.New().String() } - if comment.CreatedAt.IsZero() { - comment.CreatedAt = time.Now().UTC() + if message.CreatedAt.IsZero() { + message.CreatedAt = time.Now().UTC() } - if comment.AuthorType == "" { - comment.AuthorType = models.CommentAuthorUser + if message.AuthorType == "" { + message.AuthorType = models.MessageAuthorUser } requestsInput := 0 - if comment.RequestsInput { + if message.RequestsInput { requestsInput = 1 } - // Default type to "message" if empty - commentType := string(comment.Type) - if commentType == "" { - commentType = string(models.CommentTypeMessage) + messageType := string(message.Type) + if messageType == "" { + messageType = string(models.MessageTypeMessage) } - // Serialize metadata to JSON metadataJSON := "{}" - if comment.Metadata != nil { - metadataBytes, err := json.Marshal(comment.Metadata) + if message.Metadata != nil { + metadataBytes, err := json.Marshal(message.Metadata) if err != nil { - return fmt.Errorf("failed to serialize comment metadata: %w", err) + return fmt.Errorf("failed to serialize message 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, type, metadata, created_at) + INSERT INTO task_messages (id, agent_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, comment.ID, comment.TaskID, comment.AuthorType, comment.AuthorID, comment.Content, requestsInput, comment.ACPSessionID, commentType, metadataJSON, comment.CreatedAt) + `, message.ID, message.AgentSessionID, message.TaskID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt) return err } -// GetComment retrieves a comment by ID -func (r *SQLiteRepository) GetComment(ctx context.Context, id string) (*models.Comment, error) { - comment := &models.Comment{} +// GetMessage retrieves a message by ID +func (r *SQLiteRepository) GetMessage(ctx context.Context, id string) (*models.Message, error) { + message := &models.Message{} var requestsInput int - var commentType string + var messageType string var metadataJSON string err := r.db.QueryRowContext(ctx, ` - 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, &commentType, &metadataJSON, &comment.CreatedAt) + SELECT id, agent_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at + FROM task_messages WHERE id = ? + `, id).Scan(&message.ID, &message.AgentSessionID, &message.TaskID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { return nil, err } - comment.RequestsInput = requestsInput == 1 - comment.Type = models.CommentType(commentType) + message.RequestsInput = requestsInput == 1 + message.Type = models.MessageType(messageType) - // 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) + if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize message metadata: %w", err) } } - return comment, nil + return message, nil } -// ListComments returns all comments for a task ordered by creation time. -func (r *SQLiteRepository) ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) { +// ListMessages returns all messages for a session ordered by creation time. +func (r *SQLiteRepository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { rows, err := r.db.QueryContext(ctx, ` - 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) + SELECT id, agent_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at + FROM task_messages WHERE agent_session_id = ? ORDER BY created_at ASC + `, sessionID) if err != nil { return nil, err } defer rows.Close() - var result []*models.Comment + var result []*models.Message for rows.Next() { - comment := &models.Comment{} + message := &models.Message{} var requestsInput int - var commentType string + var messageType string var metadataJSON string - err := rows.Scan(&comment.ID, &comment.TaskID, &comment.AuthorType, &comment.AuthorID, &comment.Content, &requestsInput, &comment.ACPSessionID, &commentType, &metadataJSON, &comment.CreatedAt) + err := rows.Scan(&message.ID, &message.AgentSessionID, &message.TaskID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { return nil, err } - comment.RequestsInput = requestsInput == 1 - comment.Type = models.CommentType(commentType) + message.RequestsInput = requestsInput == 1 + message.Type = models.MessageType(messageType) - // 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) + if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize message metadata: %w", err) } } - result = append(result, comment) + result = append(result, message) } if err := rows.Err(); err != nil { return nil, err @@ -1332,8 +1271,8 @@ func (r *SQLiteRepository) ListComments(ctx context.Context, taskID string) ([]* return result, nil } -// ListCommentsPaginated returns comments for a task ordered by creation time with pagination. -func (r *SQLiteRepository) ListCommentsPaginated(ctx context.Context, taskID string, opts ListCommentsOptions) ([]*models.Comment, bool, error) { +// ListMessagesPaginated returns messages for a session ordered by creation time with pagination. +func (r *SQLiteRepository) ListMessagesPaginated(ctx context.Context, sessionID string, opts ListMessagesOptions) ([]*models.Message, bool, error) { limit := opts.Limit if limit < 0 { limit = 0 @@ -1344,32 +1283,32 @@ func (r *SQLiteRepository) ListCommentsPaginated(ctx context.Context, taskID str sortDir = "DESC" } - var cursor *models.Comment + var cursor *models.Message if opts.Before != "" { var err error - cursor, err = r.GetComment(ctx, opts.Before) + cursor, err = r.GetMessage(ctx, opts.Before) if err != nil { return nil, false, err } - if cursor.TaskID != taskID { - return nil, false, fmt.Errorf("comment cursor not found: %s", opts.Before) + if cursor.AgentSessionID != sessionID { + return nil, false, fmt.Errorf("message cursor not found: %s", opts.Before) } } if opts.After != "" { var err error - cursor, err = r.GetComment(ctx, opts.After) + cursor, err = r.GetMessage(ctx, opts.After) if err != nil { return nil, false, err } - if cursor.TaskID != taskID { - return nil, false, fmt.Errorf("comment cursor not found: %s", opts.After) + if cursor.AgentSessionID != sessionID { + return nil, false, fmt.Errorf("message cursor not found: %s", opts.After) } } query := ` - SELECT id, task_id, author_type, author_id, content, requests_input, acp_session_id, type, metadata, created_at - FROM task_comments WHERE task_id = ?` - args := []interface{}{taskID} + SELECT id, agent_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at + FROM task_messages WHERE agent_session_id = ?` + args := []interface{}{sessionID} if cursor != nil { if opts.Before != "" { query += " AND (created_at < ? OR (created_at = ? AND id < ?))" @@ -1390,27 +1329,26 @@ func (r *SQLiteRepository) ListCommentsPaginated(ctx context.Context, taskID str } defer rows.Close() - var result []*models.Comment + var result []*models.Message for rows.Next() { - comment := &models.Comment{} + message := &models.Message{} var requestsInput int - var commentType string + var messageType string var metadataJSON string - err := rows.Scan(&comment.ID, &comment.TaskID, &comment.AuthorType, &comment.AuthorID, &comment.Content, &requestsInput, &comment.ACPSessionID, &commentType, &metadataJSON, &comment.CreatedAt) + err := rows.Scan(&message.ID, &message.AgentSessionID, &message.TaskID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { return nil, false, err } - comment.RequestsInput = requestsInput == 1 - comment.Type = models.CommentType(commentType) + message.RequestsInput = requestsInput == 1 + message.Type = models.MessageType(messageType) - // Deserialize metadata from JSON if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &comment.Metadata); err != nil { - return nil, false, fmt.Errorf("failed to deserialize comment metadata: %w", err) + if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil { + return nil, false, fmt.Errorf("failed to deserialize message metadata: %w", err) } } - result = append(result, comment) + result = append(result, message) } if err := rows.Err(); err != nil { return nil, false, err @@ -1423,65 +1361,65 @@ func (r *SQLiteRepository) ListCommentsPaginated(ctx context.Context, taskID str return result, hasMore, nil } -// DeleteComment deletes a comment by ID -func (r *SQLiteRepository) DeleteComment(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM task_comments WHERE id = ?`, id) +// DeleteMessage deletes a message by ID +func (r *SQLiteRepository) DeleteMessage(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM task_messages WHERE id = ?`, id) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { - return fmt.Errorf("comment not found: %s", id) + return fmt.Errorf("message not found: %s", id) } return nil } -// GetCommentByToolCallID retrieves a comment by task ID and tool_call_id in metadata -func (r *SQLiteRepository) GetCommentByToolCallID(ctx context.Context, taskID, toolCallID string) (*models.Comment, error) { - comment := &models.Comment{} +// GetMessageByToolCallID retrieves a message by session ID and tool_call_id in metadata +func (r *SQLiteRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { + message := &models.Message{} var requestsInput int - var commentType string + var messageType string var metadataJSON string err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, author_type, author_id, content, requests_input, acp_session_id, type, metadata, created_at - FROM task_comments WHERE task_id = ? AND json_extract(metadata, '$.tool_call_id') = ? - `, taskID, toolCallID).Scan(&comment.ID, &comment.TaskID, &comment.AuthorType, &comment.AuthorID, - &comment.Content, &requestsInput, &comment.ACPSessionID, &commentType, &metadataJSON, &comment.CreatedAt) + SELECT id, agent_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at + FROM task_messages WHERE agent_session_id = ? AND json_extract(metadata, '$.tool_call_id') = ? + `, sessionID, toolCallID).Scan(&message.ID, &message.AgentSessionID, &message.TaskID, &message.AuthorType, &message.AuthorID, + &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { return nil, err } - comment.RequestsInput = requestsInput == 1 - comment.Type = models.CommentType(commentType) + message.RequestsInput = requestsInput == 1 + message.Type = models.MessageType(messageType) if metadataJSON != "" { - _ = json.Unmarshal([]byte(metadataJSON), &comment.Metadata) + _ = json.Unmarshal([]byte(metadataJSON), &message.Metadata) } - return comment, nil + return message, nil } -// UpdateComment updates an existing comment -func (r *SQLiteRepository) UpdateComment(ctx context.Context, comment *models.Comment) error { - metadataJSON, err := json.Marshal(comment.Metadata) +// UpdateMessage updates an existing message +func (r *SQLiteRepository) UpdateMessage(ctx context.Context, message *models.Message) error { + metadataJSON, err := json.Marshal(message.Metadata) if err != nil { return fmt.Errorf("failed to marshal metadata: %w", err) } requestsInput := 0 - if comment.RequestsInput { + if message.RequestsInput { requestsInput = 1 } result, err := r.db.ExecContext(ctx, ` - UPDATE task_comments SET content = ?, requests_input = ?, type = ?, metadata = ? + UPDATE task_messages SET content = ?, requests_input = ?, type = ?, metadata = ? WHERE id = ? - `, comment.Content, requestsInput, string(comment.Type), string(metadataJSON), comment.ID) + `, message.Content, requestsInput, string(message.Type), string(metadataJSON), message.ID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { - return fmt.Errorf("comment not found: %s", comment.ID) + return fmt.Errorf("message not found: %s", message.ID) } return nil } @@ -1500,11 +1438,11 @@ func (r *SQLiteRepository) CreateRepository(ctx context.Context, repository *mod _, err := r.db.ExecContext(ctx, ` INSERT INTO repositories ( id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, setup_script, cleanup_script, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + provider_name, default_branch, setup_script, cleanup_script, created_at, updated_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, - repository.SetupScript, repository.CleanupScript, repository.CreatedAt, repository.UpdatedAt) + repository.SetupScript, repository.CleanupScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) return err } @@ -1515,12 +1453,12 @@ func (r *SQLiteRepository) GetRepository(ctx context.Context, id string) (*model err := r.db.QueryRowContext(ctx, ` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, setup_script, cleanup_script, created_at, updated_at - FROM repositories WHERE id = ? + provider_name, default_branch, setup_script, cleanup_script, created_at, updated_at, deleted_at + FROM repositories WHERE id = ? AND deleted_at IS NULL `, id).Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, - &repository.DefaultBranch, &repository.SetupScript, &repository.CleanupScript, &repository.CreatedAt, &repository.UpdatedAt, + &repository.DefaultBranch, &repository.SetupScript, &repository.CleanupScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err == sql.ErrNoRows { @@ -1537,7 +1475,7 @@ func (r *SQLiteRepository) UpdateRepository(ctx context.Context, repository *mod UPDATE repositories SET name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?, provider_name = ?, default_branch = ?, setup_script = ?, cleanup_script = ?, updated_at = ? - WHERE id = ? + WHERE id = ? AND deleted_at IS NULL `, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.SetupScript, repository.CleanupScript, repository.UpdatedAt, repository.ID) @@ -1554,7 +1492,10 @@ func (r *SQLiteRepository) UpdateRepository(ctx context.Context, repository *mod // DeleteRepository deletes a repository by ID func (r *SQLiteRepository) DeleteRepository(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM repositories WHERE id = ?`, id) + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, ` + UPDATE repositories SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL + `, now, now, id) if err != nil { return err } @@ -1569,8 +1510,8 @@ func (r *SQLiteRepository) DeleteRepository(ctx context.Context, id string) erro func (r *SQLiteRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, setup_script, cleanup_script, created_at, updated_at - FROM repositories WHERE workspace_id = ? ORDER BY created_at DESC + provider_name, default_branch, setup_script, cleanup_script, created_at, updated_at, deleted_at + FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC `, workspaceID) if err != nil { return nil, err @@ -1583,7 +1524,7 @@ func (r *SQLiteRepository) ListRepositories(ctx context.Context, workspaceID str err := rows.Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, - &repository.DefaultBranch, &repository.SetupScript, &repository.CleanupScript, &repository.CreatedAt, &repository.UpdatedAt, + &repository.DefaultBranch, &repository.SetupScript, &repository.CleanupScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err != nil { return nil, err @@ -1687,19 +1628,42 @@ func (r *SQLiteRepository) CreateAgentSession(ctx context.Context, session *mode now := time.Now().UTC() session.StartedAt = now session.UpdatedAt = now + if session.State == "" { + session.State = models.AgentSessionStateCreated + } metadataJSON, err := json.Marshal(session.Metadata) if err != nil { return fmt.Errorf("failed to serialize agent session metadata: %w", err) } + agentProfileSnapshotJSON, err := json.Marshal(session.AgentProfileSnapshot) + if err != nil { + return fmt.Errorf("failed to serialize agent profile snapshot: %w", err) + } + executorSnapshotJSON, err := json.Marshal(session.ExecutorSnapshot) + if err != nil { + return fmt.Errorf("failed to serialize executor snapshot: %w", err) + } + environmentSnapshotJSON, err := json.Marshal(session.EnvironmentSnapshot) + if err != nil { + return fmt.Errorf("failed to serialize environment snapshot: %w", err) + } + repositorySnapshotJSON, err := json.Marshal(session.RepositorySnapshot) + if err != nil { + return fmt.Errorf("failed to serialize repository snapshot: %w", err) + } _, err = r.db.ExecContext(ctx, ` INSERT INTO agent_sessions ( - id, task_id, agent_instance_id, container_id, agent_profile_id, acp_session_id, executor_id, environment_id, - status, progress, error_message, metadata, started_at, completed_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, session.ID, session.TaskID, session.AgentInstanceID, session.ContainerID, session.AgentProfileID, session.ACPSessionID, - session.ExecutorID, session.EnvironmentID, string(session.Status), session.Progress, session.ErrorMessage, string(metadataJSON), + id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + repository_id, base_branch, worktree_id, worktree_path, worktree_branch, + agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, + state, progress, error_message, metadata, started_at, completed_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, session.ID, session.TaskID, session.AgentInstanceID, session.ContainerID, session.AgentProfileID, + session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.WorktreeID, session.WorktreePath, session.WorktreeBranch, + string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), + string(session.State), session.Progress, session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt) return err @@ -1708,18 +1672,26 @@ func (r *SQLiteRepository) CreateAgentSession(ctx context.Context, session *mode // 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 state string var metadataJSON string + var agentProfileSnapshotJSON string + var executorSnapshotJSON string + var environmentSnapshotJSON string + var repositorySnapshotJSON string var completedAt sql.NullTime err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, acp_session_id, executor_id, environment_id, - status, progress, error_message, metadata, started_at, completed_at, updated_at + SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + repository_id, base_branch, worktree_id, worktree_path, worktree_branch, + agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, + state, progress, error_message, metadata, started_at, completed_at, updated_at FROM agent_sessions WHERE id = ? `, id).Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, - &session.ACPSessionID, &session.ExecutorID, &session.EnvironmentID, &status, &session.Progress, &session.ErrorMessage, - &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, + &session.ExecutorID, &session.EnvironmentID, + &session.RepositoryID, &session.BaseBranch, &session.WorktreeID, &session.WorktreePath, &session.WorktreeBranch, + &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, + &state, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) if err == sql.ErrNoRows { @@ -1729,7 +1701,7 @@ func (r *SQLiteRepository) GetAgentSession(ctx context.Context, id string) (*mod return nil, err } - session.Status = models.AgentSessionStatus(status) + session.State = models.AgentSessionState(state) if completedAt.Valid { session.CompletedAt = &completedAt.Time } @@ -1738,6 +1710,26 @@ func (r *SQLiteRepository) GetAgentSession(ctx context.Context, id string) (*mod return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) } } + if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err) + } + } + if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err) + } + } + if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err) + } + } + if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err) + } + } return session, nil } @@ -1745,18 +1737,26 @@ func (r *SQLiteRepository) GetAgentSession(ctx context.Context, id string) (*mod // 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 state string var metadataJSON string + var agentProfileSnapshotJSON string + var executorSnapshotJSON string + var environmentSnapshotJSON string + var repositorySnapshotJSON string var completedAt sql.NullTime err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, acp_session_id, executor_id, environment_id, - status, progress, error_message, metadata, started_at, completed_at, updated_at + SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + repository_id, base_branch, worktree_id, worktree_path, worktree_branch, + agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, + state, 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.ContainerID, &session.AgentProfileID, - &session.ACPSessionID, &session.ExecutorID, &session.EnvironmentID, &status, &session.Progress, &session.ErrorMessage, - &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, + &session.ExecutorID, &session.EnvironmentID, + &session.RepositoryID, &session.BaseBranch, &session.WorktreeID, &session.WorktreePath, &session.WorktreeBranch, + &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, + &state, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) if err == sql.ErrNoRows { @@ -1766,7 +1766,7 @@ func (r *SQLiteRepository) GetAgentSessionByTaskID(ctx context.Context, taskID s return nil, err } - session.Status = models.AgentSessionStatus(status) + session.State = models.AgentSessionState(state) if completedAt.Valid { session.CompletedAt = &completedAt.Time } @@ -1775,6 +1775,26 @@ func (r *SQLiteRepository) GetAgentSessionByTaskID(ctx context.Context, taskID s return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) } } + if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err) + } + } + if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err) + } + } + if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err) + } + } + if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err) + } + } return session, nil } @@ -1782,20 +1802,28 @@ func (r *SQLiteRepository) GetAgentSessionByTaskID(ctx context.Context, taskID s // 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 state string var metadataJSON string + var agentProfileSnapshotJSON string + var executorSnapshotJSON string + var environmentSnapshotJSON string + var repositorySnapshotJSON string var completedAt sql.NullTime err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, acp_session_id, executor_id, environment_id, - status, progress, error_message, metadata, started_at, completed_at, updated_at + SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + repository_id, base_branch, worktree_id, worktree_path, worktree_branch, + agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, + state, progress, error_message, metadata, started_at, completed_at, updated_at FROM agent_sessions - WHERE task_id = ? AND status IN ('pending', 'running', 'waiting') + WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC LIMIT 1 `, taskID).Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, - &session.ACPSessionID, &session.ExecutorID, &session.EnvironmentID, &status, &session.Progress, &session.ErrorMessage, - &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, + &session.ExecutorID, &session.EnvironmentID, + &session.RepositoryID, &session.BaseBranch, &session.WorktreeID, &session.WorktreePath, &session.WorktreeBranch, + &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, + &state, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) if err == sql.ErrNoRows { @@ -1805,7 +1833,7 @@ func (r *SQLiteRepository) GetActiveAgentSessionByTaskID(ctx context.Context, ta return nil, err } - session.Status = models.AgentSessionStatus(status) + session.State = models.AgentSessionState(state) if completedAt.Valid { session.CompletedAt = &completedAt.Time } @@ -1814,6 +1842,26 @@ func (r *SQLiteRepository) GetActiveAgentSessionByTaskID(ctx context.Context, ta return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) } } + if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err) + } + } + if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err) + } + } + if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err) + } + } + if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err) + } + } return session, nil } @@ -1826,14 +1874,34 @@ func (r *SQLiteRepository) UpdateAgentSession(ctx context.Context, session *mode if err != nil { return fmt.Errorf("failed to serialize agent session metadata: %w", err) } + agentProfileSnapshotJSON, err := json.Marshal(session.AgentProfileSnapshot) + if err != nil { + return fmt.Errorf("failed to serialize agent profile snapshot: %w", err) + } + executorSnapshotJSON, err := json.Marshal(session.ExecutorSnapshot) + if err != nil { + return fmt.Errorf("failed to serialize executor snapshot: %w", err) + } + environmentSnapshotJSON, err := json.Marshal(session.EnvironmentSnapshot) + if err != nil { + return fmt.Errorf("failed to serialize environment snapshot: %w", err) + } + repositorySnapshotJSON, err := json.Marshal(session.RepositorySnapshot) + if err != nil { + return fmt.Errorf("failed to serialize repository snapshot: %w", err) + } result, err := r.db.ExecContext(ctx, ` UPDATE agent_sessions SET - agent_instance_id = ?, container_id = ?, agent_profile_id = ?, acp_session_id = ?, executor_id = ?, environment_id = ?, - status = ?, progress = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ? + agent_instance_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, environment_id = ?, + repository_id = ?, base_branch = ?, worktree_id = ?, worktree_path = ?, worktree_branch = ?, + agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?, + state = ?, progress = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ? WHERE id = ? - `, session.AgentInstanceID, session.ContainerID, session.AgentProfileID, session.ACPSessionID, session.ExecutorID, session.EnvironmentID, - string(session.Status), session.Progress, session.ErrorMessage, string(metadataJSON), session.CompletedAt, + `, session.AgentInstanceID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID, + session.RepositoryID, session.BaseBranch, session.WorktreeID, session.WorktreePath, session.WorktreeBranch, + string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), + string(session.State), session.Progress, session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt, session.ID) if err != nil { return err @@ -1846,17 +1914,17 @@ func (r *SQLiteRepository) UpdateAgentSession(ctx context.Context, session *mode 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 { +// UpdateAgentSessionState updates just the state and error message of an agent session +func (r *SQLiteRepository) UpdateAgentSessionState(ctx context.Context, id string, status models.AgentSessionState, errorMessage string) error { now := time.Now().UTC() var completedAt *time.Time - if status == models.AgentSessionStatusCompleted || status == models.AgentSessionStatusFailed || status == models.AgentSessionStatusStopped { + if status == models.AgentSessionStateCompleted || status == models.AgentSessionStateFailed || status == models.AgentSessionStateCancelled { completedAt = &now } result, err := r.db.ExecContext(ctx, ` - UPDATE agent_sessions SET status = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE id = ? + UPDATE agent_sessions SET state = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE id = ? `, string(status), errorMessage, completedAt, now, id) if err != nil { return err @@ -1872,8 +1940,10 @@ func (r *SQLiteRepository) UpdateAgentSessionStatus(ctx context.Context, id stri // 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, container_id, agent_profile_id, acp_session_id, executor_id, environment_id, - status, progress, error_message, metadata, started_at, completed_at, updated_at + SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + repository_id, base_branch, worktree_id, worktree_path, worktree_branch, + agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, + state, 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 { @@ -1887,9 +1957,11 @@ func (r *SQLiteRepository) ListAgentSessions(ctx context.Context, taskID string) // 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, container_id, agent_profile_id, acp_session_id, executor_id, environment_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 + SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + repository_id, base_branch, worktree_id, worktree_path, worktree_branch, + agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, + state, progress, error_message, metadata, started_at, completed_at, updated_at + FROM agent_sessions WHERE state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC `) if err != nil { return nil, err @@ -1899,25 +1971,86 @@ func (r *SQLiteRepository) ListActiveAgentSessions(ctx context.Context) ([]*mode return r.scanAgentSessions(rows) } +func (r *SQLiteRepository) HasActiveAgentSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { + var exists int + err := r.db.QueryRowContext(ctx, ` + SELECT 1 FROM agent_sessions + WHERE agent_profile_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') + LIMIT 1 + `, agentProfileID).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + return err == nil, err +} + +func (r *SQLiteRepository) HasActiveAgentSessionsByExecutor(ctx context.Context, executorID string) (bool, error) { + var exists int + err := r.db.QueryRowContext(ctx, ` + SELECT 1 FROM agent_sessions + WHERE executor_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') + LIMIT 1 + `, executorID).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + return err == nil, err +} + +func (r *SQLiteRepository) HasActiveAgentSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) { + var exists int + err := r.db.QueryRowContext(ctx, ` + SELECT 1 FROM agent_sessions + WHERE environment_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') + LIMIT 1 + `, environmentID).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + return err == nil, err +} + +func (r *SQLiteRepository) HasActiveAgentSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) { + var exists int + err := r.db.QueryRowContext(ctx, ` + SELECT 1 + FROM agent_sessions s + INNER JOIN tasks t ON t.id = s.task_id + WHERE s.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') + AND t.repository_id = ? + LIMIT 1 + `, repositoryID).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + return err == nil, err +} + // 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 state string var metadataJSON string + var agentProfileSnapshotJSON string + var executorSnapshotJSON string + var environmentSnapshotJSON string + var repositorySnapshotJSON string var completedAt sql.NullTime err := rows.Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, - &session.ACPSessionID, &session.ExecutorID, &session.EnvironmentID, &status, &session.Progress, &session.ErrorMessage, - &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, + &session.ExecutorID, &session.EnvironmentID, + &session.RepositoryID, &session.BaseBranch, &session.WorktreeID, &session.WorktreePath, &session.WorktreeBranch, + &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, + &state, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) if err != nil { return nil, err } - session.Status = models.AgentSessionStatus(status) + session.State = models.AgentSessionState(state) if completedAt.Valid { session.CompletedAt = &completedAt.Time } @@ -1926,6 +2059,26 @@ func (r *SQLiteRepository) scanAgentSessions(rows *sql.Rows) ([]*models.AgentSes return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err) } } + if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err) + } + } + if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err) + } + } + if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err) + } + } + if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" { + if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil { + return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err) + } + } result = append(result, session) } @@ -1962,9 +2115,9 @@ func (r *SQLiteRepository) CreateExecutor(ctx context.Context, executor *models. } _, err = r.db.ExecContext(ctx, ` - INSERT INTO executors (id, name, type, status, is_system, config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, executor.ID, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), string(configJSON), executor.CreatedAt, executor.UpdatedAt) + INSERT INTO executors (id, name, type, status, is_system, config, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, executor.ID, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt) return err } @@ -1974,11 +2127,11 @@ func (r *SQLiteRepository) GetExecutor(ctx context.Context, id string) (*models. var isSystem int err := r.db.QueryRowContext(ctx, ` - SELECT id, name, type, status, is_system, config, created_at, updated_at - FROM executors WHERE id = ? + SELECT id, name, type, status, is_system, config, created_at, updated_at, deleted_at + FROM executors WHERE id = ? AND deleted_at IS NULL `, id).Scan( &executor.ID, &executor.Name, &executor.Type, &executor.Status, - &isSystem, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, + &isSystem, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("executor not found: %s", id) @@ -2006,7 +2159,7 @@ func (r *SQLiteRepository) UpdateExecutor(ctx context.Context, executor *models. result, err := r.db.ExecContext(ctx, ` UPDATE executors SET name = ?, type = ?, status = ?, is_system = ?, config = ?, updated_at = ? - WHERE id = ? + WHERE id = ? AND deleted_at IS NULL `, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), string(configJSON), executor.UpdatedAt, executor.ID) if err != nil { return err @@ -2019,7 +2172,10 @@ func (r *SQLiteRepository) UpdateExecutor(ctx context.Context, executor *models. } func (r *SQLiteRepository) DeleteExecutor(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM executors WHERE id = ?`, id) + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, ` + UPDATE executors SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL + `, now, now, id) if err != nil { return err } @@ -2032,8 +2188,8 @@ func (r *SQLiteRepository) DeleteExecutor(ctx context.Context, id string) error func (r *SQLiteRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, type, status, is_system, config, created_at, updated_at - FROM executors ORDER BY created_at ASC + SELECT id, name, type, status, is_system, config, created_at, updated_at, deleted_at + FROM executors WHERE deleted_at IS NULL ORDER BY created_at ASC `) if err != nil { return nil, err @@ -2047,7 +2203,7 @@ func (r *SQLiteRepository) ListExecutors(ctx context.Context) ([]*models.Executo var isSystem int if err := rows.Scan( &executor.ID, &executor.Name, &executor.Type, &executor.Status, - &isSystem, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, + &isSystem, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt, ); err != nil { return nil, err } @@ -2078,9 +2234,9 @@ func (r *SQLiteRepository) CreateEnvironment(ctx context.Context, environment *m } _, err = r.db.ExecContext(ctx, ` - INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, environment.ID, environment.Name, environment.Kind, boolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt) + INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, environment.ID, environment.Name, environment.Kind, boolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt, environment.DeletedAt) return err } @@ -2090,12 +2246,12 @@ func (r *SQLiteRepository) GetEnvironment(ctx context.Context, id string) (*mode var isSystem int err := r.db.QueryRowContext(ctx, ` - SELECT id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at - FROM environments WHERE id = ? + SELECT id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at + FROM environments WHERE id = ? AND deleted_at IS NULL `, id).Scan( &environment.ID, &environment.Name, &environment.Kind, &isSystem, &environment.WorktreeRoot, &environment.ImageTag, &environment.Dockerfile, &buildConfigJSON, - &environment.CreatedAt, &environment.UpdatedAt, + &environment.CreatedAt, &environment.UpdatedAt, &environment.DeletedAt, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("environment not found: %s", id) @@ -2122,7 +2278,7 @@ func (r *SQLiteRepository) UpdateEnvironment(ctx context.Context, environment *m result, err := r.db.ExecContext(ctx, ` UPDATE environments SET name = ?, kind = ?, is_system = ?, worktree_root = ?, image_tag = ?, dockerfile = ?, build_config = ?, updated_at = ? - WHERE id = ? + WHERE id = ? AND deleted_at IS NULL `, environment.Name, environment.Kind, boolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID) if err != nil { return err @@ -2135,7 +2291,10 @@ func (r *SQLiteRepository) UpdateEnvironment(ctx context.Context, environment *m } func (r *SQLiteRepository) DeleteEnvironment(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM environments WHERE id = ?`, id) + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, ` + UPDATE environments SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL + `, now, now, id) if err != nil { return err } @@ -2148,8 +2307,8 @@ func (r *SQLiteRepository) DeleteEnvironment(ctx context.Context, id string) err func (r *SQLiteRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at - FROM environments ORDER BY created_at ASC + SELECT id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at + FROM environments WHERE deleted_at IS NULL ORDER BY created_at ASC `) if err != nil { return nil, err @@ -2164,7 +2323,7 @@ func (r *SQLiteRepository) ListEnvironments(ctx context.Context) ([]*models.Envi if err := rows.Scan( &environment.ID, &environment.Name, &environment.Kind, &isSystem, &environment.WorktreeRoot, &environment.ImageTag, &environment.Dockerfile, &buildConfigJSON, - &environment.CreatedAt, &environment.UpdatedAt, + &environment.CreatedAt, &environment.UpdatedAt, &environment.DeletedAt, ); err != nil { return nil, err } diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index 972b0eb175..fba67d16df 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -493,9 +493,20 @@ func TestSQLiteRepository_Persistence(t *testing.T) { } } -// Comment CRUD tests +// Message CRUD tests -func TestSQLiteRepository_CommentCRUD(t *testing.T) { +func setupSQLiteTestSession(ctx context.Context, repo *SQLiteRepository, taskID, sessionID string) string { + session := &models.AgentSession{ + ID: sessionID, + TaskID: taskID, + AgentProfileID: "profile-123", + State: models.AgentSessionStateStarting, + } + _ = repo.CreateAgentSession(ctx, session) + return session.ID +} + +func TestSQLiteRepository_MessageCRUD(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() ctx := context.Background() @@ -509,16 +520,18 @@ func TestSQLiteRepository_CommentCRUD(t *testing.T) { // Create a task first task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) + sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") // Create comment - comment := &models.Comment{ - TaskID: "task-123", - AuthorType: models.CommentAuthorUser, - AuthorID: "user-123", - Content: "This is a test comment", - RequestsInput: false, + comment := &models.Message{ + AgentSessionID: sessionID, + TaskID: "task-123", + AuthorType: models.MessageAuthorUser, + AuthorID: "user-123", + Content: "This is a test comment", + RequestsInput: false, } - if err := repo.CreateComment(ctx, comment); err != nil { + if err := repo.CreateMessage(ctx, comment); err != nil { t.Fatalf("failed to create comment: %v", err) } if comment.ID == "" { @@ -529,44 +542,44 @@ func TestSQLiteRepository_CommentCRUD(t *testing.T) { } // Get comment - retrieved, err := repo.GetComment(ctx, comment.ID) + retrieved, err := repo.GetMessage(ctx, comment.ID) if err != nil { t.Fatalf("failed to get comment: %v", err) } if retrieved.Content != "This is a test comment" { t.Errorf("expected content 'This is a test comment', got %s", retrieved.Content) } - if retrieved.AuthorType != models.CommentAuthorUser { + if retrieved.AuthorType != models.MessageAuthorUser { t.Errorf("expected author type 'user', got %s", retrieved.AuthorType) } // Delete comment - if err := repo.DeleteComment(ctx, comment.ID); err != nil { + if err := repo.DeleteMessage(ctx, comment.ID); err != nil { t.Fatalf("failed to delete comment: %v", err) } - _, err = repo.GetComment(ctx, comment.ID) + _, err = repo.GetMessage(ctx, comment.ID) if err == nil { t.Error("expected comment to be deleted") } } -func TestSQLiteRepository_CommentNotFound(t *testing.T) { +func TestSQLiteRepository_MessageNotFound(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() ctx := context.Background() - _, err := repo.GetComment(ctx, "nonexistent") + _, err := repo.GetMessage(ctx, "nonexistent") if err == nil { t.Error("expected error for nonexistent comment") } - err = repo.DeleteComment(ctx, "nonexistent") + err = repo.DeleteMessage(ctx, "nonexistent") if err == nil { t.Error("expected error for deleting nonexistent comment") } } -func TestSQLiteRepository_ListComments(t *testing.T) { +func TestSQLiteRepository_ListMessages(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() ctx := context.Background() @@ -582,13 +595,15 @@ func TestSQLiteRepository_ListComments(t *testing.T) { _ = repo.CreateTask(ctx, task) task2 := &models.Task{ID: "task-456", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task 2"} _ = repo.CreateTask(ctx, task2) + sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") + _ = setupSQLiteTestSession(ctx, repo, task2.ID, "session-456") // Create multiple comments - _ = repo.CreateComment(ctx, &models.Comment{ID: "comment-1", TaskID: "task-123", AuthorType: models.CommentAuthorUser, Content: "Comment 1"}) - _ = repo.CreateComment(ctx, &models.Comment{ID: "comment-2", TaskID: "task-123", AuthorType: models.CommentAuthorAgent, Content: "Comment 2"}) - _ = repo.CreateComment(ctx, &models.Comment{ID: "comment-3", TaskID: "task-456", AuthorType: models.CommentAuthorUser, Content: "Comment 3"}) + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-1", AgentSessionID: sessionID, TaskID: "task-123", AuthorType: models.MessageAuthorUser, Content: "Comment 1"}) + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-2", AgentSessionID: sessionID, TaskID: "task-123", AuthorType: models.MessageAuthorAgent, Content: "Comment 2"}) + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-3", AgentSessionID: "session-456", TaskID: "task-456", AuthorType: models.MessageAuthorUser, Content: "Comment 3"}) - comments, err := repo.ListComments(ctx, "task-123") + comments, err := repo.ListMessages(ctx, sessionID) if err != nil { t.Fatalf("failed to list comments: %v", err) } @@ -597,7 +612,7 @@ func TestSQLiteRepository_ListComments(t *testing.T) { } } -func TestSQLiteRepository_ListCommentsPagination(t *testing.T) { +func TestSQLiteRepository_ListMessagesPagination(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() ctx := context.Background() @@ -608,31 +623,35 @@ func TestSQLiteRepository_ListCommentsPagination(t *testing.T) { _ = repo.CreateColumn(ctx, column) task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) + sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - _ = repo.CreateComment(ctx, &models.Comment{ - ID: "comment-1", - TaskID: "task-123", - AuthorType: models.CommentAuthorUser, - Content: "Comment 1", - CreatedAt: baseTime.Add(-2 * time.Minute), + _ = repo.CreateMessage(ctx, &models.Message{ + ID: "comment-1", + AgentSessionID: sessionID, + TaskID: "task-123", + AuthorType: models.MessageAuthorUser, + Content: "Comment 1", + CreatedAt: baseTime.Add(-2 * time.Minute), }) - _ = repo.CreateComment(ctx, &models.Comment{ - ID: "comment-2", - TaskID: "task-123", - AuthorType: models.CommentAuthorUser, - Content: "Comment 2", - CreatedAt: baseTime.Add(-1 * time.Minute), + _ = repo.CreateMessage(ctx, &models.Message{ + ID: "comment-2", + AgentSessionID: sessionID, + TaskID: "task-123", + AuthorType: models.MessageAuthorUser, + Content: "Comment 2", + CreatedAt: baseTime.Add(-1 * time.Minute), }) - _ = repo.CreateComment(ctx, &models.Comment{ - ID: "comment-3", - TaskID: "task-123", - AuthorType: models.CommentAuthorUser, - Content: "Comment 3", - CreatedAt: baseTime, + _ = repo.CreateMessage(ctx, &models.Message{ + ID: "comment-3", + AgentSessionID: sessionID, + TaskID: "task-123", + AuthorType: models.MessageAuthorUser, + Content: "Comment 3", + CreatedAt: baseTime, }) - comments, hasMore, err := repo.ListCommentsPaginated(ctx, "task-123", ListCommentsOptions{ + comments, hasMore, err := repo.ListMessagesPaginated(ctx, sessionID, ListMessagesOptions{ Limit: 2, Sort: "desc", }) @@ -650,7 +669,7 @@ func TestSQLiteRepository_ListCommentsPagination(t *testing.T) { } } -func TestSQLiteRepository_CommentWithRequestsInput(t *testing.T) { +func TestSQLiteRepository_MessageWithRequestsInput(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() ctx := context.Background() @@ -664,27 +683,25 @@ func TestSQLiteRepository_CommentWithRequestsInput(t *testing.T) { // Create a task task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) + sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") // Create agent comment requesting input - comment := &models.Comment{ - TaskID: "task-123", - AuthorType: models.CommentAuthorAgent, - AuthorID: "agent-123", - Content: "What should I do next?", - RequestsInput: true, - ACPSessionID: "session-abc", + comment := &models.Message{ + AgentSessionID: sessionID, + TaskID: "task-123", + AuthorType: models.MessageAuthorAgent, + AuthorID: "agent-123", + Content: "What should I do next?", + RequestsInput: true, } - if err := repo.CreateComment(ctx, comment); err != nil { + if err := repo.CreateMessage(ctx, comment); err != nil { t.Fatalf("failed to create comment: %v", err) } - retrieved, _ := repo.GetComment(ctx, comment.ID) + retrieved, _ := repo.GetMessage(ctx, comment.ID) if !retrieved.RequestsInput { t.Error("expected RequestsInput to be true") } - if retrieved.ACPSessionID != "session-abc" { - t.Errorf("expected ACPSessionID 'session-abc', got %s", retrieved.ACPSessionID) - } } // AgentSession CRUD tests @@ -708,10 +725,9 @@ func TestSQLiteRepository_AgentSessionCRUD(t *testing.T) { AgentInstanceID: "instance-abc", ContainerID: "container-xyz", AgentProfileID: "profile-123", - ACPSessionID: "acp-session-123", ExecutorID: "executor-1", EnvironmentID: "env-1", - Status: models.AgentSessionStatusPending, + State: models.AgentSessionStateStarting, Progress: 0, Metadata: map[string]interface{}{"key": "value"}, } @@ -739,22 +755,22 @@ func TestSQLiteRepository_AgentSessionCRUD(t *testing.T) { if retrieved.AgentProfileID != "profile-123" { t.Errorf("expected AgentProfileID 'profile-123', got %s", retrieved.AgentProfileID) } - if retrieved.Status != models.AgentSessionStatusPending { - t.Errorf("expected status 'pending', got %s", retrieved.Status) + if retrieved.State != models.AgentSessionStateStarting { + t.Errorf("expected state 'starting', got %s", retrieved.State) } if retrieved.Metadata["key"] != "value" { t.Errorf("expected metadata key 'value', got %v", retrieved.Metadata["key"]) } // Update agent session - session.Status = models.AgentSessionStatusRunning + session.State = models.AgentSessionStateRunning session.Progress = 50 if err := repo.UpdateAgentSession(ctx, session); err != nil { t.Fatalf("failed to update agent session: %v", err) } retrieved, _ = repo.GetAgentSession(ctx, session.ID) - if retrieved.Status != models.AgentSessionStatusRunning { - t.Errorf("expected status 'running', got %s", retrieved.Status) + if retrieved.State != models.AgentSessionStateRunning { + t.Errorf("expected state 'running', got %s", retrieved.State) } if retrieved.Progress != 50 { t.Errorf("expected progress 50, got %d", retrieved.Progress) @@ -809,7 +825,7 @@ func TestSQLiteRepository_AgentSessionByTaskID(t *testing.T) { ID: "session-1", TaskID: "task-123", AgentProfileID: "profile-1", - Status: models.AgentSessionStatusCompleted, + State: models.AgentSessionStateCompleted, } _ = repo.CreateAgentSession(ctx, session1) @@ -817,7 +833,7 @@ func TestSQLiteRepository_AgentSessionByTaskID(t *testing.T) { ID: "session-2", TaskID: "task-123", AgentProfileID: "profile-2", - Status: models.AgentSessionStatusRunning, + State: models.AgentSessionStateRunning, } _ = repo.CreateAgentSession(ctx, session2) @@ -838,12 +854,12 @@ func TestSQLiteRepository_AgentSessionByTaskID(t *testing.T) { if active.ID != "session-2" { t.Errorf("expected session-2 (active), got %s", active.ID) } - if active.Status != models.AgentSessionStatusRunning { - t.Errorf("expected status 'running', got %s", active.Status) + if active.State != models.AgentSessionStateRunning { + t.Errorf("expected state 'running', got %s", active.State) } // Test when no active session exists - session2.Status = models.AgentSessionStatusCompleted + session2.State = models.AgentSessionStateCompleted _ = repo.UpdateAgentSession(ctx, session2) _, err = repo.GetActiveAgentSessionByTaskID(ctx, "task-123") @@ -874,9 +890,9 @@ func TestSQLiteRepository_ListAgentSessions(t *testing.T) { _ = repo.CreateTask(ctx, task2) // Create sessions for different tasks - _ = repo.CreateAgentSession(ctx, &models.AgentSession{ID: "session-1", TaskID: "task-1", AgentProfileID: "profile-1", Status: models.AgentSessionStatusCompleted}) - _ = repo.CreateAgentSession(ctx, &models.AgentSession{ID: "session-2", TaskID: "task-1", AgentProfileID: "profile-1", Status: models.AgentSessionStatusRunning}) - _ = repo.CreateAgentSession(ctx, &models.AgentSession{ID: "session-3", TaskID: "task-2", AgentProfileID: "profile-2", Status: models.AgentSessionStatusPending}) + _ = repo.CreateAgentSession(ctx, &models.AgentSession{ID: "session-1", TaskID: "task-1", AgentProfileID: "profile-1", State: models.AgentSessionStateCompleted}) + _ = repo.CreateAgentSession(ctx, &models.AgentSession{ID: "session-2", TaskID: "task-1", AgentProfileID: "profile-1", State: models.AgentSessionStateRunning}) + _ = repo.CreateAgentSession(ctx, &models.AgentSession{ID: "session-3", TaskID: "task-2", AgentProfileID: "profile-2", State: models.AgentSessionStateStarting}) // List sessions for task-1 sessions, err := repo.ListAgentSessions(ctx, "task-1") @@ -898,13 +914,13 @@ func TestSQLiteRepository_ListAgentSessions(t *testing.T) { // Verify only active statuses are returned for _, s := range activeSessions { - if s.Status != models.AgentSessionStatusPending && s.Status != models.AgentSessionStatusRunning && s.Status != models.AgentSessionStatusWaiting { - t.Errorf("expected active status, got %s", s.Status) + if s.State != models.AgentSessionStateStarting && s.State != models.AgentSessionStateRunning && s.State != models.AgentSessionStateWaitingForInput { + t.Errorf("expected active state, got %s", s.State) } } } -func TestSQLiteRepository_UpdateAgentSessionStatus(t *testing.T) { +func TestSQLiteRepository_UpdateAgentSessionState(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() ctx := context.Background() @@ -922,31 +938,31 @@ func TestSQLiteRepository_UpdateAgentSessionStatus(t *testing.T) { ID: "session-123", TaskID: "task-123", AgentProfileID: "profile-1", - Status: models.AgentSessionStatusPending, + State: models.AgentSessionStateStarting, } _ = repo.CreateAgentSession(ctx, session) // Update to running status - err := repo.UpdateAgentSessionStatus(ctx, "session-123", models.AgentSessionStatusRunning, "") + err := repo.UpdateAgentSessionState(ctx, "session-123", models.AgentSessionStateRunning, "") if err != nil { t.Fatalf("failed to update agent session status: %v", err) } retrieved, _ := repo.GetAgentSession(ctx, "session-123") - if retrieved.Status != models.AgentSessionStatusRunning { - t.Errorf("expected status 'running', got %s", retrieved.Status) + if retrieved.State != models.AgentSessionStateRunning { + t.Errorf("expected state 'running', got %s", retrieved.State) } if retrieved.CompletedAt != nil { t.Error("expected CompletedAt to be nil for running status") } // Update to completed status (should set CompletedAt) - err = repo.UpdateAgentSessionStatus(ctx, "session-123", models.AgentSessionStatusCompleted, "") + err = repo.UpdateAgentSessionState(ctx, "session-123", models.AgentSessionStateCompleted, "") if err != nil { t.Fatalf("failed to update agent session status to completed: %v", err) } retrieved, _ = repo.GetAgentSession(ctx, "session-123") - if retrieved.Status != models.AgentSessionStatusCompleted { - t.Errorf("expected status 'completed', got %s", retrieved.Status) + if retrieved.State != models.AgentSessionStateCompleted { + t.Errorf("expected state 'completed', got %s", retrieved.State) } if retrieved.CompletedAt == nil { t.Error("expected CompletedAt to be set for completed status") @@ -957,17 +973,17 @@ func TestSQLiteRepository_UpdateAgentSessionStatus(t *testing.T) { ID: "session-456", TaskID: "task-123", AgentProfileID: "profile-1", - Status: models.AgentSessionStatusRunning, + State: models.AgentSessionStateRunning, } _ = repo.CreateAgentSession(ctx, session2) - err = repo.UpdateAgentSessionStatus(ctx, "session-456", models.AgentSessionStatusFailed, "connection timeout") + err = repo.UpdateAgentSessionState(ctx, "session-456", models.AgentSessionStateFailed, "connection timeout") if err != nil { t.Fatalf("failed to update agent session status to failed: %v", err) } retrieved, _ = repo.GetAgentSession(ctx, "session-456") - if retrieved.Status != models.AgentSessionStatusFailed { - t.Errorf("expected status 'failed', got %s", retrieved.Status) + if retrieved.State != models.AgentSessionStateFailed { + t.Errorf("expected state 'failed', got %s", retrieved.State) } if retrieved.ErrorMessage != "connection timeout" { t.Errorf("expected error message 'connection timeout', got %s", retrieved.ErrorMessage) @@ -981,24 +997,24 @@ func TestSQLiteRepository_UpdateAgentSessionStatus(t *testing.T) { ID: "session-789", TaskID: "task-123", AgentProfileID: "profile-1", - Status: models.AgentSessionStatusRunning, + State: models.AgentSessionStateRunning, } _ = repo.CreateAgentSession(ctx, session3) - err = repo.UpdateAgentSessionStatus(ctx, "session-789", models.AgentSessionStatusStopped, "") + err = repo.UpdateAgentSessionState(ctx, "session-789", models.AgentSessionStateCancelled, "") if err != nil { t.Fatalf("failed to update agent session status to stopped: %v", err) } retrieved, _ = repo.GetAgentSession(ctx, "session-789") - if retrieved.Status != models.AgentSessionStatusStopped { - t.Errorf("expected status 'stopped', got %s", retrieved.Status) + if retrieved.State != models.AgentSessionStateCancelled { + t.Errorf("expected state 'cancelled', got %s", retrieved.State) } if retrieved.CompletedAt == nil { t.Error("expected CompletedAt to be set for stopped status") } // Test nonexistent session - err = repo.UpdateAgentSessionStatus(ctx, "nonexistent", models.AgentSessionStatusRunning, "") + err = repo.UpdateAgentSessionState(ctx, "nonexistent", models.AgentSessionStateRunning, "") if err == nil { t.Error("expected error for updating nonexistent session status") } diff --git a/apps/backend/internal/task/service/constants.go b/apps/backend/internal/task/service/constants.go index 34d8c5eea5..b8c224c2fa 100644 --- a/apps/backend/internal/task/service/constants.go +++ b/apps/backend/internal/task/service/constants.go @@ -1,6 +1,6 @@ package service const ( - DefaultCommentsPageSize = 10 - MaxCommentsPageSize = 100 + DefaultMessagesPageSize = 10 + MaxMessagesPageSize = 100 ) diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index be4220c8ff..a65231f187 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "strings" "time" @@ -23,13 +24,21 @@ type WorktreeCleanup interface { OnTaskDeleted(ctx context.Context, taskID string) error } +// TaskExecutionStopper stops active task execution (agent session + instance). +type TaskExecutionStopper interface { + StopTask(ctx context.Context, taskID, reason string, force bool) error +} + +var ErrActiveAgentSessions = errors.New("active agent sessions exist") + // Service provides task business logic type Service struct { - repo repository.Repository - eventBus bus.EventBus - logger *logger.Logger - discoveryConfig RepositoryDiscoveryConfig - worktreeCleanup WorktreeCleanup + repo repository.Repository + eventBus bus.EventBus + logger *logger.Logger + discoveryConfig RepositoryDiscoveryConfig + worktreeCleanup WorktreeCleanup + executionStopper TaskExecutionStopper } // NewService creates a new task service @@ -47,35 +56,40 @@ func (s *Service) SetWorktreeCleanup(cleanup WorktreeCleanup) { s.worktreeCleanup = cleanup } +// SetExecutionStopper wires the task execution stopper (orchestrator). +func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) { + s.executionStopper = stopper +} + // Request types // CreateTaskRequest contains the data for creating a new task type CreateTaskRequest struct { - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - ColumnID string `json:"column_id"` - Title string `json:"title"` - Description string `json:"description"` - Priority int `json:"priority"` - State *v1.TaskState `json:"state,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - RepositoryURL string `json:"repository_url,omitempty"` - Branch string `json:"branch,omitempty"` - AssignedTo string `json:"assigned_to,omitempty"` - Position int `json:"position"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description"` + Priority int `json:"priority"` + State *v1.TaskState `json:"state,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + AssignedTo string `json:"assigned_to,omitempty"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } // UpdateTaskRequest contains the data for updating a task type UpdateTaskRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Priority *int `json:"priority,omitempty"` - State *v1.TaskState `json:"state,omitempty"` - AgentProfileID *string `json:"agent_profile_id,omitempty"` - AssignedTo *string `json:"assigned_to,omitempty"` - Position *int `json:"position,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + RepositoryID *string `json:"repository_id,omitempty"` + BaseBranch *string `json:"base_branch,omitempty"` + AssignedTo *string `json:"assigned_to,omitempty"` + Position *int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } // CreateBoardRequest contains the data for creating a new board @@ -193,12 +207,12 @@ type UpdateEnvironmentRequest struct { BuildConfig map[string]string `json:"build_config,omitempty"` } -type ListCommentsRequest struct { - TaskID string - Limit int - Before string - After string - Sort string +type ListMessagesRequest struct { + AgentSessionID string + Limit int + Before string + After string + Sort string } // CreateRepositoryScriptRequest contains the data for creating a repository script @@ -220,28 +234,27 @@ type UpdateRepositoryScriptRequest struct { // CreateTask creates a new task and publishes a task.created event func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) { - if strings.TrimSpace(req.RepositoryURL) == "" { - return nil, fmt.Errorf("repository_url is required") + if strings.TrimSpace(req.RepositoryID) == "" { + return nil, fmt.Errorf("repository_id is required") } state := v1.TaskStateCreated if req.State != nil { state = *req.State } task := &models.Task{ - ID: uuid.New().String(), - WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, - ColumnID: req.ColumnID, - Title: req.Title, - Description: req.Description, - State: state, - Priority: req.Priority, - AgentProfileID: req.AgentProfileID, - RepositoryURL: req.RepositoryURL, - Branch: req.Branch, - AssignedTo: req.AssignedTo, - Position: req.Position, - Metadata: req.Metadata, + ID: uuid.New().String(), + WorkspaceID: req.WorkspaceID, + BoardID: req.BoardID, + ColumnID: req.ColumnID, + Title: req.Title, + Description: req.Description, + State: state, + Priority: req.Priority, + RepositoryID: req.RepositoryID, + BaseBranch: req.BaseBranch, + AssignedTo: req.AssignedTo, + Position: req.Position, + Metadata: req.Metadata, } if err := s.repo.CreateTask(ctx, task); err != nil { @@ -284,8 +297,11 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ task.State = *req.State stateChanged = true } - if req.AgentProfileID != nil { - task.AgentProfileID = *req.AgentProfileID + if req.RepositoryID != nil { + task.RepositoryID = *req.RepositoryID + } + if req.BaseBranch != nil { + task.BaseBranch = *req.BaseBranch } if req.AssignedTo != nil { task.AssignedTo = *req.AssignedTo @@ -319,6 +335,14 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error { return err } + if s.executionStopper != nil { + if err := s.executionStopper.StopTask(ctx, id, "task deleted", true); err != nil { + s.logger.Warn("failed to stop task execution on delete", + zap.String("task_id", id), + zap.Error(err)) + } + } + // Clean up worktree BEFORE deleting task (CASCADE would delete worktree DB records) // This must happen first so we can find the worktree paths to remove from disk if s.worktreeCleanup != nil { @@ -815,6 +839,14 @@ func (s *Service) DeleteRepository(ctx context.Context, id string) error { if err != nil { return err } + active, err := s.repo.HasActiveAgentSessionsByRepository(ctx, id) + if err != nil { + s.logger.Error("failed to check active agent sessions for repository", zap.String("repository_id", id), zap.Error(err)) + return err + } + if active { + return ErrActiveAgentSessions + } if err := s.repo.DeleteRepository(ctx, id); err != nil { s.logger.Error("failed to delete repository", zap.String("repository_id", id), zap.Error(err)) return err @@ -953,6 +985,14 @@ func (s *Service) DeleteExecutor(ctx context.Context, id string) error { if executor.IsSystem { return fmt.Errorf("system executors cannot be deleted") } + active, err := s.repo.HasActiveAgentSessionsByExecutor(ctx, id) + if err != nil { + s.logger.Error("failed to check active agent sessions for executor", zap.String("executor_id", id), zap.Error(err)) + return err + } + if active { + return ErrActiveAgentSessions + } if err := s.repo.DeleteExecutor(ctx, id); err != nil { return err } @@ -1032,6 +1072,14 @@ func (s *Service) DeleteEnvironment(ctx context.Context, id string) error { if environment.IsSystem { return fmt.Errorf("system environments cannot be deleted") } + active, err := s.repo.HasActiveAgentSessionsByEnvironment(ctx, id) + if err != nil { + s.logger.Error("failed to check active agent sessions for environment", zap.String("environment_id", id), zap.Error(err)) + return err + } + if active { + return ErrActiveAgentSessions + } if err := s.repo.DeleteEnvironment(ctx, id); err != nil { return err } @@ -1062,14 +1110,14 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * "updated_at": task.UpdatedAt.Format(time.RFC3339), } - if task.AgentProfileID != "" { - data["agent_profile_id"] = task.AgentProfileID - } if task.AssignedTo != "" { data["assigned_to"] = task.AssignedTo } - if task.RepositoryURL != "" { - data["repository_url"] = task.RepositoryURL + if task.RepositoryID != "" { + data["repository_id"] = task.RepositoryID + } + if task.BaseBranch != "" { + data["base_branch"] = task.BaseBranch } if task.Metadata != nil { data["metadata"] = task.Metadata @@ -1104,15 +1152,15 @@ func (s *Service) publishWorkspaceEvent(ctx context.Context, eventType string, w } data := map[string]interface{}{ - "id": workspace.ID, - "name": workspace.Name, - "description": workspace.Description, - "owner_id": workspace.OwnerID, - "default_executor_id": workspace.DefaultExecutorID, - "default_environment_id": workspace.DefaultEnvironmentID, + "id": workspace.ID, + "name": workspace.Name, + "description": workspace.Description, + "owner_id": workspace.OwnerID, + "default_executor_id": workspace.DefaultExecutorID, + "default_environment_id": workspace.DefaultEnvironmentID, "default_agent_profile_id": workspace.DefaultAgentProfileID, - "created_at": workspace.CreatedAt.Format(time.RFC3339), - "updated_at": workspace.UpdatedAt.Format(time.RFC3339), + "created_at": workspace.CreatedAt.Format(time.RFC3339), + "updated_at": workspace.UpdatedAt.Format(time.RFC3339), } event := bus.NewEvent(eventType, "task-service", data) @@ -1224,89 +1272,91 @@ func (s *Service) publishEnvironmentEvent(ctx context.Context, eventType string, } } -// Comment operations +// Message operations -// CreateCommentRequest contains the data for creating a new comment -type CreateCommentRequest struct { - TaskID string `json:"task_id"` +// CreateMessageRequest contains the data for creating a new message +type CreateMessageRequest struct { + AgentSessionID string `json:"agent_session_id"` + TaskID string `json:"task_id,omitempty"` 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 -func (s *Service) CreateComment(ctx context.Context, req *CreateCommentRequest) (*models.Comment, error) { - // Verify task exists - _, err := s.repo.GetTask(ctx, req.TaskID) +// CreateMessage creates a new message on an agent session +func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) (*models.Message, error) { + session, err := s.repo.GetAgentSession(ctx, req.AgentSessionID) if err != nil { return nil, err } - authorType := models.CommentAuthorUser + authorType := models.MessageAuthorUser if req.AuthorType == "agent" { - authorType = models.CommentAuthorAgent + authorType = models.MessageAuthorAgent } - commentType := models.CommentType(req.Type) - if commentType == "" { - commentType = models.CommentTypeMessage + messageType := models.MessageType(req.Type) + if messageType == "" { + messageType = models.MessageTypeMessage } - comment := &models.Comment{ + taskID := req.TaskID + if taskID == "" && session != nil { + taskID = session.TaskID + } + + message := &models.Message{ ID: uuid.New().String(), - TaskID: req.TaskID, + AgentSessionID: req.AgentSessionID, + TaskID: taskID, AuthorType: authorType, AuthorID: req.AuthorID, Content: req.Content, - Type: commentType, + Type: messageType, 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 { - s.logger.Error("failed to create comment", zap.Error(err)) + if err := s.repo.CreateMessage(ctx, message); err != nil { + s.logger.Error("failed to create message", zap.Error(err)) return nil, err } - // Publish comment.added event - s.publishCommentEvent(ctx, events.CommentAdded, comment) + // Publish message.added event + s.publishMessageEvent(ctx, events.MessageAdded, message) - s.logger.Info("comment created", - zap.String("comment_id", comment.ID), - zap.String("task_id", comment.TaskID), - zap.String("author_type", string(comment.AuthorType))) + s.logger.Info("message created", + zap.String("message_id", message.ID), + zap.String("agent_session_id", message.AgentSessionID), + zap.String("author_type", string(message.AuthorType))) - return comment, nil + return message, nil } -// GetComment retrieves a comment by ID -func (s *Service) GetComment(ctx context.Context, id string) (*models.Comment, error) { - return s.repo.GetComment(ctx, id) +// GetMessage retrieves a message by ID +func (s *Service) GetMessage(ctx context.Context, id string) (*models.Message, error) { + return s.repo.GetMessage(ctx, id) } -// ListComments returns all comments for a task. -func (s *Service) ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) { - return s.repo.ListComments(ctx, taskID) +// ListMessages returns all messages for a session. +func (s *Service) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { + return s.repo.ListMessages(ctx, sessionID) } -// ListCommentsPaginated returns comments for a task with pagination options. -func (s *Service) ListCommentsPaginated(ctx context.Context, req ListCommentsRequest) ([]*models.Comment, bool, error) { +// ListMessagesPaginated returns messages for a session with pagination options. +func (s *Service) ListMessagesPaginated(ctx context.Context, req ListMessagesRequest) ([]*models.Message, bool, error) { limit := req.Limit if limit <= 0 && (req.Before != "" || req.After != "") { - limit = DefaultCommentsPageSize + limit = DefaultMessagesPageSize } - if limit > MaxCommentsPageSize { - limit = MaxCommentsPageSize + if limit > MaxMessagesPageSize { + limit = MaxMessagesPageSize } - return s.repo.ListCommentsPaginated(ctx, req.TaskID, repository.ListCommentsOptions{ + return s.repo.ListMessagesPaginated(ctx, req.AgentSessionID, repository.ListMessagesOptions{ Limit: limit, Before: req.Before, After: req.After, @@ -1314,95 +1364,89 @@ func (s *Service) ListCommentsPaginated(ctx context.Context, req ListCommentsReq }) } -// DeleteComment deletes a comment -func (s *Service) DeleteComment(ctx context.Context, id string) error { - if err := s.repo.DeleteComment(ctx, id); err != nil { - s.logger.Error("failed to delete comment", zap.String("comment_id", id), zap.Error(err)) +// DeleteMessage deletes a message +func (s *Service) DeleteMessage(ctx context.Context, id string) error { + if err := s.repo.DeleteMessage(ctx, id); err != nil { + s.logger.Error("failed to delete message", zap.String("message_id", id), zap.Error(err)) return err } - s.logger.Info("comment deleted", zap.String("comment_id", id)) + s.logger.Info("message deleted", zap.String("message_id", id)) return nil } -// UpdateToolCallComment updates a tool call comment's status -func (s *Service) UpdateToolCallComment(ctx context.Context, taskID, toolCallID, status, result string) error { - // Find the comment by tool_call_id - comment, err := s.repo.GetCommentByToolCallID(ctx, taskID, toolCallID) +// UpdateToolCallMessage updates a tool call message's status +func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result string) error { + message, err := s.repo.GetMessageByToolCallID(ctx, sessionID, toolCallID) if err != nil { - s.logger.Warn("tool call comment not found for update", - zap.String("task_id", taskID), + s.logger.Warn("tool call message not found for update", + zap.String("agent_session_id", sessionID), zap.String("tool_call_id", toolCallID), zap.Error(err)) return err } - // Update the metadata status - if comment.Metadata == nil { - comment.Metadata = make(map[string]interface{}) + if message.Metadata == nil { + message.Metadata = make(map[string]interface{}) } - comment.Metadata["status"] = status + message.Metadata["status"] = status if result != "" { - comment.Metadata["result"] = result + message.Metadata["result"] = result } - // Save the updated comment - if err := s.repo.UpdateComment(ctx, comment); err != nil { - s.logger.Error("failed to update tool call comment", - zap.String("comment_id", comment.ID), + if err := s.repo.UpdateMessage(ctx, message); err != nil { + s.logger.Error("failed to update tool call message", + zap.String("message_id", message.ID), zap.String("tool_call_id", toolCallID), zap.Error(err)) return err } - // Publish comment.updated event - s.publishCommentEvent(ctx, events.CommentUpdated, comment) + // Publish message.updated event + s.publishMessageEvent(ctx, events.MessageUpdated, message) - s.logger.Info("tool call comment updated", - zap.String("comment_id", comment.ID), + s.logger.Info("tool call message updated", + zap.String("message_id", message.ID), zap.String("tool_call_id", toolCallID), zap.String("status", status)) return nil } -// publishCommentEvent publishes comment events to the event bus -func (s *Service) publishCommentEvent(ctx context.Context, eventType string, comment *models.Comment) { +// publishMessageEvent publishes message events to the event bus +func (s *Service) publishMessageEvent(ctx context.Context, eventType string, message *models.Message) { if s.eventBus == nil { - s.logger.Warn("publishCommentEvent: eventBus is nil, skipping") + s.logger.Warn("publishMessageEvent: eventBus is nil, skipping") return } - commentType := string(comment.Type) - if commentType == "" { - commentType = "message" + messageType := string(message.Type) + if messageType == "" { + messageType = "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), + "message_id": message.ID, + "agent_session_id": message.AgentSessionID, + "task_id": message.TaskID, + "author_type": string(message.AuthorType), + "author_id": message.AuthorID, + "content": message.Content, + "type": messageType, + "requests_input": message.RequestsInput, + "created_at": message.CreatedAt.Format(time.RFC3339), } - if comment.ACPSessionID != "" { - data["acp_session_id"] = comment.ACPSessionID - } - - if comment.Metadata != nil { - data["metadata"] = comment.Metadata + if message.Metadata != nil { + data["metadata"] = message.Metadata } event := bus.NewEvent(eventType, "task-service", data) if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish comment event", + s.logger.Error("failed to publish message event", zap.String("event_type", eventType), - zap.String("comment_id", comment.ID), + zap.String("message_id", message.ID), zap.Error(err)) } } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index c8bb9933aa..6f0ef28957 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -93,15 +93,17 @@ func TestService_CreateTask(t *testing.T) { _ = repo.CreateBoard(ctx, board) column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do", State: v1.TaskStateTODO} _ = repo.CreateColumn(ctx, column) + repository := &models.Repository{ID: "repo-123", WorkspaceID: "ws-1", Name: "Test Repo"} + _ = repo.CreateRepository(ctx, repository) req := &CreateTaskRequest{ - WorkspaceID: "ws-1", - BoardID: "board-123", - ColumnID: "col-123", - Title: "Test Task", - Description: "A test task", - Priority: 5, - RepositoryURL: "/tmp/repo", + WorkspaceID: "ws-1", + BoardID: "board-123", + ColumnID: "col-123", + Title: "Test Task", + Description: "A test task", + Priority: 5, + RepositoryID: "repo-123", } task, err := svc.CreateTask(ctx, req) @@ -452,7 +454,7 @@ func TestService_ListColumns(t *testing.T) { } } -// Comment tests +// Message tests func setupTestTask(t *testing.T, repo repository.Repository) { t.Helper() @@ -463,22 +465,36 @@ func setupTestTask(t *testing.T, repo repository.Repository) { _ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}) } -func TestService_CreateComment(t *testing.T) { +func setupTestSession(t *testing.T, repo repository.Repository) string { + t.Helper() + ctx := context.Background() + session := &models.AgentSession{ + ID: "session-123", + TaskID: "task-123", + AgentProfileID: "profile-123", + State: models.AgentSessionStateStarting, + } + _ = repo.CreateAgentSession(ctx, session) + return session.ID +} + +func TestService_CreateMessage(t *testing.T) { svc, eventBus, repo := createTestService(t) ctx := context.Background() // Create a task first setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) eventBus.ClearEvents() - req := &CreateCommentRequest{ - TaskID: "task-123", - Content: "This is a test comment", - AuthorType: "user", - AuthorID: "user-123", + req := &CreateMessageRequest{ + AgentSessionID: sessionID, + Content: "This is a test comment", + AuthorType: "user", + AuthorID: "user-123", } - comment, err := svc.CreateComment(ctx, req) + comment, err := svc.CreateMessage(ctx, req) if err != nil { t.Fatalf("failed to create comment: %v", err) } @@ -489,7 +505,7 @@ func TestService_CreateComment(t *testing.T) { if comment.Content != "This is a test comment" { t.Errorf("expected content 'This is a test comment', got %s", comment.Content) } - if comment.AuthorType != models.CommentAuthorUser { + if comment.AuthorType != models.MessageAuthorUser { t.Errorf("expected author type 'user', got %s", comment.AuthorType) } @@ -498,67 +514,65 @@ func TestService_CreateComment(t *testing.T) { if len(events) != 1 { t.Errorf("expected 1 event, got %d", len(events)) } - if events[0].Type != "comment.added" { - t.Errorf("expected event type 'comment.added', got %s", events[0].Type) + if events[0].Type != "message.added" { + t.Errorf("expected event type 'message.added', got %s", events[0].Type) } } -func TestService_CreateAgentComment(t *testing.T) { +func TestService_CreateAgentMessage(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) - req := &CreateCommentRequest{ - TaskID: "task-123", - Content: "What should I do next?", - AuthorType: "agent", - AuthorID: "agent-123", - RequestsInput: true, - ACPSessionID: "session-abc", + req := &CreateMessageRequest{ + AgentSessionID: sessionID, + Content: "What should I do next?", + AuthorType: "agent", + AuthorID: "agent-123", + RequestsInput: true, } - comment, err := svc.CreateComment(ctx, req) + comment, err := svc.CreateMessage(ctx, req) if err != nil { t.Fatalf("failed to create comment: %v", err) } - if comment.AuthorType != models.CommentAuthorAgent { + if comment.AuthorType != models.MessageAuthorAgent { t.Errorf("expected author type 'agent', got %s", comment.AuthorType) } if !comment.RequestsInput { t.Error("expected RequestsInput to be true") } - if comment.ACPSessionID != "session-abc" { - t.Errorf("expected ACPSessionID 'session-abc', got %s", comment.ACPSessionID) - } } -func TestService_CreateCommentTaskNotFound(t *testing.T) { +func TestService_CreateMessageSessionNotFound(t *testing.T) { svc, _, _ := createTestService(t) ctx := context.Background() - req := &CreateCommentRequest{ - TaskID: "nonexistent", - Content: "Test comment", + req := &CreateMessageRequest{ + AgentSessionID: "nonexistent", + Content: "Test comment", } - _, err := svc.CreateComment(ctx, req) + _, err := svc.CreateMessage(ctx, req) if err == nil { t.Error("expected error for nonexistent task") } } -func TestService_GetComment(t *testing.T) { +func TestService_GetMessage(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) - comment := &models.Comment{ID: "comment-123", TaskID: "task-123", AuthorType: models.CommentAuthorUser, Content: "Test"} - _ = repo.CreateComment(ctx, comment) + comment := &models.Message{ID: "comment-123", AgentSessionID: sessionID, TaskID: "task-123", AuthorType: models.MessageAuthorUser, Content: "Test"} + _ = repo.CreateMessage(ctx, comment) - retrieved, err := svc.GetComment(ctx, "comment-123") + retrieved, err := svc.GetMessage(ctx, "comment-123") if err != nil { t.Fatalf("failed to get comment: %v", err) } @@ -567,16 +581,17 @@ func TestService_GetComment(t *testing.T) { } } -func TestService_ListComments(t *testing.T) { +func TestService_ListMessages(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) - _ = repo.CreateComment(ctx, &models.Comment{ID: "comment-1", TaskID: "task-123", AuthorType: models.CommentAuthorUser, Content: "Comment 1"}) - _ = repo.CreateComment(ctx, &models.Comment{ID: "comment-2", TaskID: "task-123", AuthorType: models.CommentAuthorAgent, Content: "Comment 2"}) + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-1", AgentSessionID: sessionID, TaskID: "task-123", AuthorType: models.MessageAuthorUser, Content: "Comment 1"}) + _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-2", AgentSessionID: sessionID, TaskID: "task-123", AuthorType: models.MessageAuthorAgent, Content: "Comment 2"}) - comments, err := svc.ListComments(ctx, "task-123") + comments, err := svc.ListMessages(ctx, sessionID) if err != nil { t.Fatalf("failed to list comments: %v", err) } @@ -585,21 +600,22 @@ func TestService_ListComments(t *testing.T) { } } -func TestService_DeleteComment(t *testing.T) { +func TestService_DeleteMessage(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) - comment := &models.Comment{ID: "comment-123", TaskID: "task-123", AuthorType: models.CommentAuthorUser, Content: "Test"} - _ = repo.CreateComment(ctx, comment) + comment := &models.Message{ID: "comment-123", AgentSessionID: sessionID, TaskID: "task-123", AuthorType: models.MessageAuthorUser, Content: "Test"} + _ = repo.CreateMessage(ctx, comment) - err := svc.DeleteComment(ctx, "comment-123") + err := svc.DeleteMessage(ctx, "comment-123") if err != nil { t.Fatalf("failed to delete comment: %v", err) } - _, err = svc.GetComment(ctx, "comment-123") + _, err = svc.GetMessage(ctx, "comment-123") if err == nil { t.Error("expected comment to be deleted") } diff --git a/apps/backend/pkg/acp/protocol/message.go b/apps/backend/pkg/acp/protocol/message.go index 7d16f4b593..006961a72e 100644 --- a/apps/backend/pkg/acp/protocol/message.go +++ b/apps/backend/pkg/acp/protocol/message.go @@ -17,7 +17,6 @@ const ( MessageTypeStatus MessageType = "status" MessageTypeHeartbeat MessageType = "heartbeat" MessageTypeInputRequired MessageType = "input_required" // Agent requests input from user - MessageTypeSessionInfo MessageType = "session_info" // Agent reports session information // Backend → Agent message types MessageTypeControl MessageType = "control" // Control commands (pause, resume, stop) @@ -58,4 +57,3 @@ func (m *Message) IsValid() bool { } return true } - diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index e8d8568bf0..6a14557fcc 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -18,6 +18,33 @@ const ( TaskStateCancelled TaskState = "CANCELLED" ) +// AgentSessionState represents the state of an agent session. +type AgentSessionState string + +const ( + AgentSessionStateCreated AgentSessionState = "CREATED" + AgentSessionStateStarting AgentSessionState = "STARTING" + AgentSessionStateRunning AgentSessionState = "RUNNING" + AgentSessionStateWaitingForInput AgentSessionState = "WAITING_FOR_INPUT" + AgentSessionStateCompleted AgentSessionState = "COMPLETED" + AgentSessionStateFailed AgentSessionState = "FAILED" + AgentSessionStateCancelled AgentSessionState = "CANCELLED" +) + +// MessageType represents a normalized session message type. +type MessageType string + +const ( + MessageTypeMessage MessageType = "message" + MessageTypeContent MessageType = "content" + MessageTypeToolCall MessageType = "tool_call" + MessageTypeProgress MessageType = "progress" + MessageTypeError MessageType = "error" + MessageTypeStatus MessageType = "status" + MessageTypeThinking MessageType = "thinking" + MessageTypeTodo MessageType = "todo" +) + // Task represents a Kanban task type Task struct { ID string `json:"id"` @@ -27,9 +54,8 @@ type Task struct { Description string `json:"description"` State TaskState `json:"state"` Priority int `json:"priority"` - AgentProfileID *string `json:"agent_profile_id,omitempty"` - RepositoryURL *string `json:"repository_url,omitempty"` - Branch *string `json:"branch,omitempty"` + RepositoryID *string `json:"repository_id,omitempty"` + BaseBranch *string `json:"base_branch,omitempty"` AssignedAgentID *string `json:"assigned_agent_id,omitempty"` CreatedBy string `json:"created_by"` CreatedAt time.Time `json:"created_at"` @@ -41,24 +67,22 @@ type Task struct { // CreateTaskRequest for creating a new task type CreateTaskRequest struct { - Title string `json:"title" binding:"required,max=500"` - Description string `json:"description" binding:"required"` - Priority int `json:"priority" binding:"min=0,max=10"` - AgentProfileID *string `json:"agent_profile_id,omitempty"` - RepositoryURL *string `json:"repository_url,omitempty"` - Branch *string `json:"branch,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Title string `json:"title" binding:"required,max=500"` + Description string `json:"description" binding:"required"` + Priority int `json:"priority" binding:"min=0,max=10"` + RepositoryID *string `json:"repository_id,omitempty"` + BaseBranch *string `json:"base_branch,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } // UpdateTaskRequest for updating an existing task type UpdateTaskRequest struct { - Title *string `json:"title,omitempty" binding:"omitempty,max=500"` - Description *string `json:"description,omitempty"` - Priority *int `json:"priority,omitempty" binding:"omitempty,min=0,max=10"` - AgentType *string `json:"agent_type,omitempty"` - RepositoryURL *string `json:"repository_url,omitempty"` - Branch *string `json:"branch,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Title *string `json:"title,omitempty" binding:"omitempty,max=500"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty" binding:"omitempty,min=0,max=10"` + RepositoryID *string `json:"repository_id,omitempty"` + BaseBranch *string `json:"base_branch,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } // UpdateTaskStateRequest for changing task state @@ -78,28 +102,28 @@ type TaskEvent struct { CreatedAt time.Time `json:"created_at"` } -// Comment represents a comment on a task (user or agent) -type Comment struct { +// Message represents a message in an agent session (user or agent) +type Message struct { ID string `json:"id"` - TaskID string `json:"task_id"` + AgentSessionID string `json:"agent_session_id"` + TaskID string `json:"task_id,omitempty"` 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 - Type string `json:"type,omitempty"` - RequestsInput bool `json:"requests_input,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` +// CreateMessageRequest for adding a message to an agent session +type CreateMessageRequest struct { + AgentSessionID string `json:"agent_session_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 @@ -111,20 +135,20 @@ type PermissionOption struct { // PermissionRequest represents an agent's request for user permission type PermissionRequest struct { - RequestID string `json:"request_id"` // Unique ID for this request (JSON-RPC ID) - TaskID string `json:"task_id"` // Task the agent is working on - InstanceID string `json:"instance_id"` // Agent instance ID - SessionID string `json:"session_id"` // ACP session ID - ToolCallID string `json:"tool_call_id"` // Tool call requesting permission - Title string `json:"title"` // Human-readable title + RequestID string `json:"request_id"` // Unique ID for this request (JSON-RPC ID) + TaskID string `json:"task_id"` // Task the agent is working on + InstanceID string `json:"instance_id"` // Agent instance ID + SessionID string `json:"session_id"` // ACP session ID + ToolCallID string `json:"tool_call_id"` // Tool call requesting permission + Title string `json:"title"` // Human-readable title Description string `json:"description,omitempty"` // Additional context - Options []PermissionOption `json:"options"` // Available choices + Options []PermissionOption `json:"options"` // Available choices CreatedAt time.Time `json:"created_at"` } // PermissionResponse represents the user's response to a permission request type PermissionResponse struct { - RequestID string `json:"request_id" binding:"required"` // The request being responded to - OptionID string `json:"option_id,omitempty"` // Selected option (if not cancelled) - Cancelled bool `json:"cancelled,omitempty"` // True if user cancelled + RequestID string `json:"request_id" binding:"required"` // The request being responded to + OptionID string `json:"option_id,omitempty"` // Selected option (if not cancelled) + Cancelled bool `json:"cancelled,omitempty"` // True if user cancelled } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 03e88e7c95..e9f03bbddb 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -92,47 +92,48 @@ const ( ActionUserSubscribe = "user.subscribe" ActionUserUnsubscribe = "user.unsubscribe" - // Comment actions - ActionCommentAdd = "comment.add" - ActionCommentGet = "comment.get" - ActionCommentList = "comment.list" + // Message actions + ActionMessageAdd = "message.add" + ActionMessageGet = "message.get" + ActionMessageList = "message.list" // Notification actions (server -> client) - ActionACPProgress = "acp.progress" - ActionACPLog = "acp.log" - ActionACPResult = "acp.result" - ActionACPError = "acp.error" - ActionACPStatus = "acp.status" - ActionACPHeartbeat = "acp.heartbeat" - ActionTaskCreated = "task.created" - ActionTaskUpdated = "task.updated" - ActionTaskDeleted = "task.deleted" - ActionTaskStateChanged = "task.state_changed" - ActionAgentUpdated = "agent.updated" - ActionWorkspaceCreated = "workspace.created" - ActionWorkspaceUpdated = "workspace.updated" - ActionWorkspaceDeleted = "workspace.deleted" - ActionBoardCreated = "board.created" - ActionBoardUpdated = "board.updated" - ActionBoardDeleted = "board.deleted" - ActionColumnCreated = "column.created" - ActionColumnUpdated = "column.updated" - ActionColumnDeleted = "column.deleted" - ActionCommentAdded = "comment.added" - ActionCommentUpdated = "comment.updated" - ActionInputRequested = "input.requested" - ActionRepositoryCreated = "repository.created" - ActionRepositoryUpdated = "repository.updated" - ActionRepositoryDeleted = "repository.deleted" - ActionRepositoryScriptCreated = "repository.script.created" - ActionRepositoryScriptUpdated = "repository.script.updated" - ActionRepositoryScriptDeleted = "repository.script.deleted" - ActionExecutorCreated = "executor.created" - ActionExecutorUpdated = "executor.updated" - ActionExecutorDeleted = "executor.deleted" - ActionEnvironmentCreated = "environment.created" - ActionEnvironmentUpdated = "environment.updated" - ActionEnvironmentDeleted = "environment.deleted" + ActionACPProgress = "acp.progress" + ActionACPLog = "acp.log" + ActionACPResult = "acp.result" + ActionACPError = "acp.error" + ActionACPStatus = "acp.status" + ActionACPHeartbeat = "acp.heartbeat" + ActionTaskCreated = "task.created" + ActionTaskUpdated = "task.updated" + ActionTaskDeleted = "task.deleted" + ActionTaskStateChanged = "task.state_changed" + ActionAgentUpdated = "agent.updated" + ActionWorkspaceCreated = "workspace.created" + ActionWorkspaceUpdated = "workspace.updated" + ActionWorkspaceDeleted = "workspace.deleted" + ActionBoardCreated = "board.created" + ActionBoardUpdated = "board.updated" + ActionBoardDeleted = "board.deleted" + ActionColumnCreated = "column.created" + ActionColumnUpdated = "column.updated" + ActionColumnDeleted = "column.deleted" + ActionMessageAdded = "message.added" + ActionMessageUpdated = "message.updated" + ActionAgentSessionStateChanged = "agent_session.state_changed" + ActionInputRequested = "input.requested" + ActionRepositoryCreated = "repository.created" + ActionRepositoryUpdated = "repository.updated" + ActionRepositoryDeleted = "repository.deleted" + ActionRepositoryScriptCreated = "repository.script.created" + ActionRepositoryScriptUpdated = "repository.script.updated" + ActionRepositoryScriptDeleted = "repository.script.deleted" + ActionExecutorCreated = "executor.created" + ActionExecutorUpdated = "executor.updated" + ActionExecutorDeleted = "executor.deleted" + ActionEnvironmentCreated = "environment.created" + ActionEnvironmentUpdated = "environment.updated" + ActionEnvironmentDeleted = "environment.deleted" ActionAgentProfileDeleted = "agent.profile.deleted" ActionAgentProfileCreated = "agent.profile.created" @@ -148,8 +149,8 @@ const ( ActionWorkspaceFileChanges = "workspace.file.changes" // Notification // User actions - ActionUserGet = "user.get" - ActionUserSettingsUpdate = "user.settings.update" + ActionUserGet = "user.get" + ActionUserSettingsUpdate = "user.settings.update" ActionUserSettingsUpdated = "user.settings.updated" ) diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index 268d562319..922eae1a32 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -5,11 +5,14 @@ import '@git-diff-view/react/styles/diff-view.css'; import { TooltipProvider } from '@kandev/ui/tooltip'; import { TaskTopBar } from '@/components/task/task-top-bar'; import { TaskLayout } from '@/components/task/task-layout'; +import { DebugOverlay } from '@/components/debug-overlay'; import type { Task } from '@/lib/types/http'; +import { DEBUG_UI } from '@/lib/config'; import { getWebSocketClient } from '@/lib/ws/connection'; import { useRepositories } from '@/hooks/use-repositories'; import { useTaskAgent } from '@/hooks/use-task-agent'; -import { useTaskComments } from '@/hooks/use-task-comments'; +import { useTaskMessages } from '@/hooks/use-task-messages'; +import { useAppStore } from '@/components/state-provider'; type TaskPageClientProps = { task: Task | null; @@ -17,11 +20,40 @@ type TaskPageClientProps = { export default function TaskPage({ task: initialTask }: TaskPageClientProps) { const [isMounted, setIsMounted] = useState(false); - const task = initialTask; + const kanbanTask = useAppStore((state) => + initialTask?.id ? state.kanban.tasks.find((item) => item.id === initialTask.id) ?? null : null + ); + const task = useMemo(() => { + if (!initialTask) return null; + if (!kanbanTask) return initialTask; + return { + ...initialTask, + title: kanbanTask.title ?? initialTask.title, + description: kanbanTask.description ?? initialTask.description, + column_id: (kanbanTask.columnId as string | undefined) ?? initialTask.column_id, + position: kanbanTask.position ?? initialTask.position, + state: (kanbanTask.state as Task['state'] | undefined) ?? initialTask.state, + repository_id: (kanbanTask.repositoryId as string | undefined) ?? initialTask.repository_id, + }; + }, [initialTask, kanbanTask]); + const connectionStatus = useAppStore((state) => state.connection.status); // Custom hooks for state management - const { isAgentRunning, isAgentLoading, worktreePath, worktreeBranch, handleStartAgent, handleStopAgent } = useTaskAgent(task); - const { isLoading: isLoadingComments } = useTaskComments(task?.id ?? null); + const { + isAgentRunning, + isAgentLoading, + worktreePath, + worktreeBranch, + agentSessionId, + agentSessionState, + handleStartAgent, + handleStopAgent, + } = useTaskAgent(task); + const isAgentWorking = + agentSessionState !== null + ? agentSessionState === 'STARTING' || agentSessionState === 'RUNNING' + : isAgentRunning && (task?.state === 'IN_PROGRESS' || task?.state === 'SCHEDULING'); + const { isLoading: isLoadingMessages } = useTaskMessages(task?.id ?? null, agentSessionId); useEffect(() => { queueMicrotask(() => setIsMounted(true)); @@ -29,8 +61,8 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) { const { repositories } = useRepositories(task?.workspace_id ?? null, Boolean(task?.workspace_id)); const repository = useMemo( - () => repositories.find((item) => item.local_path === task?.repository_url) ?? null, - [repositories, task?.repository_url] + () => repositories.find((item) => item.id === task?.repository_id) ?? null, + [repositories, task?.repository_id] ); const handleSendMessage = useCallback(async (content: string) => { @@ -39,12 +71,21 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) { const client = getWebSocketClient(); if (!client) return; - try { - await client.request('comment.add', { task_id: task.id, content }, 10000); - } catch (error) { - console.error('Failed to send comment:', error); + if (!agentSessionId) { + console.error('No active agent session. Start an agent before sending a message.'); + return; } - }, [task]); + + try { + await client.request( + 'message.add', + { task_id: task.id, agent_session_id: agentSessionId, content }, + 10000 + ); + } catch (error) { + console.error('Failed to send message:', error); + } + }, [agentSessionId, task]); if (!isMounted) { return
; @@ -53,25 +94,38 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) { return (