1100 lines
40 KiB
Go
1100 lines
40 KiB
Go
// 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
|
|
}
|