feat: task_session_worktrees
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user