74a8d7563e
* feat(web): split file search from command panel and add inline task search
Separate the command panel (Cmd+K) from file search (Cmd+Shift+K) to
prevent file results from overpowering commands. Add inline task search
directly in the command panel — tasks appear in a "Tasks" group when
typing ≥2 characters, with workflow step badges and archived state
indicators. Archived tasks are sorted last with reduced opacity.
- Add "search-files" mode to CommandPanelMode
- Add FILE_SEARCH shortcut (Cmd/Ctrl+Shift+K)
- Move file search to dedicated mode, remove from commands mode
- Add useInlineTaskSearchEffect for commands mode task search
- Remove "Search Tasks" sub-mode entry point (now inline)
- Show file search shortcut hint in command panel footer
* feat(web): add configurable keyboard shortcuts in general settings
Add localStorage-based shortcut override system for the command panel
and file search keybindings. Users can record new key combos via the
settings UI and reset to defaults.
- Add shortcut-overrides module (get/set/reset via localStorage)
- Add KeyboardShortcutsCard with shortcut recorder UI
- Add "Keyboard Shortcuts" section to general settings page
* test(web): add e2e tests for command panel and file search split
Cover the key use cases: Cmd+K opens commands without files,
Cmd+Shift+K opens file search, inline task search with workflow step
badges, escape/backspace navigation, and panel toggle behavior.
* fix(test): remove unused seedData variable in command panel e2e test
* feat(settings): persist keyboard shortcuts to user settings API
Replace localStorage-based shortcut overrides with server-persisted
storage via the user settings API. Adds keyboard_shortcuts field to
the backend model, DTO, store, service, and controller. Frontend
reads overrides from Zustand store (hydrated from SSR/WS) and saves
via PATCH /api/v1/user/settings.
* fix(ui): prioritize tasks over commands, add step colors, fix shortcut display
- Move task results above command results in cmd+K for faster task switching
- Replace magnifier icon with task icon (IconSubtask) for task results
- Apply workflow step color to task badges
- Fix formatShortcut early return bug that dropped Shift modifier
when ctrlOrCmd was set (e.g. Cmd+Shift+K showed as Cmd+K)
- Fix same bug in shortcutToCodeMirrorKeybinding
* fix(ui): use clipboard icon for task results in command panel
* style: format command panel footer
* fix(ui): use hammer icon and map step colors to hex for task badges
* feat(ui): show repo directory and agent name on task results
Display secondary details (repository directory, agent name, last
updated) on the right side of task results in the command panel.
* fix: avoid inline selector closure to prevent getServerSnapshot loop
Select the raw repositories map from the store instead of using an
inline selector that closes over workspaceId and returns new array
references, which triggers React's getServerSnapshot infinite loop.
* feat(task): expose agent name and working directory in task DTO
Add primary_agent_name (from AgentProfileSnapshot) and
primary_working_directory (from RepositorySnapshot) to TaskDTO.
Extend the primary session info query to fetch agent_profile_snapshot
and repository_snapshot columns, and extract the new fields in the
handler layer.
* feat(ui): show agent name and working directory on command panel tasks
Replace executor name with agent name from the primary session and
display the working directory (with repository path fallback) in the
task result details.
* fix(ui): add Commands heading to separate tasks from commands in search
* fix(task): use profile display name instead of agent slug
* feat(workflow): add show_in_command_panel per-step configuration
Add a configurable boolean to each workflow step that controls whether
tasks in that step appear in the command panel (Cmd+K). Default
templates mark backlog/done steps as hidden, active steps as visible.
- Add show_in_command_panel to WorkflowStep, StepDefinition, StepPortable
- Add column with idempotent migration (DEFAULT 1 for backwards compat)
- Include field in DTO serialization and export/import round-trip
- Update all default templates (Plan & Build, Architecture, Kanban, PR Review)
- Add tests for export preservation and import round-trip
* feat(ui): wire show_in_command_panel through frontend and settings
- Add field to HTTP types (WorkflowStep, WorkflowStepDTO, StepDefinition)
- Add to kanban state types and KanbanUpdatePayload
- Map in SSR snapshot mapper and WS kanban handler
- Add "Show in command panel" checkbox to workflow step settings
- Extract StepCheckboxRow component to stay within lint limits
* feat(ui): filter command panel tasks by show_in_command_panel flag
Replace position-based heuristic (resolveExcludedStepIds) with the
per-step show_in_command_panel configuration. Tasks in steps where
the flag is false are hidden from the default Cmd+K view but still
accessible via search.
- Replace resolveExcludedStepIds with resolveVisibleStepIds
- Consolidate useInlineTaskSearchEffect params into options object
* fix(ui): differentiate task sections in command panel
Rename the inline task results heading to "Active Tasks" when showing
the default view, distinguishing it from the "Tasks" command group
that contains "Create New Task".
* fix(ui): keep auto-archive checkbox inline with other step options
* fix(ui): include show_in_command_panel in step update action
The server action was missing show_in_command_panel from its Pick type
and body builder, so the checkbox change was silently dropped.
* fix(ui): pass is_start_step and show_in_command_panel from template steps
Both normalizeWorkflowTemplate mappers (workflow-api.ts and
workspaces.ts) were only picking name/position/color/prompt/events
from backend template steps, dropping is_start_step and
show_in_command_panel. This caused all template steps to appear
with show_in_command_panel checked when creating a new workflow.
* fix(ui): fix command panel empty tasks and step update losing checkbox state
Two fixes:
- Command panel: when kanban steps haven't loaded yet, don't filter
tasks by visibility (resolveVisibleStepIds returns null for empty
steps instead of an empty Set that rejects everything)
- Workflow settings: BackendWorkflowStep type and transformWorkflowStep
were missing show_in_command_panel, so refreshing steps after an
update dropped the field and the checkbox always reverted
* fix(ui): prevent back button from stealing focus in command panel
The back button was focusable and appeared before CommandInput, so
opening file search mode focused the button instead of the input.
Add tabIndex={-1} to keep focus on the search input.
* feat(web): show archive icon for archived tasks in command panel
Replace the text badge (e.g. "COMPLETED") with an IconArchive for
archived tasks in the command panel search results. Active tasks
keep the existing IconHammer icon.
* style: fix Prettier formatting and extract useStepMaps hook
Fix Prettier formatting issues in command-panel.tsx and
workflow-pipeline-editor-panels.tsx. Extract useStepMaps hook
from useInlineTaskSearchEffect to stay within the 100-line
function limit.
* fix: address PR review feedback
- Validate keyboard shortcut payload shape before persisting (service.go)
- Normalize KeyboardShortcuts to {} on read path for legacy rows (sqlite.go)
- Deduplicate getRepoDir into getFileName (command-panel-footer.tsx)
- Show toast on shortcut persistence failure instead of swallowing (keyboard-shortcuts-card.tsx)
- Reset keyboard_shortcuts in E2E test fixture for deterministic tests (test-base.ts)
1021 lines
36 KiB
Go
1021 lines
36 KiB
Go
// Package sqlite provides SQLite-based repository implementations.
|
|
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"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_execution_id, container_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, workflow_step_id, review_status, is_passthrough
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, 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.WorkflowStepID, session.ReviewStatus,
|
|
dialect.BoolToInt(session.IsPassthrough))
|
|
|
|
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 workflowStepID sql.NullString
|
|
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, &workflowStepID, &reviewStatus, &isPassthrough,
|
|
)
|
|
|
|
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 workflowStepID.Valid {
|
|
session.WorkflowStepID = &workflowStepID.String
|
|
}
|
|
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 id, task_id, agent_execution_id, container_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, workflow_step_id, review_status, is_passthrough
|
|
FROM task_sessions WHERE 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 id, task_id, agent_execution_id, container_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, workflow_step_id, review_status, is_passthrough
|
|
FROM task_sessions WHERE task_id = ? ORDER BY 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 id, task_id, agent_execution_id, container_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, workflow_step_id, review_status, is_passthrough
|
|
FROM task_sessions
|
|
WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
|
|
ORDER BY 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
|
|
func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.TaskSession) error {
|
|
session.UpdatedAt = time.Now().UTC()
|
|
|
|
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)
|
|
}
|
|
|
|
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE task_sessions SET
|
|
agent_execution_id = ?, container_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 = ?, completed_at = ?, updated_at = ?,
|
|
is_primary = ?, workflow_step_id = ?, review_status = ?, is_passthrough = ?
|
|
WHERE id = ?
|
|
`), session.AgentExecutionID, session.ContainerID, 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.CompletedAt, session.UpdatedAt,
|
|
dialect.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus,
|
|
dialect.BoolToInt(session.IsPassthrough),
|
|
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
|
|
}
|
|
|
|
// ClearSessionExecutionID clears the agent_execution_id for a session.
|
|
// This is used when a stale execution ID needs to be removed (e.g., after a failed resume on startup).
|
|
func (r *Repository) ClearSessionExecutionID(ctx context.Context, id string) error {
|
|
now := time.Now().UTC()
|
|
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE task_sessions SET agent_execution_id = '', container_id = '', updated_at = ? WHERE id = ?
|
|
`), now, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return fmt.Errorf("agent session not found: %s", id)
|
|
}
|
|
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 id, task_id, agent_execution_id, container_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, workflow_step_id, review_status, is_passthrough
|
|
FROM task_sessions WHERE task_id = ? ORDER BY 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 id, task_id, agent_execution_id, container_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, workflow_step_id, review_status, is_passthrough
|
|
FROM task_sessions WHERE state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY 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 id, task_id, agent_execution_id, container_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, workflow_step_id, review_status, is_passthrough
|
|
FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY 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
|
|
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
|
|
SELECT 1 FROM task_sessions
|
|
WHERE agent_profile_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
|
|
LIMIT 1
|
|
`), agentProfileID).Scan(&exists)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
return err == nil, 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
|
|
}
|
|
|
|
// 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 workflowStepID sql.NullString
|
|
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, &workflowStepID, &reviewStatus, &isPassthrough,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
session.State = models.TaskSessionState(state)
|
|
session.IsPrimary = isPrimary == 1
|
|
session.IsPassthrough = isPassthrough == 1
|
|
if workflowStepID.Valid {
|
|
session.WorkflowStepID = &workflowStepID.String
|
|
}
|
|
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
|
|
}
|
|
|
|
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 id, task_id, agent_execution_id, container_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, workflow_step_id, review_status, is_passthrough
|
|
FROM task_sessions WHERE task_id = ? AND 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.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 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(&taskID, &reviewStatus, &executorID, &sessionState, &agentProfileSnapshotJSON, &repositorySnapshotJSON, &executorType, &executorName); err != nil {
|
|
return nil, err
|
|
}
|
|
session := &models.TaskSession{
|
|
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
|
|
}
|