feat: task_session_worktrees

This commit is contained in:
Carlos Florêncio
2026-01-16 01:57:21 +00:00
parent f86044c083
commit 41bad65020
29 changed files with 1475 additions and 1362 deletions
+6 -1
View File
@@ -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
@@ -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,
@@ -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
+208 -49
View File
@@ -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)
@@ -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"
@@ -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{})
@@ -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)
@@ -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()
@@ -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)
}
+27 -11
View File
@@ -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
+46 -28
View File
@@ -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
}
+15 -12
View File
@@ -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 {
@@ -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))
+52 -22
View File
@@ -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,
@@ -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)
@@ -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)
+265 -41
View File
@@ -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
}
+337 -39
View File
@@ -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 {
@@ -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 {
+105 -35
View File
@@ -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
}
@@ -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)
+34 -19
View File
@@ -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"`
}
-931
View File
@@ -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<ListCommentsResponse>(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<string>;
expandedMessages: Set<string>;
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 <file_path>' },
{ label: '/edit', detail: 'Edit a file', info: 'Usage: /edit <file_path>' },
{ label: '/bash', detail: 'Run bash command', info: 'Usage: /bash <command>' },
{ label: '/grep', detail: 'Search in files', info: 'Usage: /grep <pattern>' },
{ 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<string, any>;
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
<ErrorBoundary
fallback={<ErrorFallback />}
onError={(error) => console.error('Message render error:', error)}
>
<MessageRenderer comment={comment} />
</ErrorBoundary>
```
**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
---
+4 -4
View File
@@ -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}
+2 -2
View File
@@ -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
),
+4 -2
View File
@@ -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', {
+8 -4
View File
@@ -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
) {
+1 -1
View File
@@ -15,7 +15,7 @@ export function snapshotToState(snapshot: BoardSnapshot): Partial<AppState> {
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);
+12 -2
View File
@@ -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<string, unknown>;
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;