diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 74663b9a19..040f4755b0 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -865,6 +865,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch // If empty, the agent will run without a mounted workspace launchReq := &lifecycle.LaunchRequest{ TaskID: req.TaskID, + SessionID: req.SessionID, TaskTitle: req.TaskTitle, AgentProfileID: req.AgentProfileID, WorkspacePath: req.RepositoryURL, // May be empty - lifecycle manager handles this @@ -887,8 +888,11 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch // Streaming is now handled by the lifecycle manager // Extract worktree info from metadata if available - var worktreePath, worktreeBranch string + var worktreeID, worktreePath, worktreeBranch string if instance.Metadata != nil { + if id, ok := instance.Metadata["worktree_id"].(string); ok { + worktreeID = id + } if path, ok := instance.Metadata["worktree_path"].(string); ok { worktreePath = path } @@ -901,6 +905,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch AgentInstanceID: instance.ID, ContainerID: instance.ContainerID, Status: instance.Status, + WorktreeID: worktreeID, WorktreePath: worktreePath, WorktreeBranch: worktreeBranch, }, nil diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 29e16ef9bf..be8d7f3ced 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -67,6 +67,7 @@ func (ai *AgentInstance) GetAgentCtlClient() *agentctl.Client { // LaunchRequest contains parameters for launching an agent type LaunchRequest struct { TaskID string + SessionID string TaskTitle string // Human-readable task title for semantic worktree naming AgentProfileID string WorkspacePath string // Host path to workspace (original repository path) @@ -692,6 +693,7 @@ func (m *Manager) getOrCreateWorktree(ctx context.Context, req *LaunchRequest) ( // Create request with optional WorktreeID for resumption createReq := worktree.CreateRequest{ TaskID: req.TaskID, + SessionID: req.SessionID, TaskTitle: req.TaskTitle, RepositoryID: req.RepositoryID, RepositoryPath: req.RepositoryPath, diff --git a/apps/backend/internal/agent/worktree/manager.go b/apps/backend/internal/agent/worktree/manager.go index 2197cea737..1570902994 100644 --- a/apps/backend/internal/agent/worktree/manager.go +++ b/apps/backend/internal/agent/worktree/manager.go @@ -225,6 +225,7 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest) (*Workt now := time.Now() wt := &Worktree{ ID: worktreeID, + SessionID: req.SessionID, TaskID: req.TaskID, RepositoryID: req.RepositoryID, RepositoryPath: req.RepositoryPath, @@ -238,6 +239,11 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest) (*Workt // Persist to store if m.store != nil { + if req.SessionID == "" { + m.logger.Warn("skipping worktree persistence: missing session_id", + zap.String("task_id", req.TaskID), + zap.String("worktree_id", wt.ID)) + } else { if err := m.store.CreateWorktree(ctx, wt); err != nil { // Cleanup on failure if cleanupErr := m.removeWorktreeDir(ctx, worktreePath, req.RepositoryPath); cleanupErr != nil { @@ -245,6 +251,7 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest) (*Workt } return nil, fmt.Errorf("failed to persist worktree: %w", err) } + } } // Update cache diff --git a/apps/backend/internal/agent/worktree/store.go b/apps/backend/internal/agent/worktree/store.go index a97971254e..59779f0eef 100644 --- a/apps/backend/internal/agent/worktree/store.go +++ b/apps/backend/internal/agent/worktree/store.go @@ -15,7 +15,7 @@ type SQLiteStore struct { } // NewSQLiteStore creates a new SQLite-backed worktree store. -// It uses the provided sql.DB connection and ensures the worktrees table exists. +// It uses the provided sql.DB connection and ensures the task_session_worktrees table exists. func NewSQLiteStore(db *sql.DB) (*SQLiteStore, error) { store := &SQLiteStore{db: db} if err := store.initSchema(); err != nil { @@ -24,28 +24,30 @@ func NewSQLiteStore(db *sql.DB) (*SQLiteStore, error) { return store, nil } -// initSchema creates the worktrees table if it doesn't exist. +// initSchema creates the task_session_worktrees table if it doesn't exist. func (s *SQLiteStore) initSchema() error { schema := ` - CREATE TABLE IF NOT EXISTS worktrees ( + CREATE TABLE IF NOT EXISTS task_session_worktrees ( id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, + session_id TEXT NOT NULL, + worktree_id TEXT NOT NULL, repository_id TEXT NOT NULL, - repository_path TEXT NOT NULL, - path TEXT NOT NULL, - branch TEXT NOT NULL, - base_branch TEXT NOT NULL, + position INTEGER DEFAULT 0, + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, merged_at DATETIME, deleted_at DATETIME, - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, + UNIQUE(session_id, worktree_id) ); - CREATE INDEX IF NOT EXISTS idx_worktrees_task_id ON worktrees(task_id); - CREATE INDEX IF NOT EXISTS idx_worktrees_repository_id ON worktrees(repository_id); - CREATE INDEX IF NOT EXISTS idx_worktrees_status ON worktrees(status); + CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_session_id ON task_session_worktrees(session_id); + CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_worktree_id ON task_session_worktrees(worktree_id); + CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_repository_id ON task_session_worktrees(repository_id); + CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_status ON task_session_worktrees(status); ` _, err := s.db.Exec(schema) @@ -57,6 +59,12 @@ func (s *SQLiteStore) CreateWorktree(ctx context.Context, wt *Worktree) error { if wt.ID == "" { wt.ID = uuid.New().String() } + if wt.SessionID == "" { + return fmt.Errorf("session ID is required to persist worktree") + } + if wt.Status == "" { + wt.Status = StatusActive + } now := time.Now().UTC() if wt.CreatedAt.IsZero() { wt.CreatedAt = now @@ -66,12 +74,22 @@ func (s *SQLiteStore) CreateWorktree(ctx context.Context, wt *Worktree) error { } _, err := s.db.ExecContext(ctx, ` - INSERT INTO worktrees ( - id, task_id, repository_id, repository_path, path, branch, base_branch, - status, created_at, updated_at, merged_at, deleted_at + INSERT INTO task_session_worktrees ( + id, session_id, worktree_id, repository_id, position, + worktree_path, worktree_branch, status, + created_at, updated_at, merged_at, deleted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, wt.ID, wt.TaskID, wt.RepositoryID, wt.RepositoryPath, wt.Path, wt.Branch, - wt.BaseBranch, wt.Status, wt.CreatedAt, wt.UpdatedAt, wt.MergedAt, wt.DeletedAt) + ON CONFLICT(session_id, worktree_id) DO UPDATE SET + repository_id = excluded.repository_id, + worktree_path = excluded.worktree_path, + worktree_branch = excluded.worktree_branch, + status = excluded.status, + updated_at = excluded.updated_at, + merged_at = excluded.merged_at, + deleted_at = excluded.deleted_at + `, uuid.New().String(), wt.SessionID, wt.ID, wt.RepositoryID, 0, + wt.Path, wt.Branch, wt.Status, + wt.CreatedAt, wt.UpdatedAt, wt.MergedAt, wt.DeletedAt) return err } @@ -80,15 +98,41 @@ func (s *SQLiteStore) CreateWorktree(ctx context.Context, wt *Worktree) error { func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree, error) { wt := &Worktree{} var mergedAt, deletedAt sql.NullTime + var repositoryPath, baseBranch sql.NullString err := s.db.QueryRowContext(ctx, ` - SELECT id, task_id, repository_id, repository_path, path, branch, base_branch, - status, created_at, updated_at, merged_at, deleted_at - FROM worktrees WHERE id = ? + SELECT + tsw.worktree_id, + tsw.session_id, + s.task_id, + tsw.repository_id, + r.local_path, + tsw.worktree_path, + tsw.worktree_branch, + s.base_branch, + tsw.status, + tsw.created_at, + tsw.updated_at, + tsw.merged_at, + tsw.deleted_at + FROM task_session_worktrees tsw + LEFT JOIN task_sessions s ON tsw.session_id = s.id + LEFT JOIN repositories r ON tsw.repository_id = r.id + WHERE tsw.worktree_id = ? `, id).Scan( - &wt.ID, &wt.TaskID, &wt.RepositoryID, &wt.RepositoryPath, &wt.Path, - &wt.Branch, &wt.BaseBranch, &wt.Status, &wt.CreatedAt, &wt.UpdatedAt, - &mergedAt, &deletedAt, + &wt.ID, + &wt.SessionID, + &wt.TaskID, + &wt.RepositoryID, + &repositoryPath, + &wt.Path, + &wt.Branch, + &baseBranch, + &wt.Status, + &wt.CreatedAt, + &wt.UpdatedAt, + &mergedAt, + &deletedAt, ) if err == sql.ErrNoRows { @@ -98,6 +142,12 @@ func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree return nil, err } + if repositoryPath.Valid { + wt.RepositoryPath = repositoryPath.String + } + if baseBranch.Valid { + wt.BaseBranch = baseBranch.String + } if mergedAt.Valid { wt.MergedAt = &mergedAt.Time } @@ -113,15 +163,42 @@ func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (*Worktree, error) { wt := &Worktree{} var mergedAt, deletedAt sql.NullTime + var repositoryPath, baseBranch sql.NullString err := s.db.QueryRowContext(ctx, ` - SELECT id, task_id, repository_id, repository_path, path, branch, base_branch, - status, created_at, updated_at, merged_at, deleted_at - FROM worktrees WHERE task_id = ? AND status = ? ORDER BY created_at DESC LIMIT 1 + SELECT + tsw.worktree_id, + tsw.session_id, + s.task_id, + tsw.repository_id, + r.local_path, + tsw.worktree_path, + tsw.worktree_branch, + s.base_branch, + tsw.status, + tsw.created_at, + tsw.updated_at, + tsw.merged_at, + tsw.deleted_at + FROM task_session_worktrees tsw + INNER JOIN task_sessions s ON tsw.session_id = s.id + LEFT JOIN repositories r ON tsw.repository_id = r.id + WHERE s.task_id = ? AND tsw.status = ? + ORDER BY tsw.created_at DESC LIMIT 1 `, taskID, StatusActive).Scan( - &wt.ID, &wt.TaskID, &wt.RepositoryID, &wt.RepositoryPath, &wt.Path, - &wt.Branch, &wt.BaseBranch, &wt.Status, &wt.CreatedAt, &wt.UpdatedAt, - &mergedAt, &deletedAt, + &wt.ID, + &wt.SessionID, + &wt.TaskID, + &wt.RepositoryID, + &repositoryPath, + &wt.Path, + &wt.Branch, + &baseBranch, + &wt.Status, + &wt.CreatedAt, + &wt.UpdatedAt, + &mergedAt, + &deletedAt, ) if err == sql.ErrNoRows { @@ -131,6 +208,12 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (* return nil, err } + if repositoryPath.Valid { + wt.RepositoryPath = repositoryPath.String + } + if baseBranch.Valid { + wt.BaseBranch = baseBranch.String + } if mergedAt.Valid { wt.MergedAt = &mergedAt.Time } @@ -144,9 +227,24 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (* // GetWorktreesByTaskID retrieves all worktrees for a task. func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) ([]*Worktree, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT id, task_id, repository_id, repository_path, path, branch, base_branch, - status, created_at, updated_at, merged_at, deleted_at - FROM worktrees WHERE task_id = ? ORDER BY created_at DESC + SELECT + tsw.worktree_id, + tsw.session_id, + s.task_id, + tsw.repository_id, + r.local_path, + tsw.worktree_path, + tsw.worktree_branch, + s.base_branch, + tsw.status, + tsw.created_at, + tsw.updated_at, + tsw.merged_at, + tsw.deleted_at + FROM task_session_worktrees tsw + INNER JOIN task_sessions s ON tsw.session_id = s.id + LEFT JOIN repositories r ON tsw.repository_id = r.id + WHERE s.task_id = ? ORDER BY tsw.created_at DESC `, taskID) if err != nil { return nil, err @@ -159,9 +257,24 @@ func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) ( // GetWorktreesByRepositoryID retrieves all worktrees for a repository. func (s *SQLiteStore) GetWorktreesByRepositoryID(ctx context.Context, repoID string) ([]*Worktree, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT id, task_id, repository_id, repository_path, path, branch, base_branch, - status, created_at, updated_at, merged_at, deleted_at - FROM worktrees WHERE repository_id = ? + SELECT + tsw.worktree_id, + tsw.session_id, + s.task_id, + tsw.repository_id, + r.local_path, + tsw.worktree_path, + tsw.worktree_branch, + s.base_branch, + tsw.status, + tsw.created_at, + tsw.updated_at, + tsw.merged_at, + tsw.deleted_at + FROM task_session_worktrees tsw + LEFT JOIN task_sessions s ON tsw.session_id = s.id + LEFT JOIN repositories r ON tsw.repository_id = r.id + WHERE tsw.repository_id = ? `, repoID) if err != nil { return nil, err @@ -175,13 +288,28 @@ func (s *SQLiteStore) GetWorktreesByRepositoryID(ctx context.Context, repoID str func (s *SQLiteStore) UpdateWorktree(ctx context.Context, wt *Worktree) error { wt.UpdatedAt = time.Now().UTC() - result, err := s.db.ExecContext(ctx, ` - UPDATE worktrees SET - repository_id = ?, repository_path = ?, path = ?, branch = ?, base_branch = ?, + query := ` + UPDATE task_session_worktrees SET + repository_id = ?, worktree_path = ?, worktree_branch = ?, status = ?, updated_at = ?, merged_at = ?, deleted_at = ? - WHERE id = ? - `, wt.RepositoryID, wt.RepositoryPath, wt.Path, wt.Branch, wt.BaseBranch, - wt.Status, wt.UpdatedAt, wt.MergedAt, wt.DeletedAt, wt.ID) + WHERE worktree_id = ? + ` + args := []interface{}{ + wt.RepositoryID, + wt.Path, + wt.Branch, + wt.Status, + wt.UpdatedAt, + wt.MergedAt, + wt.DeletedAt, + wt.ID, + } + if wt.SessionID != "" { + query += " AND session_id = ?" + args = append(args, wt.SessionID) + } + + result, err := s.db.ExecContext(ctx, query, args...) if err != nil { return err } @@ -195,7 +323,7 @@ func (s *SQLiteStore) UpdateWorktree(ctx context.Context, wt *Worktree) error { // DeleteWorktree removes a worktree record. func (s *SQLiteStore) DeleteWorktree(ctx context.Context, id string) error { - result, err := s.db.ExecContext(ctx, `DELETE FROM worktrees WHERE id = ?`, id) + result, err := s.db.ExecContext(ctx, `DELETE FROM task_session_worktrees WHERE worktree_id = ?`, id) if err != nil { return err } @@ -210,9 +338,24 @@ func (s *SQLiteStore) DeleteWorktree(ctx context.Context, id string) error { // ListActiveWorktrees returns all worktrees with status 'active'. func (s *SQLiteStore) ListActiveWorktrees(ctx context.Context) ([]*Worktree, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT id, task_id, repository_id, repository_path, path, branch, base_branch, - status, created_at, updated_at, merged_at, deleted_at - FROM worktrees WHERE status = ? + SELECT + tsw.worktree_id, + tsw.session_id, + s.task_id, + tsw.repository_id, + r.local_path, + tsw.worktree_path, + tsw.worktree_branch, + s.base_branch, + tsw.status, + tsw.created_at, + tsw.updated_at, + tsw.merged_at, + tsw.deleted_at + FROM task_session_worktrees tsw + LEFT JOIN task_sessions s ON tsw.session_id = s.id + LEFT JOIN repositories r ON tsw.repository_id = r.id + WHERE tsw.status = ? `, StatusActive) if err != nil { return nil, err @@ -228,16 +371,33 @@ func (s *SQLiteStore) scanWorktrees(rows *sql.Rows) ([]*Worktree, error) { for rows.Next() { wt := &Worktree{} var mergedAt, deletedAt sql.NullTime + var repositoryPath, baseBranch sql.NullString err := rows.Scan( - &wt.ID, &wt.TaskID, &wt.RepositoryID, &wt.RepositoryPath, &wt.Path, - &wt.Branch, &wt.BaseBranch, &wt.Status, &wt.CreatedAt, &wt.UpdatedAt, - &mergedAt, &deletedAt, + &wt.ID, + &wt.SessionID, + &wt.TaskID, + &wt.RepositoryID, + &repositoryPath, + &wt.Path, + &wt.Branch, + &baseBranch, + &wt.Status, + &wt.CreatedAt, + &wt.UpdatedAt, + &mergedAt, + &deletedAt, ) if err != nil { return nil, err } + if repositoryPath.Valid { + wt.RepositoryPath = repositoryPath.String + } + if baseBranch.Valid { + wt.BaseBranch = baseBranch.String + } if mergedAt.Valid { wt.MergedAt = &mergedAt.Time } @@ -252,4 +412,3 @@ func (s *SQLiteStore) scanWorktrees(rows *sql.Rows) ([]*Worktree, error) { // Ensure SQLiteStore implements Store interface. var _ Store = (*SQLiteStore)(nil) - diff --git a/apps/backend/internal/agent/worktree/worktree.go b/apps/backend/internal/agent/worktree/worktree.go index 5843bf0498..4649171c61 100644 --- a/apps/backend/internal/agent/worktree/worktree.go +++ b/apps/backend/internal/agent/worktree/worktree.go @@ -7,6 +7,9 @@ type Worktree struct { // ID is the unique identifier for this worktree record. ID string `json:"id"` + // SessionID is the task session associated with this worktree. + SessionID string `json:"session_id,omitempty"` + // TaskID is the ID of the task this worktree is associated with. // Multiple worktrees can exist for the same task (one per agent session). TaskID string `json:"task_id"` @@ -49,6 +52,9 @@ type CreateRequest struct { // TaskID is the unique task identifier (required). TaskID string + // SessionID is the task session identifier (required for persistence). + SessionID string + // TaskTitle is the human-readable task title (optional). // If provided, it will be used to generate semantic worktree/branch names. // The title is sanitized and truncated to 20 characters. @@ -103,4 +109,3 @@ const StatusMerged = "merged" // StatusDeleted is the status for a deleted worktree. const StatusDeleted = "deleted" - diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index a2570d8d69..fae659b523 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/kandev/kandev/internal/agent/worktree" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" @@ -66,6 +67,9 @@ func NewTestServer(t *testing.T) *TestServer { t.Errorf("failed to close task repo: %v", err) } }) + if _, err := worktree.NewSQLiteStore(taskRepo.DB()); err != nil { + t.Fatalf("failed to init worktree store: %v", err) + } // Initialize task service taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index 8983475bc1..b4069e4fc9 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap" + "github.com/kandev/kandev/internal/agent/worktree" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" @@ -494,6 +495,9 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { t.Errorf("failed to close task repo: %v", err) } }) + if _, err := worktree.NewSQLiteStore(taskRepo.DB()); err != nil { + t.Fatalf("failed to init worktree store: %v", err) + } // Initialize task service taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) @@ -626,8 +630,12 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st Title: "Test Task", Description: "This is a test task for the orchestrator", Priority: priority, - RepositoryID: repository.ID, - BaseBranch: "main", + Repositories: []taskservice.TaskRepositoryInput{ + { + RepositoryID: repository.ID, + BaseBranch: "main", + }, + }, }) require.NoError(t, err) diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 563417d4c5..ff6225963a 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -63,6 +63,7 @@ type AgentManagerClient interface { // LaunchAgentRequest contains parameters for launching an agent type LaunchAgentRequest struct { TaskID string + SessionID string TaskTitle string // Human-readable task title for semantic worktree naming AgentProfileID string RepositoryURL string @@ -85,6 +86,7 @@ type LaunchAgentResponse struct { AgentInstanceID string ContainerID string Status v1.AgentStatus + WorktreeID string WorktreePath string WorktreeBranch string } @@ -107,7 +109,7 @@ type TaskExecution struct { // FromTaskSession converts a models.TaskSession to TaskExecution func FromTaskSession(s *models.TaskSession) *TaskExecution { - return &TaskExecution{ + execution := &TaskExecution{ TaskID: s.TaskID, AgentInstanceID: s.AgentInstanceID, AgentProfileID: s.AgentProfileID, @@ -117,6 +119,11 @@ func FromTaskSession(s *models.TaskSession) *TaskExecution { LastUpdate: s.UpdatedAt, SessionID: s.ID, } + if len(s.Worktrees) > 0 { + execution.WorktreePath = s.Worktrees[0].WorktreePath + execution.WorktreeBranch = s.Worktrees[0].WorktreeBranch + } + return execution } // agentSessionStateToV1 converts models.TaskSessionState to v1.TaskSessionState @@ -264,8 +271,20 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP var repositoryPath string var repositoryID string var baseBranch string - if task.RepositoryID != nil && *task.RepositoryID != "" { - repositoryID = *task.RepositoryID + + // Get the primary repository for this task + primaryTaskRepo, err := e.repo.GetPrimaryTaskRepository(ctx, task.ID) + if err != nil { + e.logger.Error("failed to get primary task repository", + zap.String("task_id", task.ID), + zap.Error(err)) + return nil, err + } + + if primaryTaskRepo != nil { + repositoryID = primaryTaskRepo.RepositoryID + baseBranch = primaryTaskRepo.BaseBranch + repository, err := e.repo.GetRepository(ctx, repositoryID) if err != nil { e.logger.Error("failed to load repository for task", @@ -278,9 +297,7 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP if repositoryPath != "" { req.RepositoryURL = repositoryPath } - if task.BaseBranch != nil && *task.BaseBranch != "" { - baseBranch = *task.BaseBranch - } else if repository.DefaultBranch != "" { + if baseBranch == "" && repository.DefaultBranch != "" { baseBranch = repository.DefaultBranch } if baseBranch != "" { @@ -300,6 +317,30 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP } } + // Create agent session in database before launch so worktree associations can persist. + sessionID := uuid.New().String() + now := time.Now().UTC() + session := &models.TaskSession{ + ID: sessionID, + TaskID: task.ID, + AgentProfileID: agentProfileID, + RepositoryID: repositoryID, + BaseBranch: baseBranch, + State: models.TaskSessionStateCreated, + Progress: 0, + StartedAt: now, + UpdatedAt: now, + } + + if err := e.repo.CreateTaskSession(ctx, session); err != nil { + e.logger.Error("failed to persist agent session before launch", + zap.String("task_id", task.ID), + zap.Error(err)) + return nil, err + } + + req.SessionID = sessionID + e.logger.Info("launching agent for task", zap.String("task_id", task.ID), zap.String("agent_profile_id", agentProfileID), @@ -311,33 +352,45 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP e.logger.Error("failed to launch agent", zap.String("task_id", task.ID), zap.Error(err)) + if updateErr := e.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error()); updateErr != nil { + e.logger.Warn("failed to mark session as failed after launch error", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.Error(updateErr)) + } return nil, err } - // Create agent session in database - sessionID := uuid.New().String() - now := time.Now().UTC() - session := &models.TaskSession{ - ID: sessionID, - TaskID: task.ID, - AgentInstanceID: resp.AgentInstanceID, - ContainerID: resp.ContainerID, - AgentProfileID: agentProfileID, - RepositoryID: repositoryID, - BaseBranch: baseBranch, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - State: models.TaskSessionStateStarting, - Progress: 0, - StartedAt: now, - UpdatedAt: now, + session.AgentInstanceID = resp.AgentInstanceID + session.ContainerID = resp.ContainerID + session.State = models.TaskSessionStateStarting + session.Progress = 0 + session.ErrorMessage = "" + session.UpdatedAt = time.Now().UTC() + + if err := e.repo.UpdateTaskSession(ctx, session); err != nil { + e.logger.Error("failed to update agent session after launch", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.Error(err)) } - if err := e.repo.CreateTaskSession(ctx, session); err != nil { - e.logger.Error("failed to persist agent session", - zap.String("task_id", task.ID), - zap.Error(err)) - // Continue anyway - the agent is already running + if resp.WorktreeID != "" { + sessionWorktree := &models.TaskSessionWorktree{ + SessionID: session.ID, + WorktreeID: resp.WorktreeID, + RepositoryID: repositoryID, + Position: 0, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + } + if err := e.repo.CreateTaskSessionWorktree(ctx, sessionWorktree); err != nil { + e.logger.Error("failed to persist session worktree association", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID), + zap.String("worktree_id", resp.WorktreeID), + zap.Error(err)) + } } // Track the execution in the in-memory cache @@ -395,6 +448,7 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo req := &LaunchAgentRequest{ TaskID: task.ID, + SessionID: session.ID, TaskTitle: task.Title, AgentProfileID: session.AgentProfileID, TaskDescription: "", @@ -407,8 +461,8 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo metadata[key] = value } } - if session.WorktreeID != "" { - metadata["worktree_id"] = session.WorktreeID + if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreeID != "" { + metadata["worktree_id"] = session.Worktrees[0].WorktreeID } if len(metadata) > 0 { req.Metadata = metadata @@ -416,8 +470,8 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo repositoryID := session.RepositoryID var repositoryPath string - if repositoryID == "" && task.RepositoryID != nil { - repositoryID = *task.RepositoryID + if repositoryID == "" && len(task.Repositories) > 0 { + repositoryID = task.Repositories[0].RepositoryID } if repositoryID != "" { repository, err := e.repo.GetRepository(ctx, repositoryID) @@ -435,8 +489,8 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo } baseBranch := session.BaseBranch - if baseBranch == "" && task.BaseBranch != nil && *task.BaseBranch != "" { - baseBranch = *task.BaseBranch + if baseBranch == "" && len(task.Repositories) > 0 && task.Repositories[0].BaseBranch != "" { + baseBranch = task.Repositories[0].BaseBranch } if baseBranch != "" { req.Branch = baseBranch @@ -476,12 +530,6 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo session.AgentInstanceID = resp.AgentInstanceID session.ContainerID = resp.ContainerID - if resp.WorktreePath != "" { - session.WorktreePath = resp.WorktreePath - } - if resp.WorktreeBranch != "" { - session.WorktreeBranch = resp.WorktreeBranch - } session.Progress = 0 session.ErrorMessage = "" session.State = models.TaskSessionStateStarting @@ -494,6 +542,40 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo zap.Error(err)) } + if resp.WorktreeID != "" { + hasWorktree := false + for _, wt := range session.Worktrees { + if wt.WorktreeID == resp.WorktreeID { + hasWorktree = true + break + } + } + if !hasWorktree { + sessionWorktree := &models.TaskSessionWorktree{ + SessionID: session.ID, + WorktreeID: resp.WorktreeID, + RepositoryID: repositoryID, + Position: 0, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + } + if err := e.repo.CreateTaskSessionWorktree(ctx, sessionWorktree); err != nil { + e.logger.Error("failed to persist session worktree association on resume", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID), + zap.String("worktree_id", resp.WorktreeID), + zap.Error(err)) + } + } + } + + worktreePath := resp.WorktreePath + worktreeBranch := resp.WorktreeBranch + if worktreePath == "" && len(session.Worktrees) > 0 { + worktreePath = session.Worktrees[0].WorktreePath + worktreeBranch = session.Worktrees[0].WorktreeBranch + } + now := time.Now().UTC() execution := &TaskExecution{ TaskID: task.ID, @@ -504,8 +586,8 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo Progress: 0, LastUpdate: now, SessionID: session.ID, - WorktreePath: session.WorktreePath, - WorktreeBranch: session.WorktreeBranch, + WorktreePath: worktreePath, + WorktreeBranch: worktreeBranch, } e.mu.Lock() diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 3f801930f5..85be02c04e 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -375,10 +375,10 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin if session.TaskID != taskID { return nil, fmt.Errorf("task session does not belong to task") } - if session.WorktreePath == "" { + if len(session.Worktrees) == 0 || session.Worktrees[0].WorktreePath == "" { return nil, fmt.Errorf("task session has no worktree path") } - if _, err := os.Stat(session.WorktreePath); err != nil { + if _, err := os.Stat(session.Worktrees[0].WorktreePath); err != nil { return nil, fmt.Errorf("worktree path not found: %w", err) } diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index 5aa60c1c79..afac24616d 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -80,6 +80,15 @@ func (c *TaskController) GetTask(ctx context.Context, req dto.GetTaskRequest) (d } func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskRequest) (dto.TaskDTO, error) { + // Convert DTO repositories to service layer input + var repos []service.TaskRepositoryInput + for _, r := range req.Repositories { + repos = append(repos, service.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + }) + } + task, err := c.service.CreateTask(ctx, &service.CreateTaskRequest{ WorkspaceID: req.WorkspaceID, BoardID: req.BoardID, @@ -88,8 +97,7 @@ func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskReque Description: req.Description, Priority: req.Priority, State: req.State, - RepositoryID: req.RepositoryID, - BaseBranch: req.BaseBranch, + Repositories: repos, AssignedTo: req.AssignedTo, Position: req.Position, Metadata: req.Metadata, @@ -101,16 +109,24 @@ func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskReque } func (c *TaskController) UpdateTask(ctx context.Context, req dto.UpdateTaskRequest) (dto.TaskDTO, error) { + // Convert DTO repositories to service layer input + var repos []service.TaskRepositoryInput + for _, r := range req.Repositories { + repos = append(repos, service.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + }) + } + task, err := c.service.UpdateTask(ctx, req.ID, &service.UpdateTaskRequest{ - Title: req.Title, - Description: req.Description, - Priority: req.Priority, - State: req.State, - RepositoryID: req.RepositoryID, - BaseBranch: req.BaseBranch, - AssignedTo: req.AssignedTo, - Position: req.Position, - Metadata: req.Metadata, + Title: req.Title, + Description: req.Description, + Priority: req.Priority, + State: req.State, + Repositories: repos, + AssignedTo: req.AssignedTo, + Position: req.Position, + Metadata: req.Metadata, }) if err != nil { return dto.TaskDTO{}, err diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index ffefac52c8..92e353b001 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -92,26 +92,36 @@ type EnvironmentDTO struct { } type TaskDTO 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"` - 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"` - UpdatedAt time.Time `json:"updated_at"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + 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"` + Repositories []TaskRepositoryDTO `json:"repositories,omitempty"` + AssignedAgentID *string `json:"assigned_agent_id,omitempty"` + Position int `json:"position"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Metadata map[string]interface{} `json:"metadata,omitempty"` // Worktree information (populated if worktree exists for this task) WorktreePath *string `json:"worktree_path,omitempty"` WorktreeBranch *string `json:"worktree_branch,omitempty"` } +type TaskRepositoryDTO struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type TaskSessionDTO struct { ID string `json:"id"` TaskID string `json:"task_id"` @@ -348,19 +358,26 @@ func FromLocalRepository(repo service.LocalRepository) LocalRepositoryDTO { } func FromTask(task *models.Task) TaskDTO { - var repositoryID *string - if task.RepositoryID != "" { - repositoryID = &task.RepositoryID - } - var baseBranch *string - if task.BaseBranch != "" { - baseBranch = &task.BaseBranch - } var assignedAgentID *string if task.AssignedTo != "" { assignedAgentID = &task.AssignedTo } + // Convert repositories + var repositories []TaskRepositoryDTO + for _, repo := range task.Repositories { + repositories = append(repositories, TaskRepositoryDTO{ + ID: repo.ID, + TaskID: repo.TaskID, + RepositoryID: repo.RepositoryID, + BaseBranch: repo.BaseBranch, + Position: repo.Position, + Metadata: repo.Metadata, + CreatedAt: repo.CreatedAt, + UpdatedAt: repo.UpdatedAt, + }) + } + return TaskDTO{ ID: task.ID, WorkspaceID: task.WorkspaceID, @@ -370,8 +387,7 @@ func FromTask(task *models.Task) TaskDTO { Description: task.Description, State: task.State, Priority: task.Priority, - RepositoryID: repositoryID, - BaseBranch: baseBranch, + Repositories: repositories, AssignedAgentID: assignedAgentID, Position: task.Position, CreatedAt: task.CreatedAt, @@ -391,9 +407,6 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { EnvironmentID: session.EnvironmentID, RepositoryID: session.RepositoryID, BaseBranch: session.BaseBranch, - WorktreeID: session.WorktreeID, - WorktreePath: session.WorktreePath, - WorktreeBranch: session.WorktreeBranch, State: session.State, Progress: session.Progress, ErrorMessage: session.ErrorMessage, @@ -406,5 +419,10 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { CompletedAt: session.CompletedAt, UpdatedAt: session.UpdatedAt, } + if len(session.Worktrees) > 0 { + result.WorktreeID = session.Worktrees[0].WorktreeID + result.WorktreePath = session.Worktrees[0].WorktreePath + result.WorktreeBranch = session.Worktrees[0].WorktreeBranch + } return result } diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 238322fb94..e30ad97798 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -237,6 +237,11 @@ type GetTaskRequest struct { ID string } +type TaskRepositoryInput struct { + RepositoryID string + BaseBranch string +} + type CreateTaskRequest struct { WorkspaceID string BoardID string @@ -245,24 +250,22 @@ type CreateTaskRequest struct { Description string Priority int State *v1.TaskState - RepositoryID string - BaseBranch string + Repositories []TaskRepositoryInput AssignedTo string Position int Metadata map[string]interface{} } type UpdateTaskRequest struct { - ID string - Title *string - Description *string - Priority *int - State *v1.TaskState - RepositoryID *string - BaseBranch *string - AssignedTo *string - Position *int - Metadata map[string]interface{} + ID string + Title *string + Description *string + Priority *int + State *v1.TaskState + Repositories []TaskRepositoryInput + AssignedTo *string + Position *int + Metadata map[string]interface{} } type DeleteTaskRequest struct { diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index fe2afd582f..183c7e577a 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -83,19 +83,23 @@ func (h *TaskHandlers) httpListTaskSessions(c *gin.Context) { c.JSON(http.StatusOK, resp) } +type httpTaskRepositoryInput struct { + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` +} + type httpCreateTaskRequest struct { - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - ColumnID string `json:"column_id"` - Title string `json:"title"` - 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"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + AssignedTo string `json:"assigned_to,omitempty"` + Position int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } func (h *TaskHandlers) httpCreateTask(c *gin.Context) { @@ -108,23 +112,28 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) return } - if body.RepositoryID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id is required"}) - return + + // Convert repositories + var repos []dto.TaskRepositoryInput + for _, r := range body.Repositories { + repos = append(repos, dto.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + }) } + resp, err := h.controller.CreateTask(c.Request.Context(), dto.CreateTaskRequest{ - WorkspaceID: body.WorkspaceID, - BoardID: body.BoardID, - ColumnID: body.ColumnID, - Title: body.Title, - Description: body.Description, - Priority: body.Priority, - State: body.State, - RepositoryID: body.RepositoryID, - BaseBranch: body.BaseBranch, - AssignedTo: body.AssignedTo, - Position: body.Position, - Metadata: body.Metadata, + WorkspaceID: body.WorkspaceID, + BoardID: body.BoardID, + ColumnID: body.ColumnID, + Title: body.Title, + Description: body.Description, + Priority: body.Priority, + State: body.State, + Repositories: repos, + AssignedTo: body.AssignedTo, + Position: body.Position, + Metadata: body.Metadata, }) if err != nil { handleNotFound(c, h.logger, err, "task not created") @@ -134,15 +143,14 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { } type httpUpdateTaskRequest struct { - 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"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + AssignedTo *string `json:"assigned_to,omitempty"` + Position *int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { @@ -151,17 +159,28 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } + + // Convert repositories if provided + var repos []dto.TaskRepositoryInput + if body.Repositories != nil { + for _, r := range body.Repositories { + repos = append(repos, dto.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + }) + } + } + resp, err := h.controller.UpdateTask(c.Request.Context(), dto.UpdateTaskRequest{ - ID: c.Param("id"), - Title: body.Title, - Description: body.Description, - Priority: body.Priority, - State: body.State, - RepositoryID: body.RepositoryID, - BaseBranch: body.BaseBranch, - AssignedTo: body.AssignedTo, - Position: body.Position, - Metadata: body.Metadata, + ID: c.Param("id"), + Title: body.Title, + Description: body.Description, + Priority: body.Priority, + State: body.State, + Repositories: repos, + AssignedTo: body.AssignedTo, + Position: body.Position, + Metadata: body.Metadata, }) if err != nil { handleNotFound(c, h.logger, err, "task not updated") @@ -253,18 +272,17 @@ func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Me } type wsCreateTaskRequest struct { - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - ColumnID string `json:"column_id"` - Title string `json:"title"` - 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"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + AssignedTo string `json:"assigned_to,omitempty"` + Position int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -284,23 +302,28 @@ 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.RepositoryID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id is required", nil) + + // Convert repositories + var repos []dto.TaskRepositoryInput + for _, r := range req.Repositories { + repos = append(repos, dto.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + }) } resp, err := h.controller.CreateTask(ctx, dto.CreateTaskRequest{ - WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, - ColumnID: req.ColumnID, - Title: req.Title, - Description: req.Description, - Priority: req.Priority, - State: req.State, - RepositoryID: req.RepositoryID, - BaseBranch: req.BaseBranch, - AssignedTo: req.AssignedTo, - Position: req.Position, - Metadata: req.Metadata, + WorkspaceID: req.WorkspaceID, + BoardID: req.BoardID, + ColumnID: req.ColumnID, + Title: req.Title, + Description: req.Description, + Priority: req.Priority, + State: req.State, + Repositories: repos, + AssignedTo: req.AssignedTo, + Position: req.Position, + Metadata: req.Metadata, }) if err != nil { h.logger.Error("failed to create task", zap.Error(err)) @@ -330,16 +353,15 @@ func (h *TaskHandlers) wsGetTask(ctx context.Context, msg *ws.Message) (*ws.Mess } type wsUpdateTaskRequest struct { - ID string `json:"id"` - 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"` + ID string `json:"id"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + AssignedTo *string `json:"assigned_to,omitempty"` + Position *int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -351,17 +373,27 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) } + // Convert repositories if provided + var repos []dto.TaskRepositoryInput + if req.Repositories != nil { + for _, r := range req.Repositories { + repos = append(repos, dto.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + }) + } + } + resp, err := h.controller.UpdateTask(ctx, dto.UpdateTaskRequest{ - ID: req.ID, - Title: req.Title, - Description: req.Description, - Priority: req.Priority, - State: req.State, - RepositoryID: req.RepositoryID, - BaseBranch: req.BaseBranch, - AssignedTo: req.AssignedTo, - Position: req.Position, - Metadata: req.Metadata, + ID: req.ID, + Title: req.Title, + Description: req.Description, + Priority: req.Priority, + State: req.State, + Repositories: repos, + AssignedTo: req.AssignedTo, + Position: req.Position, + Metadata: req.Metadata, }) if err != nil { h.logger.Error("failed to update task", zap.Error(err)) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 6c3090a622..791dfde452 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -16,11 +16,10 @@ type Task struct { 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"` + Repositories []*TaskRepository `json:"repositories,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -60,6 +59,18 @@ type Column struct { UpdatedAt time.Time `json:"updated_at"` } +// TaskRepository represents a repository associated with a task +type TaskRepository struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // MessageAuthorType represents who authored a message type MessageAuthorType string @@ -143,6 +154,20 @@ const ( TaskSessionStateCancelled TaskSessionState = "CANCELLED" ) +// TaskSessionWorktree represents the association between a task session and a worktree +type TaskSessionWorktree struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + WorktreeID string `json:"worktree_id"` + RepositoryID string `json:"repository_id"` + Position int `json:"position"` + CreatedAt time.Time `json:"created_at"` + + // Worktree details stored on this association + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` +} + // TaskSession represents a persistent agent execution session for a task. // This replaces the in-memory TaskExecution tracking and survives backend restarts. type TaskSession struct { @@ -153,11 +178,9 @@ type TaskSession struct { 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"` + RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility) + BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility) + Worktrees []*TaskSessionWorktree `json:"worktrees,omitempty"` // Associated worktrees 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"` @@ -184,14 +207,17 @@ func (s *TaskSession) ToAPI() map[string]interface{} { "environment_id": s.EnvironmentID, "repository_id": s.RepositoryID, "base_branch": s.BaseBranch, - "worktree_id": s.WorktreeID, - "worktree_path": s.WorktreePath, - "worktree_branch": s.WorktreeBranch, + "worktrees": s.Worktrees, "state": string(s.State), "progress": s.Progress, "started_at": s.StartedAt, "updated_at": s.UpdatedAt, } + // For backward compatibility, populate worktree_path and worktree_branch from first worktree + if len(s.Worktrees) > 0 { + result["worktree_path"] = s.Worktrees[0].WorktreePath + result["worktree_branch"] = s.Worktrees[0].WorktreeBranch + } if s.ErrorMessage != "" { result["error_message"] = s.ErrorMessage } @@ -316,21 +342,26 @@ type Environment struct { // ToAPI converts internal Task to API type func (t *Task) ToAPI() *v1.Task { - var repositoryID *string - if t.RepositoryID != "" { - repositoryID = &t.RepositoryID - } - - var baseBranch *string - if t.BaseBranch != "" { - baseBranch = &t.BaseBranch - } - var assignedAgentID *string if t.AssignedTo != "" { assignedAgentID = &t.AssignedTo } + // Convert TaskRepository models to API types + var repositories []v1.TaskRepository + for _, repo := range t.Repositories { + repositories = append(repositories, v1.TaskRepository{ + ID: repo.ID, + TaskID: repo.TaskID, + RepositoryID: repo.RepositoryID, + BaseBranch: repo.BaseBranch, + Position: repo.Position, + Metadata: repo.Metadata, + CreatedAt: repo.CreatedAt, + UpdatedAt: repo.UpdatedAt, + }) + } + return &v1.Task{ ID: t.ID, WorkspaceID: t.WorkspaceID, @@ -339,9 +370,8 @@ func (t *Task) ToAPI() *v1.Task { Description: t.Description, State: t.State, Priority: t.Priority, - RepositoryID: repositoryID, - BaseBranch: baseBranch, AssignedAgentID: assignedAgentID, + Repositories: repositories, CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, Metadata: t.Metadata, diff --git a/apps/backend/internal/task/models/models_test.go b/apps/backend/internal/task/models/models_test.go index 5d82459a74..5b218c8793 100644 --- a/apps/backend/internal/task/models/models_test.go +++ b/apps/backend/internal/task/models/models_test.go @@ -36,6 +36,18 @@ func TestTaskStateConstants(t *testing.T) { func TestTaskStructInitialization(t *testing.T) { now := time.Now().UTC() + repos := []*TaskRepository{ + { + ID: "task-repo-1", + TaskID: "task-123", + RepositoryID: "repo-123", + BaseBranch: "main", + Position: 0, + Metadata: map[string]interface{}{"role": "primary"}, + CreatedAt: now, + UpdatedAt: now, + }, + } task := Task{ ID: "task-123", WorkspaceID: "workspace-001", @@ -45,11 +57,10 @@ func TestTaskStructInitialization(t *testing.T) { Description: "A test task description", State: v1.TaskStateTODO, Priority: 5, - RepositoryID: "repo-123", - BaseBranch: "main", AssignedTo: "agent-001", Position: 1, Metadata: map[string]interface{}{"key": "value"}, + Repositories: repos, CreatedAt: now, UpdatedAt: now, } @@ -78,11 +89,14 @@ func TestTaskStructInitialization(t *testing.T) { if task.Priority != 5 { t.Errorf("expected Priority 5, got %d", task.Priority) } - if task.RepositoryID != "repo-123" { - t.Errorf("expected RepositoryID 'repo-123', got %s", task.RepositoryID) + if len(task.Repositories) != 1 { + t.Fatalf("expected 1 repository, got %d", len(task.Repositories)) } - if task.BaseBranch != "main" { - t.Errorf("expected BaseBranch 'main', got %s", task.BaseBranch) + if task.Repositories[0].RepositoryID != "repo-123" { + t.Errorf("expected RepositoryID 'repo-123', got %s", task.Repositories[0].RepositoryID) + } + if task.Repositories[0].BaseBranch != "main" { + t.Errorf("expected BaseBranch 'main', got %s", task.Repositories[0].BaseBranch) } if task.AssignedTo != "agent-001" { t.Errorf("expected AssignedTo 'agent-001', got %s", task.AssignedTo) @@ -160,8 +174,18 @@ func TestTaskToAPI(t *testing.T) { Description: "A test task description", State: v1.TaskStateInProgress, Priority: 3, - RepositoryID: "repo-123", - BaseBranch: "main", + Repositories: []*TaskRepository{ + { + ID: "task-repo-1", + TaskID: "task-123", + RepositoryID: "repo-123", + BaseBranch: "main", + Position: 0, + Metadata: map[string]interface{}{"role": "primary"}, + CreatedAt: now, + UpdatedAt: now, + }, + }, AssignedTo: "agent-001", Position: 2, Metadata: map[string]interface{}{"key": "value"}, @@ -192,11 +216,14 @@ func TestTaskToAPI(t *testing.T) { if apiTask.Priority != task.Priority { t.Errorf("expected Priority %d, got %d", task.Priority, apiTask.Priority) } - if apiTask.RepositoryID == nil || *apiTask.RepositoryID != task.RepositoryID { - t.Errorf("expected RepositoryID %s, got %v", task.RepositoryID, apiTask.RepositoryID) + if len(apiTask.Repositories) != 1 { + t.Fatalf("expected 1 repository, got %d", len(apiTask.Repositories)) } - if apiTask.BaseBranch == nil || *apiTask.BaseBranch != task.BaseBranch { - t.Errorf("expected BaseBranch %s, got %v", task.BaseBranch, apiTask.BaseBranch) + if apiTask.Repositories[0].RepositoryID != "repo-123" { + t.Errorf("expected RepositoryID repo-123, got %s", apiTask.Repositories[0].RepositoryID) + } + if apiTask.Repositories[0].BaseBranch != "main" { + t.Errorf("expected BaseBranch main, got %s", apiTask.Repositories[0].BaseBranch) } if apiTask.AssignedAgentID == nil || *apiTask.AssignedAgentID != task.AssignedTo { t.Errorf("expected AssignedAgentID %s, got %v", task.AssignedTo, apiTask.AssignedAgentID) @@ -217,8 +244,6 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) { Description: "A test task description", State: v1.TaskStateTODO, Priority: 0, - RepositoryID: "", - BaseBranch: "", AssignedTo: "", Position: 0, Metadata: nil, @@ -228,11 +253,8 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) { apiTask := task.ToAPI() - 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 len(apiTask.Repositories) != 0 { + t.Errorf("expected no repositories, got %d", len(apiTask.Repositories)) } 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 ea5447c68d..b44ce361a9 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -34,6 +34,15 @@ type Repository interface { AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error + // TaskRepository operations + CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error + GetTaskRepository(ctx context.Context, id string) (*models.TaskRepository, error) + ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) + UpdateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error + DeleteTaskRepository(ctx context.Context, id string) error + DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error + GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) + // Board operations CreateBoard(ctx context.Context, board *models.Board) error GetBoard(ctx context.Context, id string) (*models.Board, error) @@ -73,6 +82,12 @@ type Repository interface { HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) DeleteTaskSession(ctx context.Context, id string) error + // Task Session Worktree operations + CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) + DeleteTaskSessionWorktree(ctx context.Context, id string) error + DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error + // Repository operations CreateRepository(ctx context.Context, repository *models.Repository) error GetRepository(ctx context.Context, id string) (*models.Repository, error) diff --git a/apps/backend/internal/task/repository/memory.go b/apps/backend/internal/task/repository/memory.go index 91d008adb7..3db1eb0399 100644 --- a/apps/backend/internal/task/repository/memory.go +++ b/apps/backend/internal/task/repository/memory.go @@ -15,19 +15,21 @@ import ( // MemoryRepository provides in-memory task storage operations type MemoryRepository struct { - workspaces map[string]*models.Workspace - tasks map[string]*models.Task - boards map[string]*models.Board - columns map[string]*models.Column - messages map[string]*models.Message - repositories map[string]*models.Repository - repoScripts map[string]*models.RepositoryScript - taskSessions map[string]*models.TaskSession - executors map[string]*models.Executor - environments map[string]*models.Environment - taskBoards map[string]map[string]struct{} - taskPlacements map[string]map[string]*taskPlacement - mu sync.RWMutex + workspaces map[string]*models.Workspace + tasks map[string]*models.Task + boards map[string]*models.Board + columns map[string]*models.Column + messages map[string]*models.Message + repositories map[string]*models.Repository + repoScripts map[string]*models.RepositoryScript + taskRepositories map[string]*models.TaskRepository + taskSessions map[string]*models.TaskSession + taskSessionWorktrees map[string]*models.TaskSessionWorktree + executors map[string]*models.Executor + environments map[string]*models.Environment + taskBoards map[string]map[string]struct{} + taskPlacements map[string]map[string]*taskPlacement + mu sync.RWMutex } // Ensure MemoryRepository implements Repository interface @@ -36,18 +38,20 @@ var _ Repository = (*MemoryRepository)(nil) // NewMemoryRepository creates a new in-memory task repository func NewMemoryRepository() *MemoryRepository { repo := &MemoryRepository{ - workspaces: make(map[string]*models.Workspace), - tasks: make(map[string]*models.Task), - boards: make(map[string]*models.Board), - columns: make(map[string]*models.Column), - messages: make(map[string]*models.Message), - repositories: make(map[string]*models.Repository), - repoScripts: make(map[string]*models.RepositoryScript), - taskSessions: make(map[string]*models.TaskSession), - executors: make(map[string]*models.Executor), - environments: make(map[string]*models.Environment), - taskBoards: make(map[string]map[string]struct{}), - taskPlacements: make(map[string]map[string]*taskPlacement), + workspaces: make(map[string]*models.Workspace), + tasks: make(map[string]*models.Task), + boards: make(map[string]*models.Board), + columns: make(map[string]*models.Column), + messages: make(map[string]*models.Message), + repositories: make(map[string]*models.Repository), + repoScripts: make(map[string]*models.RepositoryScript), + taskRepositories: make(map[string]*models.TaskRepository), + taskSessions: make(map[string]*models.TaskSession), + taskSessionWorktrees: make(map[string]*models.TaskSessionWorktree), + executors: make(map[string]*models.Executor), + environments: make(map[string]*models.Environment), + taskBoards: make(map[string]map[string]struct{}), + taskPlacements: make(map[string]map[string]*taskPlacement), } repo.seedDefaults() return repo @@ -321,6 +325,111 @@ func (r *MemoryRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boar return nil } +// TaskRepository operations + +// CreateTaskRepository creates a new task-repository association +func (r *MemoryRepository) CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { + r.mu.Lock() + defer r.mu.Unlock() + + if taskRepo.ID == "" { + taskRepo.ID = uuid.New().String() + } + now := time.Now().UTC() + taskRepo.CreatedAt = now + taskRepo.UpdatedAt = now + + r.taskRepositories[taskRepo.ID] = taskRepo + return nil +} + +// GetTaskRepository retrieves a task repository by ID +func (r *MemoryRepository) GetTaskRepository(ctx context.Context, id string) (*models.TaskRepository, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + taskRepo, ok := r.taskRepositories[id] + if !ok { + return nil, fmt.Errorf("task repository not found: %s", id) + } + return taskRepo, nil +} + +// ListTaskRepositories retrieves all repositories for a task, ordered by position +func (r *MemoryRepository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*models.TaskRepository + for _, taskRepo := range r.taskRepositories { + if taskRepo.TaskID == taskID { + result = append(result, taskRepo) + } + } + + // Sort by position then created_at + sort.Slice(result, func(i, j int) bool { + if result[i].Position != result[j].Position { + return result[i].Position < result[j].Position + } + return result[i].CreatedAt.Before(result[j].CreatedAt) + }) + + return result, nil +} + +// UpdateTaskRepository updates an existing task repository +func (r *MemoryRepository) UpdateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.taskRepositories[taskRepo.ID]; !ok { + return fmt.Errorf("task repository not found: %s", taskRepo.ID) + } + + taskRepo.UpdatedAt = time.Now().UTC() + r.taskRepositories[taskRepo.ID] = taskRepo + return nil +} + +// DeleteTaskRepository deletes a task repository by ID +func (r *MemoryRepository) DeleteTaskRepository(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.taskRepositories[id]; !ok { + return fmt.Errorf("task repository not found: %s", id) + } + + delete(r.taskRepositories, id) + return nil +} + +// DeleteTaskRepositoriesByTask deletes all repositories for a task +func (r *MemoryRepository) DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error { + r.mu.Lock() + defer r.mu.Unlock() + + for id, taskRepo := range r.taskRepositories { + if taskRepo.TaskID == taskID { + delete(r.taskRepositories, id) + } + } + return nil +} + +// GetPrimaryTaskRepository retrieves the primary repository for a task (position=0 or first by created_at) +func (r *MemoryRepository) GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) { + repos, err := r.ListTaskRepositories(ctx, taskID) + if err != nil { + return nil, err + } + if len(repos) == 0 { + return nil, nil // No repositories for this task + } + return repos[0], nil +} + // Board operations // CreateBoard creates a new board @@ -827,19 +936,26 @@ func (r *MemoryRepository) CreateTaskSession(ctx context.Context, session *model // GetTaskSession retrieves an agent session by ID func (r *MemoryRepository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) { r.mu.RLock() - defer r.mu.RUnlock() session, ok := r.taskSessions[id] if !ok { + r.mu.RUnlock() return nil, fmt.Errorf("agent session not found: %s", id) } - return session, nil + sessionCopy := *session + r.mu.RUnlock() + + worktrees, err := r.ListTaskSessionWorktrees(ctx, sessionCopy.ID) + if err != nil { + return nil, err + } + sessionCopy.Worktrees = worktrees + return &sessionCopy, nil } // GetTaskSessionByTaskID retrieves the most recent agent session for a task func (r *MemoryRepository) GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { r.mu.RLock() - defer r.mu.RUnlock() var latest *models.TaskSession for _, session := range r.taskSessions { @@ -850,21 +966,38 @@ func (r *MemoryRepository) GetTaskSessionByTaskID(ctx context.Context, taskID st } } if latest == nil { + r.mu.RUnlock() return nil, fmt.Errorf("no agent session found for task: %s", taskID) } - return latest, nil + sessionCopy := *latest + r.mu.RUnlock() + + worktrees, err := r.ListTaskSessionWorktrees(ctx, sessionCopy.ID) + if err != nil { + return nil, err + } + sessionCopy.Worktrees = worktrees + return &sessionCopy, nil } // GetActiveTaskSessionByTaskID retrieves the active agent session for a task func (r *MemoryRepository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { r.mu.RLock() - defer r.mu.RUnlock() for _, session := range r.taskSessions { if session.TaskID == taskID && (session.State == models.TaskSessionStateCreated || session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning || session.State == models.TaskSessionStateWaitingForInput) { - return session, nil + sessionCopy := *session + r.mu.RUnlock() + + worktrees, err := r.ListTaskSessionWorktrees(ctx, sessionCopy.ID) + if err != nil { + return nil, err + } + sessionCopy.Worktrees = worktrees + return &sessionCopy, nil } } + r.mu.RUnlock() return nil, fmt.Errorf("no active agent session found for task: %s", taskID) } @@ -903,28 +1036,46 @@ func (r *MemoryRepository) UpdateTaskSessionState(ctx context.Context, id string // ListTaskSessions returns all agent sessions for a task func (r *MemoryRepository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { r.mu.RLock() - defer r.mu.RUnlock() var result []*models.TaskSession for _, session := range r.taskSessions { if session.TaskID == taskID { - result = append(result, session) + sessionCopy := *session + result = append(result, &sessionCopy) } } + r.mu.RUnlock() + + for _, session := range result { + worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) + if err != nil { + return nil, err + } + session.Worktrees = worktrees + } return result, nil } // ListActiveTaskSessions returns all active agent sessions func (r *MemoryRepository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) { r.mu.RLock() - defer r.mu.RUnlock() var result []*models.TaskSession for _, session := range r.taskSessions { if session.State == models.TaskSessionStateCreated || session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning || session.State == models.TaskSessionStateWaitingForInput { - result = append(result, session) + sessionCopy := *session + result = append(result, &sessionCopy) } } + r.mu.RUnlock() + + for _, session := range result { + worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) + if err != nil { + return nil, err + } + session.Worktrees = worktrees + } return result, nil } @@ -972,12 +1123,11 @@ func (r *MemoryRepository) HasActiveTaskSessionsByRepository(ctx context.Context if !isSessionActive(session.State) { continue } - task, ok := r.tasks[session.TaskID] - if !ok { - continue - } - if task.RepositoryID == repositoryID { - return true, nil + // Check if any task repository matches + for _, taskRepo := range r.taskRepositories { + if taskRepo.TaskID == session.TaskID && taskRepo.RepositoryID == repositoryID { + return true, nil + } } } return false, nil @@ -992,6 +1142,80 @@ func (r *MemoryRepository) DeleteTaskSession(ctx context.Context, id string) err return fmt.Errorf("agent session not found: %s", id) } delete(r.taskSessions, id) + for wtID, wt := range r.taskSessionWorktrees { + if wt.SessionID == id { + delete(r.taskSessionWorktrees, wtID) + } + } + return nil +} + +// Task Session Worktree operations + +func (r *MemoryRepository) CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error { + r.mu.Lock() + defer r.mu.Unlock() + + for _, existing := range r.taskSessionWorktrees { + if existing.SessionID == sessionWorktree.SessionID && existing.WorktreeID == sessionWorktree.WorktreeID { + existing.RepositoryID = sessionWorktree.RepositoryID + existing.Position = sessionWorktree.Position + existing.WorktreePath = sessionWorktree.WorktreePath + existing.WorktreeBranch = sessionWorktree.WorktreeBranch + return nil + } + } + if sessionWorktree.ID == "" { + sessionWorktree.ID = uuid.New().String() + } + sessionWorktree.CreatedAt = time.Now().UTC() + + r.taskSessionWorktrees[sessionWorktree.ID] = sessionWorktree + return nil +} + +func (r *MemoryRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var worktrees []*models.TaskSessionWorktree + for _, wt := range r.taskSessionWorktrees { + if wt.SessionID == sessionID { + worktrees = append(worktrees, wt) + } + } + + // Sort by position, then created_at + sort.Slice(worktrees, func(i, j int) bool { + if worktrees[i].Position != worktrees[j].Position { + return worktrees[i].Position < worktrees[j].Position + } + return worktrees[i].CreatedAt.Before(worktrees[j].CreatedAt) + }) + + return worktrees, nil +} + +func (r *MemoryRepository) DeleteTaskSessionWorktree(ctx context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.taskSessionWorktrees[id]; !ok { + return fmt.Errorf("task session worktree not found: %s", id) + } + delete(r.taskSessionWorktrees, id) + return nil +} + +func (r *MemoryRepository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error { + r.mu.Lock() + defer r.mu.Unlock() + + for id, wt := range r.taskSessionWorktrees { + if wt.SessionID == sessionID { + delete(r.taskSessionWorktrees, id) + } + } return nil } diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index 1f2752ed80..de5dab1ca4 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -155,8 +155,6 @@ func (r *SQLiteRepository) initSchema() error { description TEXT DEFAULT '', state TEXT DEFAULT 'TODO', priority INTEGER DEFAULT 0, - repository_id TEXT DEFAULT '', - base_branch TEXT DEFAULT '', assigned_to TEXT DEFAULT '', position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', @@ -166,6 +164,20 @@ func (r *SQLiteRepository) initSchema() error { FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS task_repositories ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + base_branch TEXT DEFAULT '', + position INTEGER DEFAULT 0, + metadata TEXT DEFAULT '{}', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE, + UNIQUE(task_id, repository_id) + ); + CREATE TABLE IF NOT EXISTS repositories ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, @@ -198,6 +210,8 @@ func (r *SQLiteRepository) initSchema() error { 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_task_repositories_task_id ON task_repositories(task_id); + CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id); CREATE INDEX IF NOT EXISTS idx_columns_board_id ON columns(board_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); @@ -230,9 +244,6 @@ func (r *SQLiteRepository) initSchema() error { environment_id TEXT DEFAULT '', 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 '{}', @@ -250,6 +261,28 @@ func (r *SQLiteRepository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_task_sessions_task_id ON task_sessions(task_id); CREATE INDEX IF NOT EXISTS idx_task_sessions_state ON task_sessions(state); CREATE INDEX IF NOT EXISTS idx_task_sessions_task_state ON task_sessions(task_id, state); + + CREATE TABLE IF NOT EXISTS task_session_worktrees ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + worktree_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + position INTEGER DEFAULT 0, + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + merged_at DATETIME, + deleted_at DATETIME, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, + UNIQUE(session_id, worktree_id) + ); + + CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_session_id ON task_session_worktrees(session_id); + CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_worktree_id ON task_session_worktrees(worktree_id); + CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_repository_id ON task_session_worktrees(repository_id); + CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_status ON task_session_worktrees(status); ` if _, err := r.db.Exec(schema); err != nil { @@ -772,9 +805,9 @@ 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, 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) + INSERT INTO tasks (id, workspace_id, board_id, column_id, title, description, state, priority, 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.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { return fmt.Errorf("failed to rollback task insert: %w", rollbackErr) @@ -789,12 +822,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 repositoryID, baseBranch, assignedTo sql.NullString + var assignedTo sql.NullString err := r.db.QueryRowContext(ctx, ` - SELECT id, workspace_id, board_id, column_id, title, description, state, priority, repository_id, base_branch, assigned_to, position, metadata, created_at, updated_at + SELECT id, workspace_id, board_id, column_id, title, description, state, priority, 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, &repositoryID, &baseBranch, &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, &assignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("task not found: %s", id) @@ -803,8 +836,6 @@ func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task return nil, err } - task.RepositoryID = repositoryID.String - task.BaseBranch = baseBranch.String task.AssignedTo = assignedTo.String _ = json.Unmarshal([]byte(metadata), &task.Metadata) return task, nil @@ -820,9 +851,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 = ?, repository_id = ?, base_branch = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ? + UPDATE tasks SET workspace_id = ?, board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ? WHERE 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) + `, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID) if err != nil { return err } @@ -851,7 +882,7 @@ 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 id, workspace_id, board_id, column_id, title, description, state, priority, repository_id, base_branch, assigned_to, position, metadata, created_at, updated_at + SELECT id, workspace_id, board_id, column_id, title, description, state, priority, assigned_to, position, metadata, created_at, updated_at FROM tasks WHERE board_id = ? ORDER BY position @@ -867,7 +898,7 @@ func (r *SQLiteRepository) ListTasks(ctx context.Context, boardID string) ([]*mo // 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 id, workspace_id, board_id, column_id, title, description, state, priority, repository_id, base_branch, assigned_to, position, metadata, created_at, updated_at + SELECT id, workspace_id, board_id, column_id, title, description, state, priority, assigned_to, position, metadata, created_at, updated_at FROM tasks WHERE column_id = ? ORDER BY position `, columnID) @@ -884,7 +915,7 @@ func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { for rows.Next() { task := &models.Task{} var metadata string - var repositoryID, baseBranch, assignedTo sql.NullString + var assignedTo sql.NullString err := rows.Scan( &task.ID, &task.WorkspaceID, @@ -894,8 +925,6 @@ func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { &task.Description, &task.State, &task.Priority, - &repositoryID, - &baseBranch, &assignedTo, &task.Position, &metadata, @@ -905,8 +934,6 @@ func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { if err != nil { return nil, err } - task.RepositoryID = repositoryID.String - task.BaseBranch = baseBranch.String task.AssignedTo = assignedTo.String _ = json.Unmarshal([]byte(metadata), &task.Metadata) result = append(result, task) @@ -928,6 +955,149 @@ func (r *SQLiteRepository) UpdateTaskState(ctx context.Context, id string, state return nil } +// TaskRepository operations + +func (r *SQLiteRepository) CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { + if taskRepo.ID == "" { + taskRepo.ID = uuid.New().String() + } + now := time.Now().UTC() + taskRepo.CreatedAt = now + taskRepo.UpdatedAt = now + + metadataJSON, err := json.Marshal(taskRepo.Metadata) + if err != nil { + metadataJSON = []byte("{}") + } + + _, err = r.db.ExecContext(ctx, ` + INSERT INTO task_repositories ( + id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, taskRepo.ID, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.CreatedAt, taskRepo.UpdatedAt) + return err +} + +func (r *SQLiteRepository) GetTaskRepository(ctx context.Context, id string) (*models.TaskRepository, error) { + taskRepo := &models.TaskRepository{} + var metadataJSON string + + err := r.db.QueryRowContext(ctx, ` + SELECT id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at + FROM task_repositories WHERE id = ? + `, id).Scan( + &taskRepo.ID, + &taskRepo.TaskID, + &taskRepo.RepositoryID, + &taskRepo.BaseBranch, + &taskRepo.Position, + &metadataJSON, + &taskRepo.CreatedAt, + &taskRepo.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("task repository not found: %s", id) + } + if err != nil { + return nil, err + } + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &taskRepo.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize task repository metadata: %w", err) + } + } + return taskRepo, nil +} + +func (r *SQLiteRepository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at + FROM task_repositories + WHERE task_id = ? + ORDER BY position ASC, created_at ASC + `, taskID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var result []*models.TaskRepository + for rows.Next() { + taskRepo := &models.TaskRepository{} + var metadataJSON string + if err := rows.Scan( + &taskRepo.ID, + &taskRepo.TaskID, + &taskRepo.RepositoryID, + &taskRepo.BaseBranch, + &taskRepo.Position, + &metadataJSON, + &taskRepo.CreatedAt, + &taskRepo.UpdatedAt, + ); err != nil { + return nil, err + } + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &taskRepo.Metadata); err != nil { + return nil, fmt.Errorf("failed to deserialize task repository metadata: %w", err) + } + } + result = append(result, taskRepo) + } + return result, rows.Err() +} + +func (r *SQLiteRepository) UpdateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { + taskRepo.UpdatedAt = time.Now().UTC() + + metadataJSON, err := json.Marshal(taskRepo.Metadata) + if err != nil { + metadataJSON = []byte("{}") + } + + result, err := r.db.ExecContext(ctx, ` + UPDATE task_repositories SET + task_id = ?, repository_id = ?, base_branch = ?, position = ?, metadata = ?, updated_at = ? + WHERE id = ? + `, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.UpdatedAt, taskRepo.ID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task repository not found: %s", taskRepo.ID) + } + return nil +} + +func (r *SQLiteRepository) DeleteTaskRepository(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM task_repositories WHERE id = ?`, id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task repository not found: %s", id) + } + return nil +} + +func (r *SQLiteRepository) DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM task_repositories WHERE task_id = ?`, taskID) + return err +} + +func (r *SQLiteRepository) GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) { + repos, err := r.ListTaskRepositories(ctx, taskID) + if err != nil { + return nil, err + } + if len(repos) == 0 { + return nil, nil + } + return repos[0], nil +} + // 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, ` @@ -1633,12 +1803,12 @@ func (r *SQLiteRepository) CreateTaskSession(ctx context.Context, session *model _, err = r.db.ExecContext(ctx, ` INSERT INTO task_sessions ( 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, + repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) 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, + session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.Progress, session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt) @@ -1659,14 +1829,14 @@ func (r *SQLiteRepository) GetTaskSession(ctx context.Context, id string) (*mode err := r.db.QueryRowContext(ctx, ` 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, + repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at FROM task_sessions WHERE id = ? `, id).Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, &session.WorktreeID, &session.WorktreePath, &session.WorktreeBranch, + &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) @@ -1708,6 +1878,12 @@ func (r *SQLiteRepository) GetTaskSession(ctx context.Context, id string) (*mode } } + worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) + if err != nil { + return nil, fmt.Errorf("failed to load session worktrees: %w", err) + } + session.Worktrees = worktrees + return session, nil } @@ -1724,14 +1900,14 @@ func (r *SQLiteRepository) GetTaskSessionByTaskID(ctx context.Context, taskID st err := r.db.QueryRowContext(ctx, ` 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, + repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1 `, taskID).Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, &session.WorktreeID, &session.WorktreePath, &session.WorktreeBranch, + &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) @@ -1773,6 +1949,12 @@ func (r *SQLiteRepository) GetTaskSessionByTaskID(ctx context.Context, taskID st } } + worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) + if err != nil { + return nil, fmt.Errorf("failed to load session worktrees: %w", err) + } + session.Worktrees = worktrees + return session, nil } @@ -1789,7 +1971,7 @@ func (r *SQLiteRepository) GetActiveTaskSessionByTaskID(ctx context.Context, tas err := r.db.QueryRowContext(ctx, ` 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, + repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at FROM task_sessions @@ -1798,7 +1980,7 @@ func (r *SQLiteRepository) GetActiveTaskSessionByTaskID(ctx context.Context, tas `, taskID).Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, &session.WorktreeID, &session.WorktreePath, &session.WorktreeBranch, + &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) @@ -1840,6 +2022,12 @@ func (r *SQLiteRepository) GetActiveTaskSessionByTaskID(ctx context.Context, tas } } + worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) + if err != nil { + return nil, fmt.Errorf("failed to load session worktrees: %w", err) + } + session.Worktrees = worktrees + return session, nil } @@ -1871,12 +2059,12 @@ func (r *SQLiteRepository) UpdateTaskSession(ctx context.Context, session *model result, err := r.db.ExecContext(ctx, ` UPDATE task_sessions SET agent_instance_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, environment_id = ?, - repository_id = ?, base_branch = ?, worktree_id = ?, worktree_path = ?, worktree_branch = ?, + repository_id = ?, base_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.ExecutorID, session.EnvironmentID, - session.RepositoryID, session.BaseBranch, session.WorktreeID, session.WorktreePath, session.WorktreeBranch, + session.RepositoryID, session.BaseBranch, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.Progress, session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt, session.ID) @@ -1918,7 +2106,7 @@ func (r *SQLiteRepository) UpdateTaskSessionState(ctx context.Context, id string func (r *SQLiteRepository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { rows, err := r.db.QueryContext(ctx, ` 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, + repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC @@ -1928,14 +2116,25 @@ func (r *SQLiteRepository) ListTaskSessions(ctx context.Context, taskID string) } defer func() { _ = rows.Close() }() - return r.scanTaskSessions(rows) + sessions, err := r.scanTaskSessions(ctx, rows) + if err != nil { + return nil, err + } + for _, session := range sessions { + worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) + if err != nil { + return nil, fmt.Errorf("failed to load session worktrees: %w", err) + } + session.Worktrees = worktrees + } + return sessions, nil } // ListActiveAgentSessions returns all active agent sessions across all tasks func (r *SQLiteRepository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) { rows, err := r.db.QueryContext(ctx, ` 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, + repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at FROM task_sessions WHERE state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC @@ -1945,7 +2144,18 @@ func (r *SQLiteRepository) ListActiveTaskSessions(ctx context.Context) ([]*model } defer func() { _ = rows.Close() }() - return r.scanTaskSessions(rows) + sessions, err := r.scanTaskSessions(ctx, rows) + if err != nil { + return nil, err + } + for _, session := range sessions { + worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) + if err != nil { + return nil, fmt.Errorf("failed to load session worktrees: %w", err) + } + session.Worktrees = worktrees + } + return sessions, nil } func (r *SQLiteRepository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { @@ -2004,7 +2214,7 @@ func (r *SQLiteRepository) HasActiveTaskSessionsByRepository(ctx context.Context } // scanAgentSessions is a helper to scan multiple agent session rows -func (r *SQLiteRepository) scanTaskSessions(rows *sql.Rows) ([]*models.TaskSession, error) { +func (r *SQLiteRepository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*models.TaskSession, error) { var result []*models.TaskSession for rows.Next() { session := &models.TaskSession{} @@ -2019,7 +2229,7 @@ func (r *SQLiteRepository) scanTaskSessions(rows *sql.Rows) ([]*models.TaskSessi err := rows.Scan( &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, &session.WorktreeID, &session.WorktreePath, &session.WorktreeBranch, + &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.Progress, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, ) @@ -2076,6 +2286,94 @@ func (r *SQLiteRepository) DeleteTaskSession(ctx context.Context, id string) err return nil } +// Task Session Worktree operations + +func (r *SQLiteRepository) CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error { + if sessionWorktree.ID == "" { + sessionWorktree.ID = uuid.New().String() + } + now := time.Now().UTC() + sessionWorktree.CreatedAt = now + updatedAt := now + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO task_session_worktrees ( + id, session_id, worktree_id, repository_id, position, + worktree_path, worktree_branch, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id, worktree_id) DO UPDATE SET + repository_id = excluded.repository_id, + position = excluded.position, + worktree_path = excluded.worktree_path, + worktree_branch = excluded.worktree_branch, + updated_at = excluded.updated_at + `, + sessionWorktree.ID, + sessionWorktree.SessionID, + sessionWorktree.WorktreeID, + sessionWorktree.RepositoryID, + sessionWorktree.Position, + sessionWorktree.WorktreePath, + sessionWorktree.WorktreeBranch, + sessionWorktree.CreatedAt, + updatedAt, + ) + return err +} + +func (r *SQLiteRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT + tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, tsw.position, + tsw.worktree_path, tsw.worktree_branch, tsw.created_at + FROM task_session_worktrees tsw + WHERE tsw.session_id = ? + ORDER BY tsw.position ASC, tsw.created_at ASC + `, sessionID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var worktrees []*models.TaskSessionWorktree + for rows.Next() { + var wt models.TaskSessionWorktree + err := rows.Scan( + &wt.ID, + &wt.SessionID, + &wt.WorktreeID, + &wt.RepositoryID, + &wt.Position, + &wt.WorktreePath, + &wt.WorktreeBranch, + &wt.CreatedAt, + ) + if err != nil { + return nil, err + } + worktrees = append(worktrees, &wt) + } + return worktrees, rows.Err() +} + +func (r *SQLiteRepository) DeleteTaskSessionWorktree(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM task_session_worktrees WHERE id = ?`, id) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task session worktree not found: %s", id) + } + return nil +} + +func (r *SQLiteRepository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM task_session_worktrees WHERE session_id = ?`, sessionID) + return err +} + // Executor operations func (r *SQLiteRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index 48e411f534..6b387661fb 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/kandev/kandev/internal/agent/worktree" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -20,6 +21,9 @@ func createTestSQLiteRepo(t *testing.T) (*SQLiteRepository, func()) { if err != nil { t.Fatalf("failed to create SQLite repository: %v", err) } + if _, err := worktree.NewSQLiteStore(repo.DB()); err != nil { + t.Fatalf("failed to init worktree store: %v", err) + } cleanup := func() { if err := repo.Close(); err != nil { diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 2dda65c5ec..2860b14f6d 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -63,6 +63,12 @@ func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) { // Request types +// TaskRepositoryInput for creating/updating task repositories +type TaskRepositoryInput struct { + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` +} + // CreateTaskRequest contains the data for creating a new task type CreateTaskRequest struct { WorkspaceID string `json:"workspace_id"` @@ -72,8 +78,7 @@ type CreateTaskRequest struct { 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"` + Repositories []TaskRepositoryInput `json:"repositories,omitempty"` AssignedTo string `json:"assigned_to,omitempty"` Position int `json:"position"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -85,8 +90,7 @@ type UpdateTaskRequest struct { 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"` + Repositories []TaskRepositoryInput `json:"repositories,omitempty"` AssignedTo *string `json:"assigned_to,omitempty"` Position *int `json:"position,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -234,27 +238,22 @@ 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.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, - RepositoryID: req.RepositoryID, - BaseBranch: req.BaseBranch, - 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, + AssignedTo: req.AssignedTo, + Position: req.Position, + Metadata: req.Metadata, } if err := s.repo.CreateTask(ctx, task); err != nil { @@ -262,15 +261,51 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode return nil, err } + // Create task-repository associations + 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 + } + 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 +// GetTask retrieves a task by ID and populates repositories func (s *Service) GetTask(ctx context.Context, id string) (*models.Task, error) { - return s.repo.GetTask(ctx, id) + 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 @@ -297,12 +332,6 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ task.State = *req.State stateChanged = true } - 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 +348,38 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ 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) } @@ -366,7 +427,22 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error { // ListTasks returns all tasks for a board func (s *Service) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { - return s.repo.ListTasks(ctx, boardID) + 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. @@ -1118,12 +1194,6 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * if task.AssignedTo != "" { data["assigned_to"] = task.AssignedTo } - if task.RepositoryID != "" { - data["repository_id"] = task.RepositoryID - } - if task.BaseBranch != "" { - data["base_branch"] = task.BaseBranch - } if task.Metadata != nil { data["metadata"] = task.Metadata } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 1b866e02c3..1368498bb5 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/kandev/kandev/internal/agent/worktree" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/models" @@ -74,6 +75,9 @@ func createTestService(t *testing.T) (*Service, *MockEventBus, repository.Reposi if err != nil { t.Fatalf("failed to create test repository: %v", err) } + if _, err := worktree.NewSQLiteStore(repo.DB()); err != nil { + t.Fatalf("failed to init worktree store: %v", err) + } t.Cleanup(func() { if err := repo.Close(); err != nil { t.Errorf("failed to close repo: %v", err) @@ -107,7 +111,12 @@ func TestService_CreateTask(t *testing.T) { Title: "Test Task", Description: "A test task", Priority: 5, - RepositoryID: "repo-123", + Repositories: []TaskRepositoryInput{ + { + RepositoryID: "repo-123", + BaseBranch: "main", + }, + }, } task, err := svc.CreateTask(ctx, req) diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 19a1579d70..00b477233b 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -45,33 +45,49 @@ const ( MessageTypeTodo MessageType = "todo" ) +// TaskRepository represents a repository associated with a task +type TaskRepository struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // Task represents a Kanban task type Task struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - Title string `json:"title"` - Description string `json:"description"` - State TaskState `json:"state"` - Priority int `json:"priority"` - 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"` - UpdatedAt time.Time `json:"updated_at"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + Title string `json:"title"` + Description string `json:"description"` + State TaskState `json:"state"` + Priority int `json:"priority"` + AssignedAgentID *string `json:"assigned_agent_id,omitempty"` + Repositories []TaskRepository `json:"repositories,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } +// TaskRepositoryInput for creating/updating task repositories +type TaskRepositoryInput struct { + RepositoryID string `json:"repository_id" binding:"required"` + BaseBranch string `json:"base_branch" binding:"required"` +} + // 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"` - RepositoryID *string `json:"repository_id,omitempty"` - BaseBranch *string `json:"base_branch,omitempty"` + Repositories []TaskRepositoryInput `json:"repositories,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -80,8 +96,7 @@ 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"` - RepositoryID *string `json:"repository_id,omitempty"` - BaseBranch *string `json:"base_branch,omitempty"` + Repositories []TaskRepositoryInput `json:"repositories,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } diff --git a/apps/web/agent_chat_plan.md b/apps/web/agent_chat_plan.md deleted file mode 100644 index d411f8d56a..0000000000 --- a/apps/web/agent_chat_plan.md +++ /dev/null @@ -1,931 +0,0 @@ -# Task Chat Interface - Final Implementation Plan - -Comprehensive plan to build a high-performance, feature-rich task chat interface with SSR, lazy loading, and command support. - -**Status:** Updated after rebase from main (Jan 2026) - -**Key Changes:** -- ✅ SSR already implemented - fetches last 10 comments on server -- ✅ TanStack Virtual v3.13.18 already integrated -- ✅ Basic tool call rendering already exists -- ✅ Pagination API implemented (limit/before/after/sort + has_more/cursor) -- ✅ Smart auto-scroll added (only scrolls when at bottom) -- ✅ Lazy loading added (scroll-to-top fetch for older messages) -- ✅ CodeMirror input implemented (markdown, line wrapping, shortcut adapter, Mod-Enter submit) -- ✅ Slash + mention autocomplete added (dummy command examples) -- ✅ Message rendering refactor with adapter-style components -- ✅ Inline diff rendering (metadata diff uses @git-diff-view/react when structured) -- ⚠️ Rich content rendering partially implemented (thinking/todos/diff metadata cards, status/error/progress) -- ❌ Rich content (thinking, todos, diffs) not fully implemented - ---- - -## Architecture Overview - -### Data Flow - -``` -1. Initial Load (SSR): - Server Component → HTTP GET /api/tasks/:id/comments?limit=10&sort=desc - → Hydrate store with last 10 messages - → Client renders, scrolled to bottom - -2. WebSocket Subscription: - Client mounts → ws.subscribe(taskId) - → Receives comment.added notifications - → Appends new messages to store - → Auto-scroll if at bottom - -3. Lazy Loading (Scroll Up): - User scrolls to top → Detect via TanStack Virtual - → If not all messages loaded: HTTP GET /api/tasks/:id/comments?before=:oldestId&limit=20 - → Prepend to store - → Maintain scroll position -``` - -### Tech Stack - -**Core:** -- React 18 + Next.js 14 (SSR) -- TanStack Virtual v3 (virtualization) -- Zustand + Immer (state) -- WebSocket (real-time) - -**UI:** -- Tailwind CSS + shadcn/ui -- CodeMirror 6 (input + file editing) -- react-markdown + remark-gfm (display) -- @git-diff-view/react (existing) - -**New Dependencies:** -```json -{ - "@codemirror/view": "^6.34.1", - "@codemirror/state": "^6.4.1", - "@codemirror/lang-javascript": "^6.2.2", - "@codemirror/lang-markdown": "^6.3.1", - "@codemirror/autocomplete": "^6.18.1", - "@codemirror/commands": "^6.7.0", - "@codemirror/language": "^6.10.3", - "@codemirror/theme-one-dark": "^6.1.2", - "codemirror": "^6.0.1" -} -``` - -**Total:** ~150KB gzipped (unified solution for chat + file editing) - -**Already Installed:** -- `@tanstack/react-virtual`: v3.13.18 ✅ -- `react-markdown` + `remark-gfm` ✅ -- `@git-diff-view/react` ✅ - ---- - -## Current Implementation Status - -### ✅ Already Implemented (After Rebase) - -1. **SSR (Server-Side Rendering)** - - `app/task/[id]/page.tsx` fetches comments via `listTaskComments()` - - State hydration via `StateHydrator` - - Fetches last 10 comments on initial load (sorted asc for display) - -2. **TanStack Virtual** - - Version: 3.13.18 - - Location: `task-chat-panel.tsx` (line 256) - - Current config: `estimateSize: 96`, `overscan: 6` - - Auto-scroll to bottom on new messages - -3. **HTTP API Endpoint** - - Backend: `GET /api/v1/tasks/:id/comments` (limit/before/after/sort) - - Frontend: `listTaskComments(taskId, params)` in `lib/http/index.ts` - - Response: `{ comments, total, has_more, cursor }` - -4. **Basic Tool Call Rendering** - - Expandable tool call cards - - Tool icons and status indicators - - Args/result display - -### ❌ Still Missing (Needs Implementation) - -1. **Rich Content Display** - - Basic thinking/todos/diff blocks rendered when metadata exists - - Error/status/progress cards added - - Inline diff viewer now supported for structured metadata - - Remaining: tool call enhancements, error card details, thinking toggle, diff extraction from tool results - -4. **CodeMirror Input** - - Still using basic Textarea - - No markdown mode or syntax highlighting - - No command/mention autocomplete - -5. **Rich Content Display** - - No thinking message display - - No todo list visualization - - No inline diff viewer - - Basic error display only - ---- - -## Implementation Phases - -### Phase 1: Pagination API + Lazy Loading (Week 1) - -#### 1.1 Backend API Enhancements - -**Update Existing Endpoint:** -```go -// File: apps/backend/internal/task/dto/requests.go -type ListCommentsRequest struct { - TaskID string - Limit int // default 10, max 100 - Before string // comment ID for pagination (older) - After string // comment ID for newer messages - Sort string // "asc" | "desc", default "desc" -} - -// File: apps/backend/internal/task/dto/dto.go -type ListCommentsResponse struct { - Comments []*v1.Comment `json:"comments"` - Total int `json:"total"` - HasMore bool `json:"has_more"` // NEW - Cursor string `json:"cursor"` // NEW -} -``` - -**Implementation Steps:** -1. Update `ListCommentsRequest` DTO to accept query params -2. Modify `CommentController.ListComments()` to pass pagination params -3. Update `Service.ListComments()` to support cursor-based queries -4. Modify SQLite repository query with `WHERE created_at < ?` and `LIMIT` -5. Calculate `has_more` by checking if count > limit -6. Return cursor as the last comment's ID - -**Handler Update:** -```go -// File: apps/backend/internal/task/handlers/comment_handlers.go -func (h *CommentHandlers) httpListComments(c *gin.Context) { - taskID := c.Param("id") - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) - before := c.Query("before") - sort := c.DefaultQuery("sort", "desc") - - resp, err := h.commentController.ListComments(c.Request.Context(), dto.ListCommentsRequest{ - TaskID: taskID, - Limit: limit, - Before: before, - Sort: sort, - }) - // ... return resp with has_more and cursor -} -``` - -**Index Check:** -```sql --- Should already exist from schema -CREATE INDEX IF NOT EXISTS idx_comments_task_created - ON task_comments(task_id, created_at DESC); -``` - -#### 1.2 Update SSR to Limit to 10 - -**Server Component:** `app/task/[id]/page.tsx` -```typescript -// Update to only fetch last 10 messages -comments = await listTaskComments(id, { limit: 10 }, { cache: 'no-store' }); -``` - -**Frontend HTTP Client:** `lib/http/index.ts` -```typescript -export async function listTaskComments( - taskId: string, - params?: { limit?: number; before?: string; sort?: string }, - options?: ApiRequestOptions -) { - const query = new URLSearchParams(); - if (params?.limit) query.set('limit', params.limit.toString()); - if (params?.before) query.set('before', params.before); - if (params?.sort) query.set('sort', params.sort); - - const url = `/api/v1/tasks/${taskId}/comments${query.toString() ? `?${query}` : ''}`; - return fetchJson(url, options); -} -``` - -**Types Update:** `lib/types/http.ts` -```typescript -export interface ListCommentsResponse { - comments: Comment[]; - total: number; - has_more: boolean; // NEW - cursor: string; // NEW -} -``` - -#### 1.3 Store Enhancements - -**Zustand Store:** -```typescript -type CommentsState = { - taskId: string | null; - items: Comment[]; // Sorted by created_at ASC - isLoading: boolean; - hasMore: boolean; - oldestCursor: string | null; // For lazy loading - - // Actions - setComments(taskId: string, comments: Comment[]); - prependComments(comments: Comment[]); // For lazy load - addComment(comment: Comment); // For new messages - setCommentsMetadata(meta: { hasMore?, isLoading? }); -}; -``` - -#### 1.4 Lazy Loading Hook - -**`useLazyLoadComments.ts`:** -```typescript -export function useLazyLoadComments(taskId: string) { - const { hasMore, oldestCursor, isLoading } = useStore(s => s.comments); - const prependComments = useStore(s => s.prependComments); - - const loadMore = useCallback(async () => { - if (!hasMore || isLoading) return; - - setCommentsMetadata({ isLoading: true }); - - const response = await fetch( - `/api/tasks/${taskId}/comments?limit=20&before=${oldestCursor}` - ); - const { comments, has_more, cursor } = await response.json(); - - prependComments(comments); - setCommentsMetadata({ - hasMore: has_more, - oldestCursor: cursor, - isLoading: false - }); - }, [taskId, hasMore, oldestCursor, isLoading]); - - return { loadMore, hasMore, isLoading }; -} -``` - ---- - -### Phase 2: Smart Auto-Scroll + Lazy Load Detection (Week 1) - -#### 2.1 Enhance Existing Virtual List - -**Update:** `task-chat-panel.tsx` (already has TanStack Virtual at line 256) - -**Add Lazy Load Detection:** -```typescript -// In TaskChatPanel component, after virtualizer setup (line 261) -const { loadMore, hasMore, isLoading } = useLazyLoadComments(taskId); - -// Detect scroll to top for lazy loading -useEffect(() => { - const [firstItem] = virtualizer.getVirtualItems(); - if (!firstItem) return; - - // If first visible item is index 0 and we have more - if (firstItem.index === 0 && hasMore && !isLoading) { - loadMore(); - } -}, [virtualizer.getVirtualItems(), hasMore, isLoading]); - -// Show loading indicator at top when loading more -const showLoadingIndicator = isLoading && hasMore; -``` - -#### 2.2 Smart Auto-Scroll - -**Replace Existing Auto-Scroll Logic** (currently at line 263-267 in `task-chat-panel.tsx`): - -```typescript -// Add ref to track if user was at bottom -const wasAtBottomRef = useRef(true); - -// Check if at bottom on scroll -const checkAtBottom = useCallback(() => { - const element = messagesContainerRef.current; - if (!element) return; - - const { scrollTop, scrollHeight, clientHeight } = element; - wasAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 50; -}, []); - -// Add scroll listener -useEffect(() => { - const element = messagesContainerRef.current; - if (!element) return; - - element.addEventListener('scroll', checkAtBottom); - return () => element.removeEventListener('scroll', checkAtBottom); -}, [checkAtBottom]); - -// REPLACE existing auto-scroll effect (line 264-267) -// OLD: Always scrolls to bottom -// NEW: Only scroll if user was at bottom -useEffect(() => { - if (itemCount === 0) return; - - if (wasAtBottomRef.current) { - virtualizer.scrollToIndex(itemCount - 1, { - align: 'end', - behavior: 'smooth', - }); - } -}, [itemCount, virtualizer]); -``` - -#### 2.3 Maintain Scroll Position on Prepend - -**Strategy:** -```typescript -// Before prepending new messages -const scrollElement = parentRef.current; -const oldScrollHeight = scrollElement.scrollHeight; -const oldScrollTop = scrollElement.scrollTop; - -// Prepend messages to store -prependComments(newComments); - -// After render, restore scroll position -requestAnimationFrame(() => { - const newScrollHeight = scrollElement.scrollHeight; - scrollElement.scrollTop = oldScrollTop + (newScrollHeight - oldScrollHeight); -}); -``` - ---- - -### Phase 3: Component Architecture (Week 1-2) - -#### 3.1 Component Hierarchy - -``` -TaskChatPanel (refactored) -├── TaskChatHeader -│ ├── Agent selector -│ ├── Settings (thinking, diff view) -│ └── Loading indicator -├── TaskChatMessages (TanStack Virtual) -│ ├── Lazy load indicator (top) -│ └── MessageRenderer (type router) -│ ├── UserMessage -│ ├── AssistantMessage + ToolCallList -│ ├── ThinkingMessage -│ ├── ToolCallCard (recursive for sub-agents) -│ ├── TodoListCard -│ ├── DiffCard -│ ├── ErrorCard -│ └── SystemMessage -└── TaskChatInput (CodeMirror with commands) -``` - -#### 3.2 Message Components - -**Key Components:** - -1. **MessageRenderer** - Type router based on `comment.type` -2. **UserMessage** - Avatar, timestamp, markdown content -3. **AssistantMessage** - Avatar, duration, tokens, copy button, markdown + tool calls -4. **ToolCallCard** - Collapsible, status badge, nested sub-agents, specialized renderers -5. **TodoListCard** - Progress bar, status icons, animated updates -6. **DiffCard** - File stats, expandable, uses @git-diff-view/react -7. **ErrorCard** - Error icon, message, stack trace -8. **ThinkingMessage** - Italic, bordered, toggle visibility -9. **SystemMessage** - Session info, warnings, status updates - -**Shared Components:** -- `MessageHeader` - Avatar, name, timestamp, metadata -- `CopyButton` - Copy message content -- `ExpandButton` - Expand/collapse long content -- `StatusBadge` - Status indicators with animations - -#### 3.3 State Management - -**Expansion State (Zustand):** -```typescript -type UIState = { - expandedTools: Set; - expandedMessages: Set; - showThinking: boolean; - diffViewMode: 'unified' | 'split'; - - toggleToolExpanded(id: string): void; - toggleMessageExpanded(id: string): void; -}; -``` - -**Persistence:** -- Store expansion state in localStorage via Zustand persist -- Restore on mount -- Clear on task change - ---- - -### Phase 4: CodeMirror Input with Commands (Week 2) - -#### 4.1 CodeMirror Setup - -**Chat Input Configuration:** -```typescript -import { EditorView, basicSetup } from '@codemirror/basic-setup'; -import { markdown } from '@codemirror/lang-markdown'; -import { autocompletion } from '@codemirror/autocomplete'; -import { keymap } from '@codemirror/view'; - -const chatExtensions = [ - basicSetup({ lineNumbers: false }), // Minimal for chat - markdown(), - autocompletion({ override: [commandCompletions, mentionCompletions] }), - keymap.of([{ key: "Mod-Enter", run: handleSubmit }]), - EditorView.theme({ - "&": { maxHeight: "300px", minHeight: "60px" }, - ".cm-content": { padding: "12px" }, - ".cm-scroller": { overflow: "auto" } - }) -]; -``` - -**Features:** -- Markdown mode with syntax awareness -- Slash commands (/read, /edit, /bash, etc.) -- File mentions (@filename.ts) -- Syntax highlighting for inline code -- Cmd+Enter to submit -- Auto-grow up to 300px height - -#### 4.2 Command Autocomplete - -**Command Completion Source:** -```typescript -import { CompletionContext } from '@codemirror/autocomplete'; - -const COMMANDS = [ - { label: '/read', detail: 'Read a file', info: 'Usage: /read ' }, - { label: '/edit', detail: 'Edit a file', info: 'Usage: /edit ' }, - { label: '/bash', detail: 'Run bash command', info: 'Usage: /bash ' }, - { label: '/grep', detail: 'Search in files', info: 'Usage: /grep ' }, - { label: '/help', detail: 'Show commands', info: 'Display all available commands' }, -]; - -function commandCompletions(context: CompletionContext) { - const word = context.matchBefore(/\/\w*/); - if (!word || (word.from === word.to && !context.explicit)) return null; - - return { - from: word.from, - options: COMMANDS.map(cmd => ({ - label: cmd.label, - detail: cmd.detail, - info: cmd.info, - apply: cmd.label + ' ', // Add space after command - })) - }; -} -``` - -**File Mention Completion:** -```typescript -function mentionCompletions(context: CompletionContext) { - const word = context.matchBefore(/@[\w\/\-\.]*/); - if (!word) return null; - - // Get available files from git status or file tree - const files = getChangedFiles(); // From store or context - - return { - from: word.from, - options: files.map(file => ({ - label: `@${file.path}`, - detail: file.type, - info: `Reference ${file.path}`, - apply: `@${file.path}`, - })) - }; -} -``` - -**Keyboard Navigation:** -- Built-in: Arrow up/down, Enter, Escape -- Tab to accept completion -- Fuzzy matching included - -#### 4.3 Submit Handler - -**Extract Content and Send:** -```typescript -function handleSubmit(view: EditorView): boolean { - const content = view.state.doc.toString(); - - if (!content.trim()) return false; - - // Parse commands (optional - backend can also parse) - const commands = extractCommands(content); - - // Send to backend - client.request('comment.add', { - task_id: taskId, - content, - metadata: commands.length > 0 ? { commands } : undefined - }); - - // Clear editor - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: '' } - }); - - return true; -} -``` - -#### 4.4 File Editing Integration - -**Unified Editor for Multiple Contexts:** - -**1. Chat Input (Minimal):** -```typescript -const chatExtensions = [ - basicSetup({ lineNumbers: false }), - markdown(), - autocompletion({ override: [commandCompletions, mentionCompletions] }), -]; -``` - -**2. File Viewer (Read-only):** -```typescript -const viewerExtensions = [ - basicSetup(), - EditorView.editable.of(false), - EditorState.readOnly.of(true), - languageForFile(filename), - syntaxHighlighting(oneDarkHighlightStyle), -]; -``` - -**3. File Editor (Full-featured):** -```typescript -const editorExtensions = [ - basicSetup(), - languageForFile(filename), - syntaxHighlighting(oneDarkHighlightStyle), - lintGutter(), - autocompletion(), - EditorView.updateListener.of((update) => { - if (update.docChanged) { - debouncedSave(update.state.doc.toString()); - } - }), -]; -``` - -**Language Detection:** -```typescript -function languageForFile(filename: string) { - const ext = filename.split('.').pop(); - switch (ext) { - case 'js': - case 'jsx': - case 'ts': - case 'tsx': - return javascript({ jsx: true, typescript: ext.includes('ts') }); - case 'py': - return python(); - case 'go': - return go(); - case 'md': - return markdown(); - // ... 100+ languages supported - default: - return []; - } -} -``` - -**Shared Configuration:** -```typescript -// Reusable theme and settings -export const baseTheme = EditorView.theme({ - "&": { height: "100%" }, - ".cm-scroller": { fontFamily: "var(--font-mono)" }, - ".cm-gutters": { backgroundColor: "var(--muted)" }, -}); - -export const baseExtensions = [ - baseTheme, - EditorState.tabSize.of(2), - EditorState.allowMultipleSelections.of(true), -]; -``` - ---- - -### Phase 5: Rich Content Rendering (Week 2-3) - -#### 5.1 Tool Call Enhancements - -**Features:** -- Recursive rendering of sub-agent calls (Task tool) -- Tool-specific summaries (Read: filename, Bash: command preview) -- Status badges with animations (pending, running, success, error) -- Collapsible params and results -- Copy button for results -- Specialized renderers (Bash output with ANSI colors, JSON pretty-print) - -**Metadata Structure:** -```typescript -type ToolCallMetadata = { - tool: { - id: string; - name: string; - args: Record; - result: string | null; - status: 'pending' | 'running' | 'success' | 'error'; - duration_ms?: number; - sub_agent_calls?: ToolCallMetadata[]; - }; -}; -``` - -#### 5.2 Todo List Visualization - -**Features:** -- Progress bar (completed / total) -- Status icons (Circle → Loader → CheckCircle) -- Active form vs content based on status -- Animated transitions -- Group by status (optional) - -**Backend Integration:** -- Agent sends `progress` type comments -- Metadata contains todos array -- Updates sent as new comments (immutable) -- Frontend shows latest state - -#### 5.3 Diff Integration - -**Features:** -- Extract diffs from tool call results (Edit, Write tools) -- Show file stats (additions, deletions) -- Expandable diff viewer using @git-diff-view/react -- Syntax highlighting per language -- "Open in Changes" button → navigate to Changes tab - -**Diff Sources:** -1. Tool result with `diff` field -2. Tool result with `old_content` and `new_content` -3. Unified diff string in result - -#### 5.4 Thinking Display - -**Features:** -- Toggle via settings -- Italic text with left border -- Markdown rendering -- Collapsed by default for long thoughts -- Visual distinction from regular messages - -**Backend:** -- Agent sends `thinking` type comments -- Or `metadata.thinking` in content comments - -#### 5.5 Error Handling - -**Features:** -- Error cards with red border -- Error icon and title -- Error message -- Collapsible stack trace -- Retry button (if applicable) -- Copy error details - ---- - -### Phase 6: Performance & Polish (Week 3-4) - -#### 6.1 Performance Optimizations - -**Memoization:** -```typescript -// Memoize message components -const MessageItem = memo(MessageRenderer, (prev, next) => { - return prev.comment.id === next.comment.id && - prev.comment.updated_at === next.comment.updated_at; -}); - -// Memoize filtered comments -const visibleComments = useMemo(() => { - return comments.filter(c => { - if (c.type === 'thinking' && !showThinking) return false; - return true; - }); -}, [comments, showThinking]); -``` - -**Debouncing:** -- Scroll position checks: 100ms -- Lazy load triggers: 200ms -- Auto-scroll: 50ms -- Input changes: 300ms (for autocomplete) - -**Code Splitting:** -```typescript -// Lazy load heavy components -const MonacoDiffEditor = lazy(() => import('./MonacoDiffEditor')); -const MermaidRenderer = lazy(() => import('./MermaidRenderer')); -``` - -**Bundle Size:** -- Target: ~150KB increase (CodeMirror core + essential languages) -- Use tree-shaking -- Import only needed language modes -- Lazy load additional languages on demand - -#### 6.2 UX Polish - -**Interactions:** -- Smooth height transitions (CSS transitions) -- Hover states on interactive elements -- Loading skeletons for lazy load -- Copy feedback (checkmark animation) -- Keyboard shortcuts (j/k navigation, / for command palette) - -**Visual:** -- Consistent spacing and alignment -- Color-coded message types -- Status indicators with meaningful colors -- Avatar fallbacks with initials -- Timestamp formatting (relative times) -- Duration formatting (ms, s, m) - -**Accessibility:** -- Keyboard navigation -- Focus management in autocomplete - - -#### 6.3 Error Boundaries - -**Wrap Critical Components:** -```typescript -} - onError={(error) => console.error('Message render error:', error)} -> - - -``` - -**Graceful Degradation:** -- If markdown fails, show plain text -- If diff parsing fails, show unified diff string -- If tool metadata is invalid, show raw JSON - ---- - -## Backend Changes - -### API Enhancements - -**New Endpoints:** -``` -GET /api/tasks/:id/comments?limit=10&before=:cursor&sort=desc -POST /api/tasks/:id/comments (existing, no changes) -``` - -**Response Format:** -```json -{ - "comments": [ - { - "id": "cmt_123", - "task_id": "tsk_456", - "author_type": "agent", - "content": "...", - "type": "content", - "metadata": {...}, - "created_at": "2025-01-14T12:00:00Z" - } - ], - "has_more": true, - "cursor": "cmt_100" -} -``` - -**Indexes:** -```sql -CREATE INDEX IF NOT EXISTS idx_comments_task_created - ON task_comments(task_id, created_at DESC); -``` - -### WebSocket (No Changes) - -Existing `comment.added` notification works as-is. - -### Comment Type Usage - -**Agent Integration:** -- `message` - User messages -- `content` - Agent text responses -- `tool_call` - Tool executions -- `progress` - Todo list updates -- `thinking` - Agent reasoning -- `error` - Error messages -- `status` - System notifications - ---- - -## File Structure - -``` -apps/web/ -├── app/task/[id]/ -│ ├── page.tsx (SSR: fetch initial comments) -│ └── page-client.tsx (Client: hydrate + subscribe) -├── components/task/ -│ ├── task-chat-panel.tsx (Main container, refactored) -│ └── chat/ -│ ├── TaskChatHeader.tsx -│ ├── TaskChatMessages.tsx (TanStack Virtual) -│ ├── TaskChatInput.tsx (Lexical + commands) -│ ├── messages/ -│ │ ├── MessageRenderer.tsx -│ │ ├── UserMessage.tsx -│ │ ├── AssistantMessage.tsx -│ │ ├── ThinkingMessage.tsx -│ │ ├── SystemMessage.tsx -│ │ └── ErrorCard.tsx -│ ├── tools/ -│ │ ├── ToolCallCard.tsx -│ │ ├── ToolStatusBadge.tsx -│ │ └── ToolIcon.tsx -│ ├── content/ -│ │ ├── TodoListCard.tsx -│ │ ├── TodoItem.tsx -│ │ ├── DiffCard.tsx -│ │ └── CopyButton.tsx -│ ├── input/ -│ │ ├── CodeMirrorEditor.tsx -│ │ ├── commandCompletions.ts -│ │ ├── mentionCompletions.ts -│ │ └── editorExtensions.ts -│ └── hooks/ -│ ├── useLazyLoadComments.ts -│ ├── useSmartAutoScroll.ts -│ └── useChatVirtualizer.ts -└── lib/ - ├── state/store.ts (Enhanced with pagination) - └── api/comments.ts (New: fetch comments API) - -apps/backend/ -└── internal/task/ - ├── handlers/ - │ └── comment_handlers.go (Add pagination support) - └── repository/ - └── sqlite.go (Cursor-based queries) -``` - -**Total new files:** ~25 -**Modified files:** 4 (page.tsx, page-client.tsx, task-chat-panel.tsx, store.ts, comment_handlers.go, sqlite.go) - ---- - -## Implementation Timeline - -### Week 1: Pagination & Smart Scroll -- [x] ~~SSR setup~~ (Already done) -- [x] ~~TanStack Virtual~~ (Already done v3.13.18) -- [x] ~~Basic tool calls~~ (Already done) -- [ ] Backend pagination API (limit, before, cursor) -- [ ] Update SSR to limit to 10 messages -- [ ] Lazy loading on scroll up -- [ ] Smart auto-scroll (only if at bottom) -- [ ] Scroll position preservation on prepend - -### Week 2: Rich Content -- [ ] Enhanced message components (copy, timestamps) -- [ ] Nested tool call rendering (sub-agents) -- [ ] Todo list visualization with progress bars -- [ ] Inline diff viewer integration -- [ ] Thinking message display -- [ ] Error card enhancements - -### Week 3: CodeMirror Input -- [ ] Replace Textarea with CodeMirror -- [ ] Markdown mode with syntax highlighting -- [ ] Slash command autocomplete (/read, /edit, /bash) -- [ ] File mention autocomplete (@file.ts) -- [ ] Keyboard navigation (arrows, enter, escape) -- [ ] Command parsing & execution -- [ ] File viewer/editor config (bonus) - -### Week 4: Polish & Testing -- [ ] Performance optimization (memoization audit) -- [ ] Error boundaries for message rendering -- [ ] Accessibility (keyboard nav) -- [ ] Documentation updates - ---- diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index 546014af93..5197e29f94 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -34,7 +34,7 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa 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, + repositories: initialTask.repositories, }; }, [initialTask, kanbanTask]); const connectionStatus = useAppStore((state) => state.connection.status); @@ -64,8 +64,8 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa const { repositories } = useRepositories(task?.workspace_id ?? null, Boolean(task?.workspace_id)); const repository = useMemo( - () => repositories.find((item) => item.id === task?.repository_id) ?? null, - [repositories, task?.repository_id] + () => repositories.find((item) => item.id === task?.repositories?.[0]?.repository_id) ?? null, + [repositories, task?.repositories] ); const handleSendMessage = useCallback(async (content: string) => { @@ -115,7 +115,7 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa activeSessionId={activeSessionId ?? null} taskTitle={task?.title} taskDescription={task?.description} - baseBranch={task?.base_branch ?? undefined} + baseBranch={task?.repositories?.[0]?.base_branch ?? undefined} onStartAgent={handleStartAgent} onStopAgent={handleStopAgent} isAgentRunning={isAgentRunning} diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index f461d8e1a2..417f3e9dfb 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -178,7 +178,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} description: task.description ?? undefined, position: task.position ?? 0, state: task.state, - repositoryId: task.repository_id ?? undefined, + repositoryId: task.repositories?.[0]?.repository_id ?? undefined, }, ], }, @@ -197,7 +197,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} columnId: task.column_id ?? item.columnId, position: task.position ?? item.position, state: task.state ?? item.state, - repositoryId: task.repository_id ?? item.repositoryId, + repositoryId: task.repositories?.[0]?.repository_id ?? item.repositoryId, } : item ), diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 782df0e4c4..aebf979805 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -273,8 +273,10 @@ export function TaskCreateDialog({ column_id: targetColumnId, title: trimmedTitle, description: description.trim(), - repository_id: repositoryId, - base_branch: branch || undefined, + repositories: repositoryId ? [{ + repository_id: repositoryId, + base_branch: branch || undefined, + }] : [], state: targetState, }); console.log('[TaskCreateDialog] task created', { diff --git a/apps/web/lib/http/index.ts b/apps/web/lib/http/index.ts index 3d18d67f8b..2ee4849f27 100644 --- a/apps/web/lib/http/index.ts +++ b/apps/web/lib/http/index.ts @@ -93,8 +93,10 @@ export async function createTask( title: string; description?: string; position?: number; - repository_id?: string; - base_branch?: string; + repositories?: Array<{ + repository_id: string; + base_branch?: string; + }>; state?: Task['state']; }, options?: ApiRequestOptions @@ -113,8 +115,10 @@ export async function updateTask( position?: number; assigned_to?: string; state?: Task['state']; - repository_id?: string; - base_branch?: string; + repositories?: Array<{ + repository_id: string; + base_branch?: string; + }>; }, options?: ApiRequestOptions ) { diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index b624688c95..3761836e40 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -15,7 +15,7 @@ export function snapshotToState(snapshot: BoardSnapshot): Partial { description: task.description ?? undefined, position: task.position ?? 0, state: task.state, - repositoryId: task.repository_id ?? undefined, + repositoryId: task.repositories?.[0]?.repository_id ?? undefined, } as KanbanTask; }) .filter((task): task is KanbanTask => task !== null); diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index e6cc9b4690..4bfb9fb23c 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -78,6 +78,17 @@ export type RepositoryScript = { updated_at: string; }; +export type TaskRepository = { + id: string; + task_id: string; + repository_id: string; + base_branch: string; + position: number; + metadata?: Record; + created_at: string; + updated_at: string; +}; + export type Task = { id: string; workspace_id: string; @@ -88,8 +99,7 @@ export type Task = { description: string; state: TaskState; priority: number; - repository_id?: string | null; - base_branch?: string | null; + repositories?: TaskRepository[]; assigned_agent_id?: string | null; created_at: string; updated_at: string;