// Package sqlite provides SQLite-based repository implementations. package sqlite import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" "github.com/google/uuid" agentdto "github.com/kandev/kandev/internal/agent/dto" "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" ) // Turn operations // CreateTurn creates a new turn func (r *Repository) CreateTurn(ctx context.Context, turn *models.Turn) error { if turn.ID == "" { turn.ID = uuid.New().String() } now := time.Now().UTC() if turn.StartedAt.IsZero() { turn.StartedAt = now } if turn.CreatedAt.IsZero() { turn.CreatedAt = now } turn.UpdatedAt = now metadataJSON := "{}" if turn.Metadata != nil { metadataBytes, err := json.Marshal(turn.Metadata) if err != nil { return fmt.Errorf("failed to serialize turn metadata: %w", err) } metadataJSON = string(metadataBytes) } _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `), turn.ID, turn.TaskSessionID, turn.TaskID, turn.StartedAt, turn.CompletedAt, metadataJSON, turn.CreatedAt, turn.UpdatedAt) return err } func scanTurnRow(row *sql.Row) (*models.Turn, error) { turn := &models.Turn{} var metadataJSON string var completedAt sql.NullTime err := row.Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) if err != nil { return nil, err } if completedAt.Valid { turn.CompletedAt = &completedAt.Time } if metadataJSON != "" && metadataJSON != "{}" { if err := json.Unmarshal([]byte(metadataJSON), &turn.Metadata); err != nil { return nil, fmt.Errorf("failed to deserialize turn metadata: %w", err) } } return turn, nil } // GetTurn retrieves a turn by ID func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns WHERE id = ? `), id) return scanTurnRow(row) } // GetActiveTurnBySessionID gets the currently active (non-completed) turn for a session func (r *Repository) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns WHERE task_session_id = ? AND completed_at IS NULL ORDER BY started_at DESC LIMIT 1 `), sessionID) return scanTurnRow(row) } // UpdateTurn updates an existing turn func (r *Repository) UpdateTurn(ctx context.Context, turn *models.Turn) error { turn.UpdatedAt = time.Now().UTC() metadataJSON := "{}" if turn.Metadata != nil { metadataBytes, err := json.Marshal(turn.Metadata) if err != nil { return fmt.Errorf("failed to serialize turn metadata: %w", err) } metadataJSON = string(metadataBytes) } _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_session_turns SET completed_at = ?, metadata = ?, updated_at = ? WHERE id = ? `), turn.CompletedAt, metadataJSON, turn.UpdatedAt, turn.ID) return err } // CompleteTurn marks a turn as completed with the current time func (r *Repository) CompleteTurn(ctx context.Context, id string) error { now := time.Now().UTC() _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_session_turns SET completed_at = ?, updated_at = ? WHERE id = ? `), now, now, id) return err } // ListTurnsBySession returns all turns for a session ordered by start time func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) { ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTurnsBySession") defer span.End() rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns WHERE task_session_id = ? ORDER BY started_at ASC `), sessionID) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var result []*models.Turn for rows.Next() { turn := &models.Turn{} var metadataJSON string var completedAt sql.NullTime err := rows.Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) if err != nil { return nil, err } if completedAt.Valid { turn.CompletedAt = &completedAt.Time } if metadataJSON != "" && metadataJSON != "{}" { if err := json.Unmarshal([]byte(metadataJSON), &turn.Metadata); err != nil { return nil, fmt.Errorf("failed to deserialize turn metadata: %w", err) } } result = append(result, turn) } if err := rows.Err(); err != nil { return nil, err } return result, nil } // Task Session operations // CreateTaskSession creates a new agent session func (r *Repository) CreateTaskSession(ctx context.Context, session *models.TaskSession) error { if session.ID == "" { session.ID = uuid.New().String() } now := time.Now().UTC() session.StartedAt = now session.UpdatedAt = now if session.State == "" { session.State = models.TaskSessionStateCreated } metadataJSON, err := json.Marshal(session.Metadata) if err != nil { return fmt.Errorf("failed to serialize agent session metadata: %w", err) } agentProfileSnapshotJSON, err := json.Marshal(session.AgentProfileSnapshot) if err != nil { return fmt.Errorf("failed to serialize agent profile snapshot: %w", err) } executorSnapshotJSON, err := json.Marshal(session.ExecutorSnapshot) if err != nil { return fmt.Errorf("failed to serialize executor snapshot: %w", err) } environmentSnapshotJSON, err := json.Marshal(session.EnvironmentSnapshot) if err != nil { return fmt.Errorf("failed to serialize environment snapshot: %w", err) } repositorySnapshotJSON, err := json.Marshal(session.RepositorySnapshot) if err != nil { return fmt.Errorf("failed to serialize repository snapshot: %w", err) } _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_sessions ( id, task_id, agent_profile_id, executor_id, executor_profile_id, environment_id, repository_id, base_branch, base_commit_sha, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, review_status, is_passthrough, task_environment_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), session.ID, session.TaskID, session.AgentProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt, dialect.BoolToInt(session.IsPrimary), session.ReviewStatus, dialect.BoolToInt(session.IsPassthrough), session.TaskEnvironmentID) return err } // unmarshalSessionJSON deserializes a JSON string into dest, skipping empty/placeholder values. func unmarshalSessionJSON(jsonStr string, dest interface{}, fieldDesc string) error { if jsonStr == "" || jsonStr == "{}" { return nil } if err := json.Unmarshal([]byte(jsonStr), dest); err != nil { return fmt.Errorf("failed to deserialize %s: %w", fieldDesc, err) } return nil } func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsErr string) (*models.TaskSession, error) { session := &models.TaskSession{} var state string var metadataJSON string var agentProfileSnapshotJSON string var executorSnapshotJSON string var environmentSnapshotJSON string var repositorySnapshotJSON string var completedAt sql.NullTime var isPrimary int var isPassthrough int var reviewStatus sql.NullString err := row.Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, &isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("%s", noRowsErr) } if err != nil { return nil, err } session.State = models.TaskSessionState(state) session.IsPrimary = isPrimary == 1 session.IsPassthrough = isPassthrough == 1 if reviewStatus.Valid { session.ReviewStatus = &reviewStatus.String } if completedAt.Valid { session.CompletedAt = &completedAt.Time } if err := unmarshalSessionJSON(metadataJSON, &session.Metadata, "agent session metadata"); err != nil { return nil, err } if err := unmarshalSessionJSON(agentProfileSnapshotJSON, &session.AgentProfileSnapshot, "agent profile snapshot"); err != nil { return nil, err } if err := unmarshalSessionJSON(executorSnapshotJSON, &session.ExecutorSnapshot, "executor snapshot"); err != nil { return nil, err } if err := unmarshalSessionJSON(environmentSnapshotJSON, &session.EnvironmentSnapshot, "environment snapshot"); err != nil { return nil, err } if err := unmarshalSessionJSON(repositorySnapshotJSON, &session.RepositorySnapshot, "repository snapshot"); err != nil { return nil, err } 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 } // GetTaskSession retrieves an agent session by ID func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id FROM task_sessions ts LEFT JOIN executors_running er ON er.session_id = ts.id WHERE ts.id = ? `), id) return r.scanTaskSession(ctx, row, fmt.Sprintf("agent session not found: %s", id)) } // GetTaskSessionByTaskID retrieves the most recent agent session for a task func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id FROM task_sessions ts LEFT JOIN executors_running er ON er.session_id = ts.id WHERE ts.task_id = ? ORDER BY ts.started_at DESC LIMIT 1 `), taskID) return r.scanTaskSession(ctx, row, fmt.Sprintf("agent session not found for task: %s", taskID)) } // GetActiveTaskSessionByTaskID retrieves the active (running/waiting) agent session for a task func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id FROM task_sessions ts LEFT JOIN executors_running er ON er.session_id = ts.id WHERE ts.task_id = ? AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY ts.started_at DESC LIMIT 1 `), taskID) return r.scanTaskSession(ctx, row, fmt.Sprintf("no active agent session for task: %s", taskID)) } // UpdateTaskSession updates an existing agent session. // Note: metadata is NOT updated here to prevent clobbering concurrent writes // from UpdateSessionMetadata callers (e.g. setSessionPlanMode, persistPrepareResult). // Use UpdateSessionMetadata for metadata changes. func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.TaskSession) error { session.UpdatedAt = time.Now().UTC() agentProfileSnapshotJSON, err := json.Marshal(session.AgentProfileSnapshot) if err != nil { return fmt.Errorf("failed to serialize agent profile snapshot: %w", err) } executorSnapshotJSON, err := json.Marshal(session.ExecutorSnapshot) if err != nil { return fmt.Errorf("failed to serialize executor snapshot: %w", err) } environmentSnapshotJSON, err := json.Marshal(session.EnvironmentSnapshot) if err != nil { return fmt.Errorf("failed to serialize environment snapshot: %w", err) } repositorySnapshotJSON, err := json.Marshal(session.RepositorySnapshot) if err != nil { return fmt.Errorf("failed to serialize repository snapshot: %w", err) } result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET agent_profile_id = ?, executor_id = ?, executor_profile_id = ?, environment_id = ?, repository_id = ?, base_branch = ?, base_commit_sha = ?, agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?, state = ?, error_message = ?, completed_at = ?, updated_at = ?, is_primary = ?, review_status = ?, is_passthrough = ?, task_environment_id = ? WHERE id = ? `), session.AgentProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, session.CompletedAt, session.UpdatedAt, dialect.BoolToInt(session.IsPrimary), session.ReviewStatus, dialect.BoolToInt(session.IsPassthrough), session.TaskEnvironmentID, session.ID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("agent session not found: %s", session.ID) } return nil } // UpdateTaskSessionState updates just the state and error message of an agent session func (r *Repository) UpdateTaskSessionState(ctx context.Context, id string, status models.TaskSessionState, errorMessage string) error { now := time.Now().UTC() var completedAt *time.Time if status == models.TaskSessionStateCompleted || status == models.TaskSessionStateFailed || status == models.TaskSessionStateCancelled { completedAt = &now } result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET state = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE id = ? `), string(status), errorMessage, completedAt, now, id) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("agent session not found: %s", id) } return nil } // UpdateSessionMetadata updates only the metadata column of a session, // avoiding a full-row overwrite that could clobber concurrent field updates. func (r *Repository) UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error { metadataJSON, err := json.Marshal(metadata) if err != nil { return fmt.Errorf("failed to serialize metadata: %w", err) } now := time.Now().UTC() result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET metadata = ?, updated_at = ? WHERE id = ? `), string(metadataJSON), now, sessionID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("agent session not found: %s", sessionID) } return nil } // SetSessionMetadataKey atomically sets a single key in the session's metadata // using SQLite's json_set. Unlike UpdateSessionMetadata (which does a full // replacement), this preserves all other metadata keys. func (r *Repository) SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error { valueJSON, err := json.Marshal(value) if err != nil { return fmt.Errorf("failed to serialize metadata value: %w", err) } now := time.Now().UTC() path := "$." + key result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?, json(?)), updated_at = ? WHERE id = ? `), path, string(valueJSON), now, sessionID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("agent session not found: %s", sessionID) } return nil } // UpdateTaskSessionBaseCommit updates the base_commit_sha for a session. // This is called after agent launch to capture the HEAD commit at session start. func (r *Repository) UpdateTaskSessionBaseCommit(ctx context.Context, id string, baseCommitSHA string) error { now := time.Now().UTC() result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET base_commit_sha = ?, updated_at = ? WHERE id = ? `), baseCommitSHA, now, id) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("agent session not found: %s", id) } return nil } // ListTaskSessions returns all agent sessions for a task func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTaskSessions") defer span.End() rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id FROM task_sessions ts LEFT JOIN executors_running er ON er.session_id = ts.id WHERE ts.task_id = ? ORDER BY ts.started_at DESC `), taskID) if err != nil { return nil, err } defer func() { _ = rows.Close() }() sessions, err := r.scanTaskSessions(ctx, rows) if err != nil { return nil, err } return r.loadWorktreesBatch(ctx, sessions) } // ListActiveTaskSessions returns all active agent sessions across all tasks func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) { ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListActiveTaskSessions") defer span.End() rows, err := r.ro.QueryContext(ctx, ` SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id FROM task_sessions ts LEFT JOIN executors_running er ON er.session_id = ts.id WHERE ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY ts.started_at DESC `) if err != nil { return nil, err } defer func() { _ = rows.Close() }() sessions, err := r.scanTaskSessions(ctx, rows) if err != nil { return nil, err } return r.loadWorktreesBatch(ctx, sessions) } // ListActiveTaskSessionsByTaskID returns all active agent sessions for a specific task func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id FROM task_sessions ts LEFT JOIN executors_running er ON er.session_id = ts.id WHERE ts.task_id = ? AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY ts.started_at DESC `), taskID) if err != nil { return nil, err } defer func() { _ = rows.Close() }() sessions, err := r.scanTaskSessions(ctx, rows) if err != nil { return nil, err } return r.loadWorktreesBatch(ctx, sessions) } // loadWorktreesBatch loads worktrees for multiple sessions in a single query. func (r *Repository) loadWorktreesBatch(ctx context.Context, sessions []*models.TaskSession) ([]*models.TaskSession, error) { if len(sessions) == 0 { return sessions, nil } sessionIDs := make([]string, len(sessions)) for i, s := range sessions { sessionIDs[i] = s.ID } worktreeMap, err := r.ListWorktreesBySessionIDs(ctx, sessionIDs) if err != nil { return nil, fmt.Errorf("failed to batch-load session worktrees: %w", err) } for _, session := range sessions { session.Worktrees = worktreeMap[session.ID] } return sessions, nil } func (r *Repository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { var exists int // Exclude ephemeral tasks (quick chat, config chat) - they shouldn't block profile deletion err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT 1 FROM task_sessions ts JOIN tasks t ON ts.task_id = t.id WHERE ts.agent_profile_id = ? AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') AND t.is_ephemeral = 0 LIMIT 1 `), agentProfileID).Scan(&exists) if err == sql.ErrNoRows { return false, nil } return err == nil, err } func (r *Repository) GetActiveTaskInfoByAgentProfile(ctx context.Context, agentProfileID string) ([]agentdto.ActiveTaskInfo, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT DISTINCT t.id, t.title, t.is_ephemeral FROM task_sessions ts JOIN tasks t ON t.id = ts.task_id WHERE ts.agent_profile_id = ? AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') `), agentProfileID) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var result []agentdto.ActiveTaskInfo for rows.Next() { var info agentdto.ActiveTaskInfo if err := rows.Scan(&info.TaskID, &info.TaskTitle, &info.IsEphemeral); err != nil { return nil, err } result = append(result, info) } return result, rows.Err() } func (r *Repository) HasActiveTaskSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) { var exists int err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT 1 FROM task_sessions WHERE environment_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') LIMIT 1 `), environmentID).Scan(&exists) if err == sql.ErrNoRows { return false, nil } return err == nil, err } func (r *Repository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) { var exists int err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT 1 FROM task_sessions s INNER JOIN task_repositories tr ON tr.task_id = s.task_id WHERE s.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') AND tr.repository_id = ? LIMIT 1 `), repositoryID).Scan(&exists) if err == sql.ErrNoRows { return false, nil } return err == nil, err } // DeleteEphemeralTasksByAgentProfile deletes all ephemeral tasks (and their sessions) // that are using the specified agent profile. This is used during profile deletion // to clean up transient quick chat / config chat tasks. func (r *Repository) DeleteEphemeralTasksByAgentProfile(ctx context.Context, agentProfileID string) (int64, error) { // Delete tasks that are ephemeral and have sessions using this profile. // CASCADE will handle session deletion. result, err := r.db.ExecContext(ctx, r.db.Rebind(` DELETE FROM tasks WHERE is_ephemeral = 1 AND id IN ( SELECT DISTINCT task_id FROM task_sessions WHERE agent_profile_id = ? ) `), agentProfileID) if err != nil { return 0, err } return result.RowsAffected() } // scanTaskSessions is a helper to scan multiple agent session rows func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*models.TaskSession, error) { var result []*models.TaskSession for rows.Next() { session, err := scanTaskSessionRow(rows) if err != nil { return nil, err } result = append(result, session) } return result, rows.Err() } // scanTaskSessionRow scans a single row into a TaskSession, applying all field mappings and JSON unmarshalling. func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) { session := &models.TaskSession{} var state string var metadataJSON string var agentProfileSnapshotJSON string var executorSnapshotJSON string var environmentSnapshotJSON string var repositorySnapshotJSON string var completedAt sql.NullTime var isPrimary int var isPassthrough int var reviewStatus sql.NullString err := rows.Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, &isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID, ) if err != nil { return nil, err } session.State = models.TaskSessionState(state) session.IsPrimary = isPrimary == 1 session.IsPassthrough = isPassthrough == 1 if reviewStatus.Valid { session.ReviewStatus = &reviewStatus.String } if completedAt.Valid { session.CompletedAt = &completedAt.Time } if err := unmarshalSessionSnapshots(session, metadataJSON, agentProfileSnapshotJSON, executorSnapshotJSON, environmentSnapshotJSON, repositorySnapshotJSON); err != nil { return nil, err } return session, nil } // unmarshalSessionSnapshots deserializes all JSON snapshot fields into the session struct. func unmarshalSessionSnapshots( session *models.TaskSession, metadataJSON, agentProfileSnapshotJSON, executorSnapshotJSON, environmentSnapshotJSON, repositorySnapshotJSON string, ) error { if err := unmarshalSessionJSON(metadataJSON, &session.Metadata, "agent session metadata"); err != nil { return err } if err := unmarshalSessionJSON(agentProfileSnapshotJSON, &session.AgentProfileSnapshot, "agent profile snapshot"); err != nil { return err } if err := unmarshalSessionJSON(executorSnapshotJSON, &session.ExecutorSnapshot, "executor snapshot"); err != nil { return err } if err := unmarshalSessionJSON(environmentSnapshotJSON, &session.EnvironmentSnapshot, "environment snapshot"); err != nil { return err } return unmarshalSessionJSON(repositorySnapshotJSON, &session.RepositorySnapshot, "repository snapshot") } // DeleteTaskSession deletes an agent session by ID func (r *Repository) DeleteTaskSession(ctx context.Context, id string) error { result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_sessions WHERE id = ?`), id) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("agent session not found: %s", id) } return nil } // Task Session Worktree operations func (r *Repository) 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, r.db.Rebind(` 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 } // UpdateTaskSessionWorktreeBranch updates the cached worktree_branch for all // worktrees belonging to a session. Called when a branch switch or rename is // detected in the live workspace so downstream consumers (PR watch // reconciliation, branch listings) see the current branch rather than the // value captured at worktree creation. func (r *Repository) UpdateTaskSessionWorktreeBranch(ctx context.Context, sessionID, branch string) error { now := time.Now().UTC() _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_session_worktrees SET worktree_branch = ?, updated_at = ? WHERE session_id = ? `), branch, now, sessionID) return err } func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` 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() } // ListWorktreesBySessionIDs returns all worktrees for the given session IDs, // grouped by session ID. This eliminates N+1 queries when loading worktrees for multiple sessions. func (r *Repository) ListWorktreesBySessionIDs(ctx context.Context, sessionIDs []string) (map[string][]*models.TaskSessionWorktree, error) { result := make(map[string][]*models.TaskSessionWorktree, len(sessionIDs)) if len(sessionIDs) == 0 { return result, nil } placeholders := make([]string, len(sessionIDs)) args := make([]interface{}, len(sessionIDs)) for i, id := range sessionIDs { placeholders[i] = "?" args[i] = id } query := fmt.Sprintf(` 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 IN (%s) ORDER BY tsw.position ASC, tsw.created_at ASC `, strings.Join(placeholders, ",")) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) if err != nil { return nil, err } defer func() { _ = rows.Close() }() 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 } result[wt.SessionID] = append(result[wt.SessionID], &wt) } return result, rows.Err() } func (r *Repository) DeleteTaskSessionWorktree(ctx context.Context, id string) error { result, err := r.db.ExecContext(ctx, r.db.Rebind(`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 *Repository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error { _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_worktrees WHERE session_id = ?`), sessionID) return err } // GetPrimarySessionByTaskID retrieves the primary session for a task func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id FROM task_sessions ts LEFT JOIN executors_running er ON er.session_id = ts.id WHERE ts.task_id = ? AND ts.is_primary = 1 LIMIT 1 `), taskID) return r.scanTaskSession(ctx, row, fmt.Sprintf("no primary session found for task: %s", taskID)) } // GetPrimarySessionIDsByTaskIDs returns a map of task ID to primary session ID for the given task IDs. // Tasks without a primary session are not included in the result. func (r *Repository) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error) { if len(taskIDs) == 0 { return make(map[string]string), nil } // Build placeholders for IN clause placeholders := make([]string, len(taskIDs)) args := make([]interface{}, len(taskIDs)) for i, id := range taskIDs { placeholders[i] = "?" args[i] = id } query := fmt.Sprintf(` SELECT id, task_id FROM task_sessions WHERE task_id IN (%s) AND is_primary = 1 `, strings.Join(placeholders, ",")) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) if err != nil { return nil, err } defer func() { _ = rows.Close() }() result := make(map[string]string) for rows.Next() { var sessionID, taskID string if err := rows.Scan(&sessionID, &taskID); err != nil { return nil, err } result[taskID] = sessionID } return result, rows.Err() } // GetSessionCountsByTaskIDs returns a map of task ID to session count for the given task IDs. func (r *Repository) GetSessionCountsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]int, error) { if len(taskIDs) == 0 { return make(map[string]int), nil } // Build placeholders for IN clause placeholders := make([]string, len(taskIDs)) args := make([]interface{}, len(taskIDs)) for i, id := range taskIDs { placeholders[i] = "?" args[i] = id } query := fmt.Sprintf(` SELECT task_id, COUNT(*) as count FROM task_sessions WHERE task_id IN (%s) GROUP BY task_id `, strings.Join(placeholders, ",")) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) if err != nil { return nil, err } defer func() { _ = rows.Close() }() result := make(map[string]int) for rows.Next() { var taskID string var count int if err := rows.Scan(&taskID, &count); err != nil { return nil, err } result[taskID] = count } return result, rows.Err() } // GetPrimarySessionInfoByTaskIDs returns a map of task ID to primary session for the given task IDs. // Returns review_status, executor info, agent profile snapshot, and repository snapshot. func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs []string) (map[string]*models.TaskSession, error) { if len(taskIDs) == 0 { return make(map[string]*models.TaskSession), nil } // Build placeholders for IN clause placeholders := make([]string, len(taskIDs)) args := make([]interface{}, len(taskIDs)) for i, id := range taskIDs { placeholders[i] = "?" args[i] = id } query := fmt.Sprintf(` SELECT ts.id, ts.task_id, ts.review_status, ts.executor_id, ts.state, ts.agent_profile_snapshot, ts.repository_snapshot, e.type, e.name FROM task_sessions ts LEFT JOIN executors e ON e.id = ts.executor_id WHERE ts.task_id IN (%s) AND ts.is_primary = 1 `, strings.Join(placeholders, ",")) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) if err != nil { return nil, err } defer func() { _ = rows.Close() }() result := make(map[string]*models.TaskSession) for rows.Next() { var sessionID string var taskID string var reviewStatus sql.NullString var executorID sql.NullString var sessionState sql.NullString var agentProfileSnapshotJSON sql.NullString var repositorySnapshotJSON sql.NullString var executorType sql.NullString var executorName sql.NullString if err := rows.Scan(&sessionID, &taskID, &reviewStatus, &executorID, &sessionState, &agentProfileSnapshotJSON, &repositorySnapshotJSON, &executorType, &executorName); err != nil { return nil, err } session := &models.TaskSession{ ID: sessionID, TaskID: taskID, } if sessionState.Valid { session.State = models.TaskSessionState(sessionState.String) } if reviewStatus.Valid { session.ReviewStatus = &reviewStatus.String } if executorID.Valid { session.ExecutorID = executorID.String } if executorType.Valid || executorName.Valid { session.ExecutorSnapshot = make(map[string]interface{}, 2) if executorType.Valid { session.ExecutorSnapshot["executor_type"] = executorType.String } if executorName.Valid { session.ExecutorSnapshot["executor_name"] = executorName.String } } if agentProfileSnapshotJSON.Valid && agentProfileSnapshotJSON.String != "" { if err := json.Unmarshal([]byte(agentProfileSnapshotJSON.String), &session.AgentProfileSnapshot); err != nil { return nil, fmt.Errorf("failed to unmarshal agent profile snapshot for task %s: %w", taskID, err) } } if repositorySnapshotJSON.Valid && repositorySnapshotJSON.String != "" { if err := json.Unmarshal([]byte(repositorySnapshotJSON.String), &session.RepositorySnapshot); err != nil { return nil, fmt.Errorf("failed to unmarshal repository snapshot for task %s: %w", taskID, err) } } result[taskID] = session } return result, rows.Err() } // SetSessionPrimary marks a session as primary and clears primary flag on other sessions for the same task func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) error { now := time.Now().UTC() // First, get the task_id for this session var taskID string err := r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT task_id FROM task_sessions WHERE id = ?`), sessionID).Scan(&taskID) if err == sql.ErrNoRows { return fmt.Errorf("session not found: %s", sessionID) } if err != nil { return err } // Clear primary flag on all sessions for this task _, err = r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET is_primary = 0, updated_at = ? WHERE task_id = ? `), now, taskID) if err != nil { return err } // Set primary flag on the specified session result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET is_primary = 1, updated_at = ? WHERE id = ? `), now, sessionID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("session not found: %s", sessionID) } return nil }