Files
kandev/apps/backend/internal/task/service/service.go
T

2038 lines
64 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
v1 "github.com/kandev/kandev/pkg/api/v1"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/worktree"
"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/task/models"
"github.com/kandev/kandev/internal/task/repository"
)
// WorktreeCleanup provides worktree cleanup on task deletion.
type WorktreeCleanup interface {
// OnTaskDeleted is called when a task is deleted to clean up its worktree.
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 (
ErrActiveTaskSessions = errors.New("active agent sessions exist")
ErrInvalidRepositorySettings = errors.New("invalid repository settings")
ErrInvalidExecutorConfig = errors.New("invalid executor config")
)
func validateExecutorConfig(config map[string]string) error {
if config == nil {
return nil
}
policy := strings.TrimSpace(config["mcp_policy"])
if policy == "" {
return nil
}
var decoded any
if err := json.Unmarshal([]byte(policy), &decoded); err != nil {
return fmt.Errorf("%w: mcp_policy must be valid JSON", ErrInvalidExecutorConfig)
}
if _, ok := decoded.(map[string]any); !ok {
return fmt.Errorf("%w: mcp_policy must be a JSON object", ErrInvalidExecutorConfig)
}
return nil
}
// Service provides task business logic
type Service struct {
repo repository.Repository
eventBus bus.EventBus
logger *logger.Logger
discoveryConfig RepositoryDiscoveryConfig
worktreeCleanup WorktreeCleanup
executionStopper TaskExecutionStopper
}
// NewService creates a new task service
func NewService(repo repository.Repository, eventBus bus.EventBus, log *logger.Logger, discoveryConfig RepositoryDiscoveryConfig) *Service {
return &Service{
repo: repo,
eventBus: eventBus,
logger: log,
discoveryConfig: discoveryConfig,
}
}
// SetWorktreeCleanup sets the worktree cleanup handler for task deletion.
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
// TaskRepositoryInput for creating/updating task repositories
type TaskRepositoryInput struct {
RepositoryID string `json:"repository_id"`
BaseBranch string `json:"base_branch"`
LocalPath string `json:"local_path,omitempty"`
Name string `json:"name,omitempty"`
DefaultBranch string `json:"default_branch,omitempty"`
}
// 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"`
Repositories []TaskRepositoryInput `json:"repositories,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"`
Repositories []TaskRepositoryInput `json:"repositories,omitempty"`
Position *int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// CreateBoardRequest contains the data for creating a new board
type CreateBoardRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
}
// UpdateBoardRequest contains the data for updating a board
type UpdateBoardRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
// CreateWorkspaceRequest contains the data for creating a new workspace
type CreateWorkspaceRequest struct {
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
DefaultExecutorID *string `json:"default_executor_id,omitempty"`
DefaultEnvironmentID *string `json:"default_environment_id,omitempty"`
DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"`
}
// UpdateWorkspaceRequest contains the data for updating a workspace
type UpdateWorkspaceRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
DefaultExecutorID *string `json:"default_executor_id,omitempty"`
DefaultEnvironmentID *string `json:"default_environment_id,omitempty"`
DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"`
}
// CreateColumnRequest contains the data for creating a new column
type CreateColumnRequest struct {
BoardID string `json:"board_id"`
Name string `json:"name"`
Position int `json:"position"`
State v1.TaskState `json:"state"`
Color string `json:"color"`
}
// UpdateColumnRequest contains the data for updating a column
type UpdateColumnRequest struct {
Name *string `json:"name,omitempty"`
Position *int `json:"position,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
Color *string `json:"color,omitempty"`
}
// CreateRepositoryRequest contains the data for creating a new repository
type CreateRepositoryRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
SourceType string `json:"source_type"`
LocalPath string `json:"local_path"`
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"`
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
}
// UpdateRepositoryRequest contains the data for updating a repository
type UpdateRepositoryRequest struct {
Name *string `json:"name,omitempty"`
SourceType *string `json:"source_type,omitempty"`
LocalPath *string `json:"local_path,omitempty"`
Provider *string `json:"provider,omitempty"`
ProviderRepoID *string `json:"provider_repo_id,omitempty"`
ProviderOwner *string `json:"provider_owner,omitempty"`
ProviderName *string `json:"provider_name,omitempty"`
DefaultBranch *string `json:"default_branch,omitempty"`
WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"`
SetupScript *string `json:"setup_script,omitempty"`
CleanupScript *string `json:"cleanup_script,omitempty"`
}
// CreateExecutorRequest contains the data for creating an executor
type CreateExecutorRequest struct {
Name string `json:"name"`
Type models.ExecutorType `json:"type"`
Status models.ExecutorStatus `json:"status"`
IsSystem bool `json:"is_system"`
Resumable bool `json:"resumable"`
Config map[string]string `json:"config,omitempty"`
}
// UpdateExecutorRequest contains the data for updating an executor
type UpdateExecutorRequest struct {
Name *string `json:"name,omitempty"`
Type *models.ExecutorType `json:"type,omitempty"`
Status *models.ExecutorStatus `json:"status,omitempty"`
Resumable *bool `json:"resumable,omitempty"`
Config map[string]string `json:"config,omitempty"`
}
// CreateEnvironmentRequest contains the data for creating an environment
type CreateEnvironmentRequest struct {
Name string `json:"name"`
Kind models.EnvironmentKind `json:"kind"`
WorktreeRoot string `json:"worktree_root,omitempty"`
ImageTag string `json:"image_tag,omitempty"`
Dockerfile string `json:"dockerfile,omitempty"`
BuildConfig map[string]string `json:"build_config,omitempty"`
}
// UpdateEnvironmentRequest contains the data for updating an environment
type UpdateEnvironmentRequest struct {
Name *string `json:"name,omitempty"`
Kind *models.EnvironmentKind `json:"kind,omitempty"`
WorktreeRoot *string `json:"worktree_root,omitempty"`
ImageTag *string `json:"image_tag,omitempty"`
Dockerfile *string `json:"dockerfile,omitempty"`
BuildConfig map[string]string `json:"build_config,omitempty"`
}
type ListMessagesRequest struct {
TaskSessionID string
Limit int
Before string
After string
Sort string
}
// CreateRepositoryScriptRequest contains the data for creating a repository script
type CreateRepositoryScriptRequest struct {
RepositoryID string `json:"repository_id"`
Name string `json:"name"`
Command string `json:"command"`
Position int `json:"position"`
}
// UpdateRepositoryScriptRequest contains the data for updating a repository script
type UpdateRepositoryScriptRequest struct {
Name *string `json:"name,omitempty"`
Command *string `json:"command,omitempty"`
Position *int `json:"position,omitempty"`
}
// Task operations
// CreateTask creates a new task and publishes a task.created event
func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) {
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,
Position: req.Position,
Metadata: req.Metadata,
}
if err := s.repo.CreateTask(ctx, task); err != nil {
s.logger.Error("failed to create task", zap.Error(err))
return nil, err
}
// Create task-repository associations
var repoByPath map[string]*models.Repository
for _, repoInput := range req.Repositories {
if repoInput.RepositoryID == "" && repoInput.LocalPath != "" {
repos, err := s.repo.ListRepositories(ctx, req.WorkspaceID)
if err != nil {
s.logger.Error("failed to list repositories", zap.Error(err))
return nil, err
}
repoByPath = make(map[string]*models.Repository, len(repos))
for _, repo := range repos {
if repo.LocalPath == "" {
continue
}
repoByPath[repo.LocalPath] = repo
}
break
}
}
for i, repoInput := range req.Repositories {
repositoryID := repoInput.RepositoryID
baseBranch := repoInput.BaseBranch
if repositoryID == "" && repoInput.LocalPath != "" {
repo := repoByPath[repoInput.LocalPath]
if repo == nil {
name := strings.TrimSpace(repoInput.Name)
if name == "" {
name = filepath.Base(repoInput.LocalPath)
}
defaultBranch := repoInput.DefaultBranch
if defaultBranch == "" {
defaultBranch = repoInput.BaseBranch
}
created, err := s.CreateRepository(ctx, &CreateRepositoryRequest{
WorkspaceID: req.WorkspaceID,
Name: name,
SourceType: "local",
LocalPath: repoInput.LocalPath,
DefaultBranch: defaultBranch,
})
if err != nil {
return nil, err
}
repo = created
repoByPath[repoInput.LocalPath] = repo
}
repositoryID = repo.ID
if baseBranch == "" {
baseBranch = repo.DefaultBranch
}
}
if repositoryID == "" {
return nil, fmt.Errorf("repository_id is required")
}
taskRepo := &models.TaskRepository{
TaskID: task.ID,
RepositoryID: repositoryID,
BaseBranch: baseBranch,
Position: i,
Metadata: make(map[string]interface{}),
}
if err := s.repo.CreateTaskRepository(ctx, taskRepo); err != nil {
s.logger.Error("failed to create task repository", zap.Error(err))
return nil, err
}
}
// Load repositories into task for response
repos, err := s.repo.ListTaskRepositories(ctx, task.ID)
if err != nil {
s.logger.Error("failed to list task repositories", zap.Error(err))
} else {
task.Repositories = repos
}
s.publishTaskEvent(ctx, events.TaskCreated, task, nil)
s.logger.Info("task created", zap.String("task_id", task.ID), zap.String("title", task.Title))
return task, nil
}
// GetTask retrieves a task by ID and populates repositories
func (s *Service) GetTask(ctx context.Context, id string) (*models.Task, error) {
task, err := s.repo.GetTask(ctx, id)
if err != nil {
return nil, err
}
// Load task repositories
repos, err := s.repo.ListTaskRepositories(ctx, id)
if err != nil {
s.logger.Error("failed to list task repositories", zap.Error(err))
} else {
task.Repositories = repos
}
return task, nil
}
// UpdateTask updates an existing task and publishes a task.updated event
func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequest) (*models.Task, error) {
task, err := s.repo.GetTask(ctx, id)
if err != nil {
return nil, err
}
var oldState *v1.TaskState
stateChanged := false
if req.Title != nil {
task.Title = *req.Title
}
if req.Description != nil {
task.Description = *req.Description
}
if req.Priority != nil {
task.Priority = *req.Priority
}
if req.State != nil && task.State != *req.State {
current := task.State
oldState = &current
task.State = *req.State
stateChanged = true
}
if req.Position != nil {
task.Position = *req.Position
}
if req.Metadata != nil {
task.Metadata = req.Metadata
}
task.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateTask(ctx, task); err != nil {
s.logger.Error("failed to update task", zap.String("task_id", id), zap.Error(err))
return nil, err
}
// Update task repositories if provided
if req.Repositories != nil {
// Delete existing repositories
if err := s.repo.DeleteTaskRepositoriesByTask(ctx, id); err != nil {
s.logger.Error("failed to delete task repositories", zap.Error(err))
return nil, err
}
// Create new repositories
for i, repoInput := range req.Repositories {
taskRepo := &models.TaskRepository{
TaskID: task.ID,
RepositoryID: repoInput.RepositoryID,
BaseBranch: repoInput.BaseBranch,
Position: i,
Metadata: make(map[string]interface{}),
}
if err := s.repo.CreateTaskRepository(ctx, taskRepo); err != nil {
s.logger.Error("failed to create task repository", zap.Error(err))
return nil, err
}
}
}
// Load repositories into task for response
repos, err := s.repo.ListTaskRepositories(ctx, task.ID)
if err != nil {
s.logger.Error("failed to list task repositories", zap.Error(err))
} else {
task.Repositories = repos
}
if stateChanged && oldState != nil {
s.publishTaskEvent(ctx, events.TaskStateChanged, task, oldState)
}
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
s.logger.Info("task updated", zap.String("task_id", task.ID))
return task, nil
}
// DeleteTask deletes a task and publishes a task.deleted event
func (s *Service) DeleteTask(ctx context.Context, id string) error {
task, err := s.repo.GetTask(ctx, id)
if err != nil {
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 {
if err := s.worktreeCleanup.OnTaskDeleted(ctx, id); err != nil {
s.logger.Warn("failed to cleanup worktree on task deletion",
zap.String("task_id", id),
zap.Error(err))
}
}
sessions, err := s.repo.ListTaskSessions(ctx, id)
if err != nil {
s.logger.Warn("failed to list task sessions for delete",
zap.String("task_id", id),
zap.Error(err))
} else {
for _, session := range sessions {
if session == nil || session.ID == "" {
continue
}
if err := s.repo.DeleteExecutorRunningBySessionID(ctx, session.ID); err != nil {
s.logger.Debug("failed to delete executor runtime for session",
zap.String("task_id", id),
zap.String("session_id", session.ID),
zap.Error(err))
}
if err := s.repo.DeleteTaskSessionWorktreesBySession(ctx, session.ID); err != nil {
s.logger.Warn("failed to delete session worktrees",
zap.String("task_id", id),
zap.String("session_id", session.ID),
zap.Error(err))
}
if err := s.repo.DeleteTaskSession(ctx, session.ID); err != nil {
s.logger.Warn("failed to delete task session",
zap.String("task_id", id),
zap.String("session_id", session.ID),
zap.Error(err))
}
}
}
if err := s.repo.DeleteTask(ctx, id); err != nil {
s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err))
return err
}
s.publishTaskEvent(ctx, events.TaskDeleted, task, nil)
s.logger.Info("task deleted", zap.String("task_id", id))
return nil
}
// ListTasks returns all tasks for a board
func (s *Service) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) {
tasks, err := s.repo.ListTasks(ctx, boardID)
if err != nil {
return nil, err
}
// Load repositories for each task
for _, task := range tasks {
repos, err := s.repo.ListTaskRepositories(ctx, task.ID)
if err != nil {
s.logger.Error("failed to list task repositories", zap.String("task_id", task.ID), zap.Error(err))
} else {
task.Repositories = repos
}
}
return tasks, nil
}
// ListTaskSessions returns all sessions for a task.
func (s *Service) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) {
return s.repo.ListTaskSessions(ctx, taskID)
}
// GetTaskSession returns a single session by ID.
func (s *Service) GetTaskSession(ctx context.Context, sessionID string) (*models.TaskSession, error) {
return s.repo.GetTaskSession(ctx, sessionID)
}
// UpdateTaskState updates the state of a task, moves it to the matching column,
// and publishes a task.state_changed event
func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) {
task, err := s.repo.GetTask(ctx, id)
if err != nil {
return nil, err
}
oldState := task.State
if err := s.repo.UpdateTaskState(ctx, id, state); err != nil {
s.logger.Error("failed to update task state", zap.String("task_id", id), zap.Error(err))
return nil, err
}
// Find the column that maps to the new state and move the task there
// This ensures the Kanban board stays in sync with task state
if task.BoardID != "" {
column, err := s.repo.GetColumnByState(ctx, task.BoardID, state)
if err != nil {
s.logger.Warn("no column found for state, task will stay in current column",
zap.String("task_id", id),
zap.String("state", string(state)),
zap.Error(err))
} else if column.ID != task.ColumnID {
// Move task to the new column (keep it at position 0 - top of the column)
if err := s.repo.AddTaskToBoard(ctx, id, task.BoardID, column.ID, 0); err != nil {
s.logger.Error("failed to move task to new column",
zap.String("task_id", id),
zap.String("column_id", column.ID),
zap.Error(err))
} else {
s.logger.Info("task moved to column matching new state",
zap.String("task_id", id),
zap.String("column_id", column.ID),
zap.String("column_name", column.Name),
zap.String("state", string(state)))
}
}
}
// Reload task to get updated state and column
task, err = s.repo.GetTask(ctx, id)
if err != nil {
return nil, err
}
s.logger.Info("task reloaded after column move",
zap.String("task_id", id),
zap.String("column_id", task.ColumnID),
zap.String("state", string(task.State)))
s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState)
s.logger.Info("task state changed",
zap.String("task_id", id),
zap.String("old_state", string(oldState)),
zap.String("new_state", string(state)))
return task, nil
}
// UpdateTaskMetadata updates only the metadata of a task (merges with existing)
func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata map[string]interface{}) (*models.Task, error) {
task, err := s.repo.GetTask(ctx, id)
if err != nil {
return nil, err
}
// Merge metadata (existing keys are preserved, new keys are added/updated)
if task.Metadata == nil {
task.Metadata = make(map[string]interface{})
}
for k, v := range metadata {
task.Metadata[k] = v
}
task.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateTask(ctx, task); err != nil {
s.logger.Error("failed to update task metadata", zap.String("task_id", id), zap.Error(err))
return nil, err
}
s.logger.Debug("task metadata updated", zap.String("task_id", id), zap.Any("metadata", metadata))
return task, nil
}
// MoveTask moves a task to a different column and position
func (s *Service) MoveTask(ctx context.Context, id string, boardID string, columnID string, position int) (*models.Task, error) {
task, err := s.repo.GetTask(ctx, id)
if err != nil {
return nil, err
}
// Get the column to determine the new state
column, err := s.repo.GetColumn(ctx, columnID)
if err != nil {
return nil, err
}
oldState := task.State
task.BoardID = boardID
task.ColumnID = columnID
task.Position = position
task.State = column.State
task.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateTask(ctx, task); err != nil {
s.logger.Error("failed to move task", zap.String("task_id", id), zap.Error(err))
return nil, err
}
// Publish state_changed event if state changed, otherwise just updated
if oldState != task.State {
s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState)
} else {
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
}
s.logger.Info("task moved",
zap.String("task_id", id),
zap.String("board_id", boardID),
zap.String("column_id", columnID),
zap.Int("position", position))
return task, nil
}
// Workspace operations
// CreateWorkspace creates a new workspace
func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceRequest) (*models.Workspace, error) {
workspace := &models.Workspace{
ID: uuid.New().String(),
Name: req.Name,
Description: req.Description,
OwnerID: req.OwnerID,
DefaultExecutorID: normalizeOptionalID(req.DefaultExecutorID),
DefaultEnvironmentID: normalizeOptionalID(req.DefaultEnvironmentID),
DefaultAgentProfileID: normalizeOptionalID(req.DefaultAgentProfileID),
}
if err := s.repo.CreateWorkspace(ctx, workspace); err != nil {
s.logger.Error("failed to create workspace", zap.Error(err))
return nil, err
}
s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace)
s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name))
return workspace, nil
}
// GetWorkspace retrieves a workspace by ID
func (s *Service) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) {
return s.repo.GetWorkspace(ctx, id)
}
// UpdateWorkspace updates an existing workspace
func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWorkspaceRequest) (*models.Workspace, error) {
workspace, err := s.repo.GetWorkspace(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
workspace.Name = *req.Name
}
if req.Description != nil {
workspace.Description = *req.Description
}
if req.DefaultExecutorID != nil {
workspace.DefaultExecutorID = normalizeOptionalID(req.DefaultExecutorID)
}
if req.DefaultEnvironmentID != nil {
workspace.DefaultEnvironmentID = normalizeOptionalID(req.DefaultEnvironmentID)
}
if req.DefaultAgentProfileID != nil {
workspace.DefaultAgentProfileID = normalizeOptionalID(req.DefaultAgentProfileID)
}
workspace.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateWorkspace(ctx, workspace); err != nil {
s.logger.Error("failed to update workspace", zap.String("workspace_id", id), zap.Error(err))
return nil, err
}
s.publishWorkspaceEvent(ctx, events.WorkspaceUpdated, workspace)
s.logger.Info("workspace updated", zap.String("workspace_id", workspace.ID))
return workspace, nil
}
// DeleteWorkspace deletes a workspace
func (s *Service) DeleteWorkspace(ctx context.Context, id string) error {
workspace, err := s.repo.GetWorkspace(ctx, id)
if err != nil {
return err
}
if err := s.repo.DeleteWorkspace(ctx, id); err != nil {
s.logger.Error("failed to delete workspace", zap.String("workspace_id", id), zap.Error(err))
return err
}
s.publishWorkspaceEvent(ctx, events.WorkspaceDeleted, workspace)
s.logger.Info("workspace deleted", zap.String("workspace_id", id))
return nil
}
func normalizeOptionalID(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
// ListWorkspaces returns all workspaces
func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) {
return s.repo.ListWorkspaces(ctx)
}
// Board operations
// CreateBoard creates a new board
func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*models.Board, error) {
board := &models.Board{
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
}
if err := s.repo.CreateBoard(ctx, board); err != nil {
s.logger.Error("failed to create board", zap.Error(err))
return nil, err
}
s.publishBoardEvent(ctx, events.BoardCreated, board)
s.logger.Info("board created", zap.String("board_id", board.ID), zap.String("name", board.Name))
return board, nil
}
// GetBoard retrieves a board by ID
func (s *Service) GetBoard(ctx context.Context, id string) (*models.Board, error) {
return s.repo.GetBoard(ctx, id)
}
// UpdateBoard updates an existing board
func (s *Service) UpdateBoard(ctx context.Context, id string, req *UpdateBoardRequest) (*models.Board, error) {
board, err := s.repo.GetBoard(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
board.Name = *req.Name
}
if req.Description != nil {
board.Description = *req.Description
}
board.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateBoard(ctx, board); err != nil {
s.logger.Error("failed to update board", zap.String("board_id", id), zap.Error(err))
return nil, err
}
s.publishBoardEvent(ctx, events.BoardUpdated, board)
s.logger.Info("board updated", zap.String("board_id", board.ID))
return board, nil
}
// DeleteBoard deletes a board
func (s *Service) DeleteBoard(ctx context.Context, id string) error {
board, err := s.repo.GetBoard(ctx, id)
if err != nil {
return err
}
if err := s.repo.DeleteBoard(ctx, id); err != nil {
s.logger.Error("failed to delete board", zap.String("board_id", id), zap.Error(err))
return err
}
s.publishBoardEvent(ctx, events.BoardDeleted, board)
s.logger.Info("board deleted", zap.String("board_id", id))
return nil
}
// ListBoards returns all boards for a workspace (or all if empty)
func (s *Service) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) {
return s.repo.ListBoards(ctx, workspaceID)
}
// Column operations
// CreateColumn creates a new column
func (s *Service) CreateColumn(ctx context.Context, req *CreateColumnRequest) (*models.Column, error) {
color := req.Color
if color == "" {
color = "bg-neutral-400"
}
column := &models.Column{
ID: uuid.New().String(),
BoardID: req.BoardID,
Name: req.Name,
Position: req.Position,
State: req.State,
Color: color,
}
if err := s.repo.CreateColumn(ctx, column); err != nil {
s.logger.Error("failed to create column", zap.Error(err))
return nil, err
}
s.publishColumnEvent(ctx, events.ColumnCreated, column)
s.logger.Info("column created",
zap.String("column_id", column.ID),
zap.String("board_id", column.BoardID),
zap.String("name", column.Name))
return column, nil
}
// GetColumn retrieves a column by ID
func (s *Service) GetColumn(ctx context.Context, id string) (*models.Column, error) {
return s.repo.GetColumn(ctx, id)
}
// ListColumns returns all columns for a board
func (s *Service) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
return s.repo.ListColumns(ctx, boardID)
}
// UpdateColumn updates an existing column
func (s *Service) UpdateColumn(ctx context.Context, id string, req *UpdateColumnRequest) (*models.Column, error) {
column, err := s.repo.GetColumn(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
column.Name = *req.Name
}
if req.Position != nil {
column.Position = *req.Position
}
if req.State != nil {
column.State = *req.State
}
if req.Color != nil {
column.Color = *req.Color
}
column.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateColumn(ctx, column); err != nil {
s.logger.Error("failed to update column", zap.Error(err))
return nil, err
}
s.publishColumnEvent(ctx, events.ColumnUpdated, column)
s.logger.Info("column updated",
zap.String("column_id", column.ID),
zap.String("board_id", column.BoardID),
zap.String("name", column.Name))
return column, nil
}
// DeleteColumn deletes an existing column
func (s *Service) DeleteColumn(ctx context.Context, id string) error {
column, err := s.repo.GetColumn(ctx, id)
if err != nil {
return err
}
if err := s.repo.DeleteColumn(ctx, id); err != nil {
s.logger.Error("failed to delete column", zap.Error(err))
return err
}
s.publishColumnEvent(ctx, events.ColumnDeleted, column)
s.logger.Info("column deleted", zap.String("column_id", id))
return nil
}
// Repository operations
func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryRequest) (*models.Repository, error) {
sourceType := req.SourceType
if sourceType == "" {
sourceType = "local"
}
prefix := strings.TrimSpace(req.WorktreeBranchPrefix)
if err := worktree.ValidateBranchPrefix(prefix); err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err)
}
if prefix == "" {
prefix = worktree.DefaultBranchPrefix
}
repository := &models.Repository{
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
Name: req.Name,
SourceType: sourceType,
LocalPath: req.LocalPath,
Provider: req.Provider,
ProviderRepoID: req.ProviderRepoID,
ProviderOwner: req.ProviderOwner,
ProviderName: req.ProviderName,
DefaultBranch: req.DefaultBranch,
WorktreeBranchPrefix: prefix,
SetupScript: req.SetupScript,
CleanupScript: req.CleanupScript,
}
if err := s.repo.CreateRepository(ctx, repository); err != nil {
s.logger.Error("failed to create repository", zap.Error(err))
return nil, err
}
s.publishRepositoryEvent(ctx, events.RepositoryCreated, repository)
s.logger.Info("repository created", zap.String("repository_id", repository.ID))
return repository, nil
}
func (s *Service) GetRepository(ctx context.Context, id string) (*models.Repository, error) {
return s.repo.GetRepository(ctx, id)
}
func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRepositoryRequest) (*models.Repository, error) {
repository, err := s.repo.GetRepository(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
repository.Name = *req.Name
}
if req.SourceType != nil {
repository.SourceType = *req.SourceType
}
if req.LocalPath != nil {
repository.LocalPath = *req.LocalPath
}
if req.Provider != nil {
repository.Provider = *req.Provider
}
if req.ProviderRepoID != nil {
repository.ProviderRepoID = *req.ProviderRepoID
}
if req.ProviderOwner != nil {
repository.ProviderOwner = *req.ProviderOwner
}
if req.ProviderName != nil {
repository.ProviderName = *req.ProviderName
}
if req.DefaultBranch != nil {
repository.DefaultBranch = *req.DefaultBranch
}
if req.WorktreeBranchPrefix != nil {
prefix := strings.TrimSpace(*req.WorktreeBranchPrefix)
if err := worktree.ValidateBranchPrefix(prefix); err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err)
}
repository.WorktreeBranchPrefix = prefix
}
if req.SetupScript != nil {
repository.SetupScript = *req.SetupScript
}
if req.CleanupScript != nil {
repository.CleanupScript = *req.CleanupScript
}
repository.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateRepository(ctx, repository); err != nil {
s.logger.Error("failed to update repository", zap.String("repository_id", id), zap.Error(err))
return nil, err
}
s.publishRepositoryEvent(ctx, events.RepositoryUpdated, repository)
s.logger.Info("repository updated", zap.String("repository_id", repository.ID))
return repository, nil
}
func (s *Service) DeleteRepository(ctx context.Context, id string) error {
repository, err := s.repo.GetRepository(ctx, id)
if err != nil {
return err
}
active, err := s.repo.HasActiveTaskSessionsByRepository(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 ErrActiveTaskSessions
}
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
}
s.publishRepositoryEvent(ctx, events.RepositoryDeleted, repository)
s.logger.Info("repository deleted", zap.String("repository_id", id))
return nil
}
func (s *Service) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) {
return s.repo.ListRepositories(ctx, workspaceID)
}
// Repository script operations
func (s *Service) CreateRepositoryScript(ctx context.Context, req *CreateRepositoryScriptRequest) (*models.RepositoryScript, error) {
script := &models.RepositoryScript{
ID: uuid.New().String(),
RepositoryID: req.RepositoryID,
Name: req.Name,
Command: req.Command,
Position: req.Position,
}
if err := s.repo.CreateRepositoryScript(ctx, script); err != nil {
s.logger.Error("failed to create repository script", zap.Error(err))
return nil, err
}
s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptCreated, script)
s.logger.Info("repository script created", zap.String("script_id", script.ID))
return script, nil
}
func (s *Service) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) {
return s.repo.GetRepositoryScript(ctx, id)
}
func (s *Service) UpdateRepositoryScript(ctx context.Context, id string, req *UpdateRepositoryScriptRequest) (*models.RepositoryScript, error) {
script, err := s.repo.GetRepositoryScript(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
script.Name = *req.Name
}
if req.Command != nil {
script.Command = *req.Command
}
if req.Position != nil {
script.Position = *req.Position
}
script.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateRepositoryScript(ctx, script); err != nil {
s.logger.Error("failed to update repository script", zap.String("script_id", id), zap.Error(err))
return nil, err
}
s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptUpdated, script)
s.logger.Info("repository script updated", zap.String("script_id", script.ID))
return script, nil
}
func (s *Service) DeleteRepositoryScript(ctx context.Context, id string) error {
script, err := s.repo.GetRepositoryScript(ctx, id)
if err != nil {
return err
}
if err := s.repo.DeleteRepositoryScript(ctx, id); err != nil {
s.logger.Error("failed to delete repository script", zap.String("script_id", id), zap.Error(err))
return err
}
s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptDeleted, script)
s.logger.Info("repository script deleted", zap.String("script_id", id))
return nil
}
func (s *Service) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) {
return s.repo.ListRepositoryScripts(ctx, repositoryID)
}
// Executor operations
func (s *Service) CreateExecutor(ctx context.Context, req *CreateExecutorRequest) (*models.Executor, error) {
if err := validateExecutorConfig(req.Config); err != nil {
return nil, err
}
executor := &models.Executor{
ID: uuid.New().String(),
Name: req.Name,
Type: req.Type,
Status: req.Status,
IsSystem: req.IsSystem,
Resumable: req.Resumable,
Config: req.Config,
}
if err := s.repo.CreateExecutor(ctx, executor); err != nil {
return nil, err
}
s.publishExecutorEvent(ctx, events.ExecutorCreated, executor)
return executor, nil
}
func (s *Service) GetExecutor(ctx context.Context, id string) (*models.Executor, error) {
return s.repo.GetExecutor(ctx, id)
}
func (s *Service) UpdateExecutor(ctx context.Context, id string, req *UpdateExecutorRequest) (*models.Executor, error) {
executor, err := s.repo.GetExecutor(ctx, id)
if err != nil {
return nil, err
}
if req.Config != nil {
if err := validateExecutorConfig(req.Config); err != nil {
return nil, err
}
}
if executor.IsSystem {
if req.Name != nil && *req.Name != executor.Name {
return nil, fmt.Errorf("system executors cannot be modified")
}
if req.Type != nil && *req.Type != executor.Type {
return nil, fmt.Errorf("system executors cannot be modified")
}
if req.Status != nil && *req.Status != executor.Status {
return nil, fmt.Errorf("system executors cannot be modified")
}
if req.Resumable != nil && *req.Resumable != executor.Resumable {
return nil, fmt.Errorf("system executors cannot be modified")
}
}
if req.Name != nil {
executor.Name = *req.Name
}
if req.Type != nil {
executor.Type = *req.Type
}
if req.Status != nil {
executor.Status = *req.Status
}
if req.Resumable != nil {
executor.Resumable = *req.Resumable
}
if req.Config != nil {
executor.Config = req.Config
}
executor.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateExecutor(ctx, executor); err != nil {
return nil, err
}
s.publishExecutorEvent(ctx, events.ExecutorUpdated, executor)
return executor, nil
}
func (s *Service) DeleteExecutor(ctx context.Context, id string) error {
executor, err := s.repo.GetExecutor(ctx, id)
if err != nil {
return err
}
if executor.IsSystem {
return fmt.Errorf("system executors cannot be deleted")
}
active, err := s.repo.HasActiveTaskSessionsByExecutor(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 ErrActiveTaskSessions
}
if err := s.repo.DeleteExecutor(ctx, id); err != nil {
return err
}
s.publishExecutorEvent(ctx, events.ExecutorDeleted, executor)
return nil
}
func (s *Service) ListExecutors(ctx context.Context) ([]*models.Executor, error) {
return s.repo.ListExecutors(ctx)
}
// Environment operations
func (s *Service) CreateEnvironment(ctx context.Context, req *CreateEnvironmentRequest) (*models.Environment, error) {
environment := &models.Environment{
ID: uuid.New().String(),
Name: req.Name,
Kind: req.Kind,
IsSystem: false,
WorktreeRoot: req.WorktreeRoot,
ImageTag: req.ImageTag,
Dockerfile: req.Dockerfile,
BuildConfig: req.BuildConfig,
}
if err := s.repo.CreateEnvironment(ctx, environment); err != nil {
return nil, err
}
s.publishEnvironmentEvent(ctx, events.EnvironmentCreated, environment)
return environment, nil
}
func (s *Service) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) {
return s.repo.GetEnvironment(ctx, id)
}
func (s *Service) UpdateEnvironment(ctx context.Context, id string, req *UpdateEnvironmentRequest) (*models.Environment, error) {
environment, err := s.repo.GetEnvironment(ctx, id)
if err != nil {
return nil, err
}
if environment.IsSystem {
if req.Name != nil || req.Kind != nil || req.ImageTag != nil || req.Dockerfile != nil || req.BuildConfig != nil {
return nil, fmt.Errorf("system environments can only update the worktree root")
}
}
if req.Name != nil {
environment.Name = *req.Name
}
if req.Kind != nil {
environment.Kind = *req.Kind
}
if req.WorktreeRoot != nil {
environment.WorktreeRoot = *req.WorktreeRoot
}
if req.ImageTag != nil {
environment.ImageTag = *req.ImageTag
}
if req.Dockerfile != nil {
environment.Dockerfile = *req.Dockerfile
}
if req.BuildConfig != nil {
environment.BuildConfig = req.BuildConfig
}
environment.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateEnvironment(ctx, environment); err != nil {
return nil, err
}
s.publishEnvironmentEvent(ctx, events.EnvironmentUpdated, environment)
return environment, nil
}
func (s *Service) DeleteEnvironment(ctx context.Context, id string) error {
environment, err := s.repo.GetEnvironment(ctx, id)
if err != nil {
return err
}
if environment.IsSystem {
return fmt.Errorf("system environments cannot be deleted")
}
active, err := s.repo.HasActiveTaskSessionsByEnvironment(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 ErrActiveTaskSessions
}
if err := s.repo.DeleteEnvironment(ctx, id); err != nil {
return err
}
s.publishEnvironmentEvent(ctx, events.EnvironmentDeleted, environment)
return nil
}
func (s *Service) ListEnvironments(ctx context.Context) ([]*models.Environment, error) {
return s.repo.ListEnvironments(ctx)
}
// publishTaskEvent publishes task events to the event bus
func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) {
if s.eventBus == nil {
return
}
data := map[string]interface{}{
"task_id": task.ID,
"board_id": task.BoardID,
"column_id": task.ColumnID,
"title": task.Title,
"description": task.Description,
"state": string(task.State),
"priority": task.Priority,
"position": task.Position,
"created_at": task.CreatedAt.Format(time.RFC3339),
"updated_at": task.UpdatedAt.Format(time.RFC3339),
}
if task.Metadata != nil {
data["metadata"] = task.Metadata
}
if oldState != nil {
data["old_state"] = string(*oldState)
data["new_state"] = string(task.State)
}
event := bus.NewEvent(eventType, "task-service", data)
// Debug logging for state changes
s.logger.Info("publishing task event",
zap.String("event_type", eventType),
zap.String("task_id", task.ID),
zap.String("board_id", task.BoardID),
zap.String("column_id", task.ColumnID),
zap.String("state", string(task.State)))
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish task event",
zap.String("event_type", eventType),
zap.String("task_id", task.ID),
zap.Error(err))
}
}
func (s *Service) publishWorkspaceEvent(ctx context.Context, eventType string, workspace *models.Workspace) {
if s.eventBus == nil || workspace == nil {
return
}
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,
"default_agent_profile_id": workspace.DefaultAgentProfileID,
"created_at": workspace.CreatedAt.Format(time.RFC3339),
"updated_at": workspace.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish workspace event",
zap.String("event_type", eventType),
zap.String("workspace_id", workspace.ID),
zap.Error(err))
}
}
func (s *Service) publishBoardEvent(ctx context.Context, eventType string, board *models.Board) {
if s.eventBus == nil || board == nil {
return
}
data := map[string]interface{}{
"id": board.ID,
"workspace_id": board.WorkspaceID,
"name": board.Name,
"description": board.Description,
"created_at": board.CreatedAt.Format(time.RFC3339),
"updated_at": board.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish board event",
zap.String("event_type", eventType),
zap.String("board_id", board.ID),
zap.Error(err))
}
}
func (s *Service) publishColumnEvent(ctx context.Context, eventType string, column *models.Column) {
if s.eventBus == nil || column == nil {
return
}
data := map[string]interface{}{
"id": column.ID,
"board_id": column.BoardID,
"name": column.Name,
"position": column.Position,
"state": string(column.State),
"color": column.Color,
"created_at": column.CreatedAt.Format(time.RFC3339),
"updated_at": column.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish column event",
zap.String("event_type", eventType),
zap.String("column_id", column.ID),
zap.Error(err))
}
}
func (s *Service) publishExecutorEvent(ctx context.Context, eventType string, executor *models.Executor) {
if s.eventBus == nil || executor == nil {
return
}
data := map[string]interface{}{
"id": executor.ID,
"name": executor.Name,
"type": executor.Type,
"status": executor.Status,
"is_system": executor.IsSystem,
"resumable": executor.Resumable,
"config": executor.Config,
"created_at": executor.CreatedAt.Format(time.RFC3339),
"updated_at": executor.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish executor event",
zap.String("event_type", eventType),
zap.String("executor_id", executor.ID),
zap.Error(err))
}
}
func (s *Service) publishEnvironmentEvent(ctx context.Context, eventType string, environment *models.Environment) {
if s.eventBus == nil || environment == nil {
return
}
data := map[string]interface{}{
"id": environment.ID,
"name": environment.Name,
"kind": environment.Kind,
"is_system": environment.IsSystem,
"worktree_root": environment.WorktreeRoot,
"image_tag": environment.ImageTag,
"dockerfile": environment.Dockerfile,
"build_config": environment.BuildConfig,
"created_at": environment.CreatedAt.Format(time.RFC3339),
"updated_at": environment.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish environment event",
zap.String("event_type", eventType),
zap.String("environment_id", environment.ID),
zap.Error(err))
}
}
// Message operations
// CreateMessageRequest contains the data for creating a new message
type CreateMessageRequest struct {
TaskSessionID string `json:"session_id"`
TaskID string `json:"task_id,omitempty"`
TurnID string `json:"turn_id"`
Content string `json:"content"`
AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user"
AuthorID string `json:"author_id,omitempty"`
RequestsInput bool `json:"requests_input,omitempty"`
Type string `json:"type,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// 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.GetTaskSession(ctx, req.TaskSessionID)
if err != nil {
return nil, err
}
authorType := models.MessageAuthorUser
if req.AuthorType == "agent" {
authorType = models.MessageAuthorAgent
}
messageType := models.MessageType(req.Type)
if messageType == "" {
messageType = models.MessageTypeMessage
}
taskID := req.TaskID
if taskID == "" && session != nil {
taskID = session.TaskID
}
// Ensure we have a turn ID - get active turn or start a new one
turnID := req.TurnID
if turnID == "" {
turn, err := s.getOrStartTurn(ctx, req.TaskSessionID)
if err != nil {
s.logger.Warn("failed to get or start turn for message",
zap.String("session_id", req.TaskSessionID),
zap.Error(err))
// Continue with empty turn ID - will fail on foreign key if turn is required
} else if turn != nil {
turnID = turn.ID
}
}
message := &models.Message{
ID: uuid.New().String(),
TaskSessionID: req.TaskSessionID,
TaskID: taskID,
TurnID: turnID,
AuthorType: authorType,
AuthorID: req.AuthorID,
Content: req.Content,
Type: messageType,
Metadata: req.Metadata,
RequestsInput: req.RequestsInput,
CreatedAt: time.Now().UTC(),
}
if err := s.repo.CreateMessage(ctx, message); err != nil {
s.logger.Error("failed to create message", zap.Error(err))
return nil, err
}
// Publish message.added event
s.publishMessageEvent(ctx, events.MessageAdded, message)
s.logger.Info("message created",
zap.String("message_id", message.ID),
zap.String("session_id", message.TaskSessionID),
zap.String("author_type", string(message.AuthorType)))
return message, nil
}
// CreateMessageWithID creates a new message with a pre-generated ID.
// This is used for streaming messages where the ID is generated by the caller.
func (s *Service) CreateMessageWithID(ctx context.Context, id string, req *CreateMessageRequest) (*models.Message, error) {
session, err := s.repo.GetTaskSession(ctx, req.TaskSessionID)
if err != nil {
return nil, err
}
authorType := models.MessageAuthorUser
if req.AuthorType == "agent" {
authorType = models.MessageAuthorAgent
}
messageType := models.MessageType(req.Type)
if messageType == "" {
messageType = models.MessageTypeMessage
}
taskID := req.TaskID
if taskID == "" && session != nil {
taskID = session.TaskID
}
// Ensure we have a turn ID - get active turn or start a new one
turnID := req.TurnID
if turnID == "" {
turn, err := s.getOrStartTurn(ctx, req.TaskSessionID)
if err != nil {
s.logger.Warn("failed to get or start turn for streaming message",
zap.String("session_id", req.TaskSessionID),
zap.Error(err))
// Continue with empty turn ID - will fail on foreign key if turn is required
} else if turn != nil {
turnID = turn.ID
}
}
message := &models.Message{
ID: id,
TaskSessionID: req.TaskSessionID,
TaskID: taskID,
TurnID: turnID,
AuthorType: authorType,
AuthorID: req.AuthorID,
Content: req.Content,
Type: messageType,
Metadata: req.Metadata,
RequestsInput: req.RequestsInput,
CreatedAt: time.Now().UTC(),
}
if err := s.repo.CreateMessage(ctx, message); err != nil {
s.logger.Error("failed to create message with ID", zap.String("id", id), zap.Error(err))
return nil, err
}
// Publish message.added event
s.publishMessageEvent(ctx, events.MessageAdded, message)
s.logger.Info("message created with ID",
zap.String("message_id", message.ID),
zap.String("session_id", message.TaskSessionID),
zap.String("author_type", string(message.AuthorType)))
return message, nil
}
// GetMessage retrieves a message by ID
func (s *Service) GetMessage(ctx context.Context, id string) (*models.Message, error) {
return s.repo.GetMessage(ctx, id)
}
// 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)
}
// 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 = DefaultMessagesPageSize
}
if limit > MaxMessagesPageSize {
limit = MaxMessagesPageSize
}
return s.repo.ListMessagesPaginated(ctx, req.TaskSessionID, repository.ListMessagesOptions{
Limit: limit,
Before: req.Before,
After: req.After,
Sort: req.Sort,
})
}
// 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("message deleted", zap.String("message_id", id))
return nil
}
// AppendMessageContent appends additional content to an existing message.
// This is used for streaming agent responses where content arrives incrementally.
func (s *Service) AppendMessageContent(ctx context.Context, messageID, additionalContent string) error {
message, err := s.repo.GetMessage(ctx, messageID)
if err != nil {
s.logger.Error("failed to get message for append",
zap.String("message_id", messageID),
zap.Error(err))
return err
}
// Append the new content
message.Content = message.Content + additionalContent
if err := s.repo.UpdateMessage(ctx, message); err != nil {
s.logger.Error("failed to append message content",
zap.String("message_id", messageID),
zap.Error(err))
return err
}
// Publish message.updated event for real-time streaming
s.publishMessageEvent(ctx, events.MessageUpdated, message)
s.logger.Debug("message content appended",
zap.String("message_id", messageID),
zap.Int("appended_length", len(additionalContent)),
zap.Int("total_length", len(message.Content)))
return nil
}
// UpdateToolCallMessage updates a tool call message's status.
// It includes retry logic to handle race conditions where the complete event
// may arrive before the message has been created by the start event.
func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result string) error {
const maxRetries = 5
const retryDelay = 100 * time.Millisecond
var message *models.Message
var err error
// Retry loop to handle race condition where complete event arrives before start event
// has finished creating the message in the database
for attempt := 0; attempt < maxRetries; attempt++ {
message, err = s.repo.GetMessageByToolCallID(ctx, sessionID, toolCallID)
if err == nil {
break // Found the message, proceed with update
}
// If context is cancelled, don't retry
if ctx.Err() != nil {
return ctx.Err()
}
// Log retry attempt (only warn on final failure)
if attempt < maxRetries-1 {
s.logger.Debug("tool call message not found, retrying",
zap.String("session_id", sessionID),
zap.String("tool_call_id", toolCallID),
zap.Int("attempt", attempt+1),
zap.Int("max_retries", maxRetries))
time.Sleep(retryDelay)
}
}
if err != nil {
s.logger.Warn("tool call message not found for update after retries",
zap.String("session_id", sessionID),
zap.String("tool_call_id", toolCallID),
zap.Int("retries", maxRetries),
zap.Error(err))
return err
}
if message.Metadata == nil {
message.Metadata = make(map[string]interface{})
}
message.Metadata["status"] = status
if result != "" {
message.Metadata["result"] = result
}
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 message.updated event
s.publishMessageEvent(ctx, events.MessageUpdated, message)
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
}
// UpdatePermissionMessage updates a permission request message's status.
// It includes retry logic to handle race conditions.
func (s *Service) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error {
const maxRetries = 5
const retryDelay = 100 * time.Millisecond
var message *models.Message
var err error
// Retry loop to handle race condition
for attempt := 0; attempt < maxRetries; attempt++ {
message, err = s.repo.GetMessageByPendingID(ctx, sessionID, pendingID)
if err == nil {
break
}
if ctx.Err() != nil {
return ctx.Err()
}
if attempt < maxRetries-1 {
s.logger.Debug("permission message not found, retrying",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.Int("attempt", attempt+1),
zap.Int("max_retries", maxRetries))
time.Sleep(retryDelay)
}
}
if err != nil {
s.logger.Warn("permission message not found for update after retries",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.Int("retries", maxRetries),
zap.Error(err))
return err
}
if message.Metadata == nil {
message.Metadata = make(map[string]interface{})
}
message.Metadata["status"] = status
if err := s.repo.UpdateMessage(ctx, message); err != nil {
s.logger.Error("failed to update permission message",
zap.String("message_id", message.ID),
zap.String("pending_id", pendingID),
zap.Error(err))
return err
}
// Publish message.updated event
s.publishMessageEvent(ctx, events.MessageUpdated, message)
s.logger.Info("permission message updated",
zap.String("message_id", message.ID),
zap.String("pending_id", pendingID),
zap.String("status", status))
return nil
}
// Turn operations
// StartTurn creates a new turn for a session and publishes the turn.started event.
// Returns the created turn.
func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
turn := &models.Turn{
ID: uuid.New().String(),
TaskSessionID: sessionID,
TaskID: session.TaskID,
StartedAt: time.Now().UTC(),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := s.repo.CreateTurn(ctx, turn); err != nil {
s.logger.Error("failed to create turn", zap.Error(err))
return nil, err
}
s.publishTurnEvent(events.TurnStarted, turn)
s.logger.Debug("started turn",
zap.String("turn_id", turn.ID),
zap.String("session_id", sessionID),
zap.String("task_id", turn.TaskID))
return turn, nil
}
// CompleteTurn marks a turn as completed and publishes the turn.completed event.
func (s *Service) CompleteTurn(ctx context.Context, turnID string) error {
if turnID == "" {
return nil // No active turn to complete
}
if err := s.repo.CompleteTurn(ctx, turnID); err != nil {
s.logger.Error("failed to complete turn", zap.String("turn_id", turnID), zap.Error(err))
return err
}
// Fetch the completed turn to get the completed_at timestamp
turn, err := s.repo.GetTurn(ctx, turnID)
if err != nil {
s.logger.Warn("failed to refetch completed turn", zap.String("turn_id", turnID), zap.Error(err))
// Still publish with the old turn data
}
s.publishTurnEvent(events.TurnCompleted, turn)
s.logger.Debug("completed turn",
zap.String("turn_id", turnID),
zap.String("session_id", turn.TaskSessionID),
zap.String("task_id", turn.TaskID))
return nil
}
// GetActiveTurn returns the currently active (non-completed) turn for a session.
func (s *Service) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return s.repo.GetActiveTurnBySessionID(ctx, sessionID)
}
// getOrStartTurn returns the active turn for a session, or starts a new one if none exists.
// This is used to ensure messages always have a valid turn ID.
func (s *Service) getOrStartTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
// First try to get an active turn
turn, err := s.repo.GetActiveTurnBySessionID(ctx, sessionID)
if err == nil && turn != nil {
return turn, nil
}
// No active turn, start a new one
return s.StartTurn(ctx, sessionID)
}
// publishTurnEvent publishes a turn event to the event bus.
func (s *Service) publishTurnEvent(eventType string, turn *models.Turn) {
if s.eventBus == nil {
return
}
_ = s.eventBus.Publish(context.Background(), eventType, bus.NewEvent(eventType, "task-service", map[string]interface{}{
"id": turn.ID,
"session_id": turn.TaskSessionID,
"task_id": turn.TaskID,
"started_at": turn.StartedAt,
"completed_at": turn.CompletedAt,
"metadata": turn.Metadata,
"created_at": turn.CreatedAt,
"updated_at": turn.UpdatedAt,
}))
}
// 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("publishMessageEvent: eventBus is nil, skipping")
return
}
messageType := string(message.Type)
if messageType == "" {
messageType = "message"
}
data := map[string]interface{}{
"message_id": message.ID,
"session_id": message.TaskSessionID,
"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 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 message event",
zap.String("event_type", eventType),
zap.String("message_id", message.ID),
zap.Error(err))
}
}
func (s *Service) publishRepositoryEvent(ctx context.Context, eventType string, repository *models.Repository) {
if s.eventBus == nil || repository == nil {
return
}
data := map[string]interface{}{
"id": repository.ID,
"workspace_id": repository.WorkspaceID,
"name": repository.Name,
"source_type": repository.SourceType,
"local_path": repository.LocalPath,
"provider": repository.Provider,
"provider_repo_id": repository.ProviderRepoID,
"provider_owner": repository.ProviderOwner,
"provider_name": repository.ProviderName,
"default_branch": repository.DefaultBranch,
"worktree_branch_prefix": repository.WorktreeBranchPrefix,
"setup_script": repository.SetupScript,
"cleanup_script": repository.CleanupScript,
"created_at": repository.CreatedAt.Format(time.RFC3339),
"updated_at": repository.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish repository event",
zap.String("event_type", eventType),
zap.String("repository_id", repository.ID),
zap.Error(err))
}
}
func (s *Service) publishRepositoryScriptEvent(ctx context.Context, eventType string, script *models.RepositoryScript) {
if s.eventBus == nil || script == nil {
return
}
data := map[string]interface{}{
"id": script.ID,
"repository_id": script.RepositoryID,
"name": script.Name,
"command": script.Command,
"position": script.Position,
"created_at": script.CreatedAt.Format(time.RFC3339),
"updated_at": script.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish repository script event",
zap.String("event_type", eventType),
zap.String("script_id", script.ID),
zap.Error(err))
}
}