package sqlite import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/jmoiron/sqlx" taskmodels "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" ) // ErrTaskNotFound is returned (wrapped) by repository task lookups when the // task row is absent. Callers that must distinguish "row missing" from // "lookup failed" should check with errors.Is — this is the positive // signal the office GC uses to classify a kandev-managed container as // safely removable. var ErrTaskNotFound = errors.New("task not found") // Automation runs never appear in a task list: they are hidden by their // provenance, not by ephemerality (docs/specs/office/requirements/automations-settings.md). // is_ephemeral keeps its original quick-chat meaning, so every list read here // pairs the two. const ( notAutomationOriginT = `COALESCE(t.origin, '') != '` + taskmodels.TaskOriginAutomationRun + `'` andNotAutomationOrigin = ` AND COALESCE(origin, '') != '` + taskmodels.TaskOriginAutomationRun + `'` andNotAutomationOriginT = " AND " + notAutomationOriginT ) // systemTasksJoin is the JOIN onto the workflows table used by every // task list/search query that needs to know whether a task lives in a // kandev-managed system workflow (coordination, future routines). const systemTasksJoin = "LEFT JOIN workflows w ON w.id = t.workflow_id" // systemTasksProjection is the boolean computed column emitted next to // task rows so the API can mark each task as system-or-not. SQLite // returns 1/0 which scans into a Go bool via sqlx. const systemTasksProjection = "(CASE WHEN COALESCE(w.workflow_template_id,'') IN (%s) THEN 1 ELSE 0 END) AS is_system" // systemTasksPlaceholders returns the comma-separated `?` placeholders // matching the count of system template IDs and the slice of values to // bind. Used in both the projection (CASE ... IN (?, ?)) and the // optional NOT IN exclusion clause. func systemTasksPlaceholders() (string, []interface{}) { ids := taskrepo.SystemWorkflowTemplateIDs if len(ids) == 0 { // Defensive: an empty list breaks `IN ()` SQL. Substitute a // sentinel that matches nothing so every task scans as // non-system without changing surrounding query shape. return "''", nil } ph := make([]string, len(ids)) args := make([]interface{}, len(ids)) for i, id := range ids { ph[i] = "?" args[i] = id } return strings.Join(ph, ","), args } // TaskExecutionFields holds the execution-related fields from a task row. // The legacy execution_policy / execution_state columns were dropped in // Phase 4 of task-model-unification; stage progression is now owned by // the workflow engine. type TaskExecutionFields struct { ID string `db:"id"` AssigneeAgentProfileID string `db:"assignee_agent_profile_id"` State string `db:"state"` WorkspaceID string `db:"workspace_id"` IsFromOffice bool `db:"is_from_office"` } // GetTaskExecutionFields returns the execution-related fields for a task. func (r *Repository) GetTaskExecutionFields(ctx context.Context, taskID string) (*TaskExecutionFields, error) { var fields TaskExecutionFields err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT tasks.id, `+RunnerProjection("tasks")+` as assignee_agent_profile_id, COALESCE(tasks.state, '') as state, COALESCE(tasks.workspace_id, '') as workspace_id, `+taskrepo.IsFromOfficePredicate("tasks")+` AS is_from_office FROM tasks WHERE tasks.id = ? `), taskID).StructScan(&fields) if err == sql.ErrNoRows { return nil, fmt.Errorf("%w: %s", ErrTaskNotFound, taskID) } if err != nil { return nil, err } return &fields, nil } // GetTaskProjectID returns the project_id for a task, or an empty string if unset. func (r *Repository) GetTaskProjectID(ctx context.Context, taskID string) (string, error) { var projectID string err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT COALESCE(project_id, '') FROM tasks WHERE id = ? `), taskID).Scan(&projectID) if err == sql.ErrNoRows { return "", fmt.Errorf("task not found: %s", taskID) } return projectID, err } // UpdateTaskState updates the state column on a task (e.g. "IN_PROGRESS", "COMPLETED"). func (r *Repository) UpdateTaskState(ctx context.Context, taskID, state string) error { result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET state = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `), state, taskID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("task not found: %s", taskID) } return nil } // UpdateTaskAssignee writes (or clears) the per-task runner participant // row for a task. ADR 0005 Wave F replaced the legacy // tasks.assignee_agent_profile_id column with a 'runner' row in // workflow_step_participants; the row is keyed by (step_id, task_id). // // An empty assigneeID clears the runner participant. The updated_at on // the task is bumped so dashboards still observe a "task changed" // timestamp. // // Tasks created outside a workflow (e.g. channel tasks) have no // workflow_step_id; for those we key the runner row on (step_id="", // task_id) so the projection's per-task runner clause still resolves it // (the (step_id="" / step_id="") match holds because the SELECT joins // step_id = task.workflow_step_id which is also ""). func (r *Repository) UpdateTaskAssignee(ctx context.Context, taskID, assigneeID string) error { var stepID string err := r.ro.QueryRowxContext(ctx, r.ro.Rebind( `SELECT COALESCE(workflow_step_id, '') FROM tasks WHERE id = ?`), taskID).Scan(&stepID) if err != nil { if err == sql.ErrNoRows { return fmt.Errorf("task not found: %s", taskID) } return err } tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if assigneeID == "" { if _, err := tx.ExecContext(ctx, tx.Rebind(` DELETE FROM workflow_step_participants WHERE step_id = ? AND task_id = ? AND role = 'runner' `), stepID, taskID); err != nil { return err } } else { var existing string probeErr := tx.QueryRowxContext(ctx, tx.Rebind(` SELECT id FROM workflow_step_participants WHERE step_id = ? AND task_id = ? AND role = 'runner' LIMIT 1 `), stepID, taskID).Scan(&existing) switch probeErr { case nil: if _, err := tx.ExecContext(ctx, tx.Rebind( `UPDATE workflow_step_participants SET agent_profile_id = ? WHERE id = ?`), assigneeID, existing); err != nil { return err } case sql.ErrNoRows: if _, err := tx.ExecContext(ctx, tx.Rebind(` INSERT INTO workflow_step_participants (id, step_id, task_id, role, agent_profile_id, decision_required, position) VALUES (?, ?, ?, 'runner', ?, 0, 0) `), newParticipantUUID(), stepID, taskID, assigneeID); err != nil { return err } default: return probeErr } } if _, err := tx.ExecContext(ctx, tx.Rebind(` UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ? `), taskID); err != nil { return err } return tx.Commit() } // TaskBasicInfo contains the minimal task fields needed for prompt building. type TaskBasicInfo struct { Title string `db:"title"` Description string `db:"description"` Identifier string `db:"identifier"` Priority string `db:"priority"` ProjectID string `db:"project_id"` } // GetTaskBasicInfo returns minimal task data for prompt context. // Returns nil, nil if the task is not found. func (r *Repository) GetTaskBasicInfo(ctx context.Context, taskID string) (*TaskBasicInfo, error) { var info TaskBasicInfo err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT COALESCE(title, '') AS title, COALESCE(description, '') AS description, COALESCE(identifier, '') AS identifier, COALESCE(priority, 'medium') AS priority, COALESCE(project_id, '') AS project_id FROM tasks WHERE id = ? `), taskID).StructScan(&info) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &info, nil } // TaskSearchResult contains fields returned by a task search query. type TaskSearchResult struct { ID string `db:"id"` WorkspaceID string `db:"workspace_id"` Identifier string `db:"identifier"` Title string `db:"title"` Description string `db:"description"` Status string `db:"status"` Priority string `db:"priority"` ParentID string `db:"parent_id"` ProjectID string `db:"project_id"` AssigneeAgentProfileID string `db:"assignee_agent_profile_id"` Labels string `db:"labels"` CreatedAt string `db:"created_at"` UpdatedAt string `db:"updated_at"` // IsSystem is true when the task lives in a kandev-managed system // workflow (e.g. the standing coordination task; future routine // tasks). The Office Tasks UI hides these by default and surfaces // them via a dev toggle. Populated by the LEFT JOIN in list/search // queries; non-list callers may leave it zero. IsSystem bool `db:"is_system"` } // TaskRow is a TaskSearchResult alias used by the office tasks API. // It uses the same columns as TaskSearchResult — execution_policy and // execution_state are intentionally excluded because those columns are // added by the task service migration and may not exist in all contexts. type TaskRow = TaskSearchResult // ListTasksByWorkspace returns all non-archived, non-ephemeral tasks for a // workspace ordered by updated_at descending. func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID string, includeSystem bool) ([]*TaskRow, error) { sysPh, sysArgs := systemTasksPlaceholders() args := []interface{}{} args = append(args, sysArgs...) // for the IS_SYSTEM projection args = append(args, workspaceID) where := []string{ "t.workspace_id = ?", "t.archived_at IS NULL", "t.is_ephemeral = 0", notAutomationOriginT, } if !includeSystem && len(sysArgs) > 0 { where = append(where, "COALESCE(w.workflow_template_id,'') NOT IN ("+sysPh+")") args = append(args, sysArgs...) } query := ` SELECT t.id, COALESCE(t.workspace_id, '') AS workspace_id, COALESCE(t.identifier, '') AS identifier, COALESCE(t.title, '') AS title, COALESCE(t.description, '') AS description, COALESCE(t.state, '') AS status, COALESCE(t.priority, 'medium') AS priority, COALESCE(t.parent_id, '') AS parent_id, COALESCE(t.project_id, '') AS project_id, ` + RunnerProjection("t") + ` AS assignee_agent_profile_id, COALESCE(t.labels, '[]') AS labels, t.created_at, t.updated_at, ` + fmt.Sprintf(systemTasksProjection, sysPh) + ` FROM tasks t ` + systemTasksJoin + ` WHERE ` + strings.Join(where, " AND ") + ` ORDER BY t.updated_at DESC ` rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(query), args...) if err != nil { return nil, fmt.Errorf("list tasks: %w", err) } defer func() { _ = rows.Close() }() return scanSearchResults(rows) } // TaskListSortField identifies the column the tasks list is sorted by. // Validated against an allow-list before being interpolated into SQL to // keep ListTasksFiltered safe against ORDER BY injection. type TaskListSortField string const ( TaskSortUpdatedAt TaskListSortField = "updated_at" TaskSortCreatedAt TaskListSortField = "created_at" TaskSortPriority TaskListSortField = "priority" ) // taskListSortColumns maps a public sort name to the SQL column expression. // Anything not in the map is rejected at the handler tier. var taskListSortColumns = map[TaskListSortField]string{ TaskSortUpdatedAt: "t.updated_at", TaskSortCreatedAt: "t.created_at", TaskSortPriority: "t.priority", } // ListTasksOptions carries the filter/sort/pagination parameters for // ListTasksFiltered. Empty / zero values are treated as "no filter". type ListTasksOptions struct { Status []string // matches tasks.state, e.g. {"TODO","IN_PROGRESS"} Priority []string // matches tasks.priority AssigneeID string // exact match on the runner column ProjectID string // exact match on tasks.project_id SortField TaskListSortField // defaults to TaskSortUpdatedAt SortDesc bool // descending order; defaults to true for updated_at/created_at // Limit caps the number of rows returned. ≤0 / >500 are clamped. Limit int // CursorValue + CursorID encode keyset pagination over (sort_value, id). // Both empty means "first page". Pass the prior page's tail values to // fetch the next page. CursorValue string CursorID string // IncludeSystem keeps tasks belonging to kandev-managed system // workflows (coordination, future routines) in the result. When // false (default) those tasks are filtered out — the Office Tasks // UI uses this to keep its list user-task-only by default. IncludeSystem bool } // ListTasksFilteredResult carries one page of tasks plus the cursor needed // to fetch the next page. type ListTasksFilteredResult struct { Tasks []*TaskRow NextCursor string // empty when this is the final page NextID string // tail row id; pair with NextCursor } // ListTasksFiltered returns a single page of non-archived, non-ephemeral // tasks for a workspace, optionally filtered by status / priority / // assignee / project, sorted by an allow-listed column, with keyset // pagination over (sort_value, id) (Stream E of office optimization). // // SQL safety: status / priority / cursor values are bound as ? parameters; // the only interpolated value is the sort column expression, sourced // from taskListSortColumns (allow-list). func (r *Repository) ListTasksFiltered( ctx context.Context, workspaceID string, opts ListTasksOptions, ) (*ListTasksFilteredResult, error) { resolved, err := resolveListTasksOptions(opts) if err != nil { return nil, err } sysPh, sysArgs := systemTasksPlaceholders() args := []interface{}{} args = append(args, sysArgs...) // for the IS_SYSTEM projection whereParts, whereArgs := buildTaskWhereClause(workspaceID, opts, resolved) args = append(args, whereArgs...) if !opts.IncludeSystem && len(sysArgs) > 0 { whereParts = append(whereParts, "COALESCE(w.workflow_template_id,'') NOT IN ("+sysPh+")") args = append(args, sysArgs...) } args = append(args, resolved.limit+1) // fetch one extra to detect "has next" query := `SELECT t.id, COALESCE(t.workspace_id, '') AS workspace_id, COALESCE(t.identifier, '') AS identifier, COALESCE(t.title, '') AS title, COALESCE(t.description, '') AS description, COALESCE(t.state, '') AS status, COALESCE(t.priority, 'medium') AS priority, COALESCE(t.parent_id, '') AS parent_id, COALESCE(t.project_id, '') AS project_id, ` + RunnerProjection("t") + ` AS assignee_agent_profile_id, COALESCE(t.labels, '[]') AS labels, t.created_at, t.updated_at, ` + fmt.Sprintf(systemTasksProjection, sysPh) + ` FROM tasks t ` + systemTasksJoin + ` WHERE ` + strings.Join(whereParts, " AND ") + ` ORDER BY ` + resolved.sortCol + ` ` + resolved.dir + `, t.id ` + resolved.dir + ` LIMIT ?` rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(query), args...) if err != nil { return nil, fmt.Errorf("list tasks filtered: %w", err) } defer func() { _ = rows.Close() }() tasks, err := scanSearchResults(rows) if err != nil { return nil, err } out := &ListTasksFilteredResult{Tasks: tasks} if len(tasks) > resolved.limit { out.Tasks = tasks[:resolved.limit] tail := out.Tasks[len(out.Tasks)-1] out.NextCursor = cursorValueForTask(tail, resolved.sortField) out.NextID = tail.ID } return out, nil } // resolvedListTasksOptions captures the validated, defaulted form of // ListTasksOptions used by buildTaskWhereClause and the SQL builder. type resolvedListTasksOptions struct { limit int sortField TaskListSortField sortCol string dir string } func resolveListTasksOptions(opts ListTasksOptions) (resolvedListTasksOptions, error) { limit := opts.Limit if limit <= 0 || limit > 500 { limit = 100 } sortField := opts.SortField if sortField == "" { sortField = TaskSortUpdatedAt } sortCol, ok := taskListSortColumns[sortField] if !ok { return resolvedListTasksOptions{}, fmt.Errorf("invalid sort field: %s", sortField) } dir := "DESC" if !opts.SortDesc { dir = "ASC" } return resolvedListTasksOptions{limit: limit, sortField: sortField, sortCol: sortCol, dir: dir}, nil } func buildTaskWhereClause( workspaceID string, opts ListTasksOptions, resolved resolvedListTasksOptions, ) ([]string, []interface{}) { args := []interface{}{workspaceID} parts := []string{ "t.workspace_id = ?", "t.archived_at IS NULL", "t.is_ephemeral = 0", notAutomationOriginT, } if len(opts.Status) > 0 { ph, vals := bindList(opts.Status) parts = append(parts, "t.state IN ("+ph+")") args = append(args, vals...) } if len(opts.Priority) > 0 { ph, vals := bindList(opts.Priority) parts = append(parts, "t.priority IN ("+ph+")") args = append(args, vals...) } if opts.AssigneeID != "" { parts = append(parts, RunnerProjection("t")+" = ?") args = append(args, opts.AssigneeID) } if opts.ProjectID != "" { parts = append(parts, "COALESCE(t.project_id,'') = ?") args = append(args, opts.ProjectID) } if opts.CursorValue != "" { // Keyset pagination: (sort_value, id) < (cursor_value, cursor_id) // for descending order, > for ascending. Equality on the sort // column tie-breaks by id so the cursor remains unique. op := "<" if resolved.dir == "ASC" { op = ">" } parts = append(parts, fmt.Sprintf( "(%s %s ? OR (%s = ? AND t.id %s ?))", resolved.sortCol, op, resolved.sortCol, op, )) args = append(args, opts.CursorValue, opts.CursorValue, opts.CursorID) } return parts, args } // bindList returns "?,?,?" placeholders and the values cast to []interface{} // for splatting into SelectContext args. func bindList(vals []string) (string, []interface{}) { ph := make([]string, len(vals)) args := make([]interface{}, len(vals)) for i, v := range vals { ph[i] = "?" args[i] = v } return strings.Join(ph, ","), args } // cursorValueForTask extracts the sort-column value from a task row for // the given sort field, used as the keyset cursor for pagination. func cursorValueForTask(t *TaskRow, sortField TaskListSortField) string { switch sortField { case TaskSortCreatedAt: return t.CreatedAt case TaskSortPriority: return t.Priority default: return t.UpdatedAt } } // GetTaskByID returns a single task as a TaskRow. // Returns nil, nil when the task does not exist. func (r *Repository) GetTaskByID(ctx context.Context, taskID string) (*TaskRow, error) { var result TaskRow err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT t.id, COALESCE(t.workspace_id, '') AS workspace_id, COALESCE(t.identifier, '') AS identifier, COALESCE(t.title, '') AS title, COALESCE(t.description, '') AS description, COALESCE(t.state, '') AS status, COALESCE(t.priority, 'medium') AS priority, COALESCE(t.parent_id, '') AS parent_id, COALESCE(t.project_id, '') AS project_id, `+RunnerProjection("t")+` AS assignee_agent_profile_id, COALESCE(t.labels, '[]') AS labels, t.created_at, t.updated_at FROM tasks t WHERE t.id = ? `), taskID).StructScan(&result) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("get task %s: %w", taskID, err) } return &result, nil } // ListChildTasks returns direct, non-archived child tasks for a parent task. func (r *Repository) ListChildTasks(ctx context.Context, parentID string) ([]*TaskRow, error) { rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(` SELECT t.id, COALESCE(t.workspace_id, '') AS workspace_id, COALESCE(t.identifier, '') AS identifier, COALESCE(t.title, '') AS title, COALESCE(t.description, '') AS description, COALESCE(t.state, '') AS status, COALESCE(t.priority, 'medium') AS priority, COALESCE(t.parent_id, '') AS parent_id, COALESCE(t.project_id, '') AS project_id, `+RunnerProjection("t")+` AS assignee_agent_profile_id, COALESCE(t.labels, '[]') AS labels, t.created_at, t.updated_at FROM tasks t WHERE t.parent_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY t.created_at ASC `), parentID) if err != nil { return nil, fmt.Errorf("list child tasks: %w", err) } defer func() { _ = rows.Close() }() return scanSearchResults(rows) } // SearchTasks performs a full-text search on tasks using FTS5, falling back to // LIKE-based search when the FTS5 table is not available. func (r *Repository) SearchTasks(ctx context.Context, workspaceID, query string, limit int) ([]*TaskSearchResult, error) { if limit <= 0 || limit > 200 { limit = 50 } if r.hasFTSTable() { return r.searchTasksFTS(ctx, workspaceID, query, limit) } return r.searchTasksLike(ctx, workspaceID, query, limit) } // hasFTSTable checks whether the tasks_fts virtual table exists. func (r *Repository) hasFTSTable() bool { var exists int err := r.ro.QueryRow( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='tasks_fts'", ).Scan(&exists) return err == nil && exists == 1 } // ftsEscape quotes a user query for safe FTS5 MATCH usage and appends * for prefix matching. // Each whitespace-separated token is wrapped in double quotes so operators like - are literal. func ftsEscape(query string) string { tokens := strings.Fields(query) if len(tokens) == 0 { return `""` } quoted := make([]string, len(tokens)) for i, tok := range tokens { escaped := strings.ReplaceAll(tok, `"`, `""`) quoted[i] = `"` + escaped + `"` } // Append * to the last token for prefix matching. last := quoted[len(quoted)-1] quoted[len(quoted)-1] = last + "*" return strings.Join(quoted, " ") } // searchTasksFTS uses FTS5 MATCH for full-text search with prefix matching. func (r *Repository) searchTasksFTS(ctx context.Context, workspaceID, query string, limit int) ([]*TaskSearchResult, error) { ftsQuery := ftsEscape(query) rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(` SELECT t.id, COALESCE(t.workspace_id, '') AS workspace_id, COALESCE(t.identifier, '') AS identifier, COALESCE(t.title, '') AS title, COALESCE(t.description, '') AS description, COALESCE(t.state, '') AS status, COALESCE(t.priority, 'medium') AS priority, COALESCE(t.parent_id, '') AS parent_id, COALESCE(t.project_id, '') AS project_id, `+RunnerProjection("t")+` AS assignee_agent_profile_id, COALESCE(t.labels, '[]') AS labels, t.created_at, t.updated_at FROM tasks t JOIN tasks_fts fts ON t.rowid = fts.rowid WHERE fts.tasks_fts MATCH ? AND t.workspace_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY rank LIMIT ? `), ftsQuery, workspaceID, limit) if err != nil { return nil, fmt.Errorf("fts search tasks: %w", err) } defer func() { _ = rows.Close() }() return scanSearchResults(rows) } // searchTasksLike is the LIKE-based fallback when FTS5 is unavailable. func (r *Repository) searchTasksLike(ctx context.Context, workspaceID, query string, limit int) ([]*TaskSearchResult, error) { pattern := "%" + query + "%" rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(` SELECT t.id, COALESCE(t.workspace_id, '') AS workspace_id, COALESCE(t.identifier, '') AS identifier, COALESCE(t.title, '') AS title, COALESCE(t.description, '') AS description, COALESCE(t.state, '') AS status, COALESCE(t.priority, 'medium') AS priority, COALESCE(t.parent_id, '') AS parent_id, COALESCE(t.project_id, '') AS project_id, `+RunnerProjection("t")+` AS assignee_agent_profile_id, COALESCE(t.labels, '[]') AS labels, t.created_at, t.updated_at FROM tasks t WHERE t.workspace_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (t.title LIKE ? OR t.description LIKE ? OR t.identifier LIKE ?) ORDER BY t.updated_at DESC LIMIT ? `), workspaceID, pattern, pattern, pattern, limit) if err != nil { return nil, fmt.Errorf("search tasks: %w", err) } defer func() { _ = rows.Close() }() return scanSearchResults(rows) } // scanSearchResults scans rows into TaskSearchResult slices. func scanSearchResults(rows *sqlx.Rows) ([]*TaskSearchResult, error) { var results []*TaskSearchResult for rows.Next() { var r TaskSearchResult if err := rows.StructScan(&r); err != nil { return nil, fmt.Errorf("scan search result: %w", err) } results = append(results, &r) } return results, rows.Err() } // CountActionableTasksForAgent returns the number of tasks assigned to // the given agent that are in an actionable state (TODO or IN_PROGRESS) // and not archived. Resolves the assignee through the runner projection // so per-task overrides and step-primary fallbacks both count. // // Automation runs are excluded: an automation configured against a workflow // step resolves to that step's runner, so counting its long-lived run would // inflate the agent's load forever and starve it of real work. func (r *Repository) CountActionableTasksForAgent(ctx context.Context, agentID string) (int, error) { var count int err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT COUNT(*) FROM tasks t WHERE `+RunnerProjection("t")+` = ? AND t.state IN ('TODO', 'IN_PROGRESS') AND t.archived_at IS NULL`+andNotAutomationOriginT+` `), agentID).Scan(&count) return count, err } // GetCheckoutAgentBySession returns the checkout_agent_id for the task // associated with a given session. Returns "" if not found. func (r *Repository) GetCheckoutAgentBySession(ctx context.Context, sessionID string) (string, error) { var agentID string err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT COALESCE(t.checkout_agent_id, '') FROM tasks t JOIN task_sessions ts ON ts.task_id = t.id WHERE ts.id = ? `), sessionID).Scan(&agentID) if err != nil { return "", err } return agentID, nil } // CheckoutTask atomically acquires a legacy task lock for an agent. // New scheduler launches must use CheckoutTaskForRun so the lock records the // exact run that owns it. This compatibility method keeps older callers and // migrated rows working without erasing a run-scoped lock. func (r *Repository) CheckoutTask(ctx context.Context, taskID, agentID string) (bool, error) { result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET checkout_agent_id = ?, checkout_at = datetime('now'), checkout_run_id = NULL WHERE id = ? AND ( checkout_agent_id IS NULL OR checkout_agent_id = '' OR (checkout_agent_id = ? AND (checkout_run_id IS NULL OR checkout_run_id = '')) ) `), agentID, taskID, agentID) if err != nil { return false, err } rows, err := result.RowsAffected() if err != nil { return false, err } return rows > 0, nil } // CheckoutTaskForRun atomically acquires an exclusive task lock for a run. // A run can renew its own lock, but another run, including one for the same // agent, cannot replace the recorded owner. func (r *Repository) CheckoutTaskForRun(ctx context.Context, taskID, agentID, runID string) (bool, error) { if runID == "" { return r.CheckoutTask(ctx, taskID, agentID) } result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET checkout_agent_id = ?, checkout_at = datetime('now'), checkout_run_id = ? WHERE id = ? AND ( checkout_agent_id IS NULL OR checkout_agent_id = '' OR (checkout_agent_id = ? AND (checkout_run_id IS NULL OR checkout_run_id = '' OR checkout_run_id = ?)) ) `), agentID, runID, taskID, agentID, runID) if err != nil { return false, err } rows, err := result.RowsAffected() if err != nil { return false, err } return rows > 0, nil } // ReleaseTaskCheckout releases the exclusive lock on a task. func (r *Repository) ReleaseTaskCheckout(ctx context.Context, taskID string) error { _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET checkout_agent_id = NULL, checkout_at = NULL, checkout_run_id = NULL WHERE id = ? `), taskID) return err } // ReleaseTaskCheckoutForAgent releases the exclusive lock on a task only if // agentID is the current holder. Unlike ReleaseTaskCheckout, a caller that // never held the lock (e.g. the loser of a checkout race, or a run for an // agent that was never checked out) cannot clear someone else's active // checkout out from under them. func (r *Repository) ReleaseTaskCheckoutForAgent(ctx context.Context, taskID, agentID string) error { _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET checkout_agent_id = NULL, checkout_at = NULL, checkout_run_id = NULL WHERE id = ? AND checkout_agent_id = ? `), taskID, agentID) return err } // ReleaseTaskCheckoutForRun releases a task lock only when the run still // owns it. The empty-owner fallback is for rows created before // checkout_run_id was introduced; all new scheduler rows carry a run ID. func (r *Repository) ReleaseTaskCheckoutForRun(ctx context.Context, taskID, agentID, runID string) error { if runID == "" { return r.ReleaseTaskCheckoutForAgent(ctx, taskID, agentID) } _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET checkout_agent_id = NULL, checkout_at = NULL, checkout_run_id = NULL WHERE id = ? AND checkout_agent_id = ? AND (checkout_run_id = ? OR checkout_run_id IS NULL OR checkout_run_id = '') `), taskID, agentID, runID) return err } // ReapStaleCheckouts clears checkout_agent_id/checkout_at/checkout_run_id on tasks whose // checkout is older than olderThan and that have no queued or claimed run // *belonging to the checkout holder* in flight. This is a backstop for // callers that fail to release the checkout on a terminal run transition // (an event-subscriber path that crashes before publishing, a run that // never reaches a terminal event at all) — releaseTaskCheckoutForRun // (internal/office/service) is the primary release path; this only cleans // up what that missed. Returns the number of tasks reaped. json_extract is // SQLite-flavoured — see CancelRunsForTasks in tree_holds.go for why // that's acceptable here. // // The in-flight check is scoped to the checkout holder's own runs, not any // run on the task: a queued run can belong to a different agent that is // waiting precisely because this checkout is leaked (see // requeueContendedCheckout in scheduler_integration.go, which re-queues a // run that lost the checkout race) — task-scoping would let that waiting // run permanently suppress the reap it depends on. 'queued' stays in the // status list even holder-scoped: recoverStaleClaimedRuns runs immediately // before reapStaleCheckouts in the same tick and flips a live agent's own // claimed run to queued at 30 minutes, so dropping 'queued' would reap a // still-executing holder. func (r *Repository) ReapStaleCheckouts(ctx context.Context, olderThan time.Time) (int64, error) { result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET checkout_agent_id = NULL, checkout_at = NULL, checkout_run_id = NULL WHERE checkout_agent_id IS NOT NULL AND checkout_agent_id != '' AND checkout_at IS NOT NULL AND checkout_at < ? AND NOT EXISTS ( SELECT 1 FROM runs w WHERE ( (tasks.checkout_run_id IS NOT NULL AND tasks.checkout_run_id != '' AND w.id = tasks.checkout_run_id) OR (COALESCE(tasks.checkout_run_id, '') = '' AND json_extract(w.payload, '$.task_id') = tasks.id) ) AND w.agent_profile_id = tasks.checkout_agent_id AND json_extract(w.payload, '$.task_id') = tasks.id AND w.status IN ('queued', 'claimed') ) `), olderThan) if err != nil { return 0, err } return result.RowsAffected() } // UpdateTaskPriority sets the priority TEXT column on a task. Caller is // responsible for validating the value against the four-value enum; the DB // CHECK constraint catches anything that slips through. func (r *Repository) UpdateTaskPriority(ctx context.Context, taskID, priority string) error { return r.execTaskScalar(ctx, taskID, "priority", priority) } // UpdateTaskProjectID sets the project_id column. Empty string clears it. func (r *Repository) UpdateTaskProjectID(ctx context.Context, taskID, projectID string) error { return r.execTaskScalar(ctx, taskID, "project_id", projectID) } // UpdateTaskParentID sets the parent_id column and applies the canonical // re-parent workspace policy: an inherit_parent subtask whose parent is // changing keeps its materialized workspace as shared_group instead of // silently inheriting the new parent's. Empty string clears the parent. // The metadata normalization is conditioned on the parent actually changing, // so a repeated PATCH with the same parent_id is a no-op for workspace // semantics. func (r *Repository) UpdateTaskParentID(ctx context.Context, taskID, parentID string) error { query := ` UPDATE tasks SET parent_id = ?, metadata = CASE WHEN parent_id IS NOT ? AND json_valid(metadata) THEN CASE WHEN json_extract(metadata, '$.workspace.mode') = 'inherit_parent' THEN json_set(metadata, '$.workspace.mode', 'shared_group') ELSE metadata END ELSE metadata END, updated_at = CURRENT_TIMESTAMP WHERE id = ? ` result, err := r.db.ExecContext(ctx, r.db.Rebind(query), parentID, parentID, taskID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("task not found: %s", taskID) } return nil } // taskScalarColumns enumerates the only columns execTaskScalar may target. // Column names are interpolated into the SQL string (SQLite does not support // parameterised identifiers), so the allowlist prevents an accidental // caller-controlled column ever reaching fmt.Sprintf — gosec G201 guardrail. var taskScalarColumns = map[string]struct{}{ "priority": {}, "project_id": {}, } // execTaskScalar updates a single TEXT column on a task and bumps updated_at. func (r *Repository) execTaskScalar(ctx context.Context, taskID, column, value string) error { if _, ok := taskScalarColumns[column]; !ok { return fmt.Errorf("disallowed task column %q", column) } query := fmt.Sprintf("UPDATE tasks SET %s = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", column) result, err := r.db.ExecContext(ctx, r.db.Rebind(query), value, taskID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("task not found: %s", taskID) } return nil } // GetProjectWorkspaceID returns the workspace_id for a project. // Returns empty string when the project does not exist. func (r *Repository) GetProjectWorkspaceID(ctx context.Context, projectID string) (string, error) { if projectID == "" { return "", nil } var workspaceID string err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT COALESCE(workspace_id,'') FROM office_projects WHERE id = ? `), projectID).Scan(&workspaceID) if err == sql.ErrNoRows { return "", nil } return workspaceID, err } // GetTaskWorkspaceID returns the workspace_id for a task. // Returns empty string when the task does not exist. func (r *Repository) GetTaskWorkspaceID(ctx context.Context, taskID string) (string, error) { if taskID == "" { return "", nil } var workspaceID string err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT COALESCE(workspace_id,'') FROM tasks WHERE id = ? `), taskID).Scan(&workspaceID) if err == sql.ErrNoRows { return "", nil } return workspaceID, err } // UnstartedTaskRow holds the minimal fields needed by the recovery sweep. type UnstartedTaskRow struct { ID string `db:"id"` AssigneeAgentProfileID string `db:"assignee_agent_profile_id"` WorkspaceID string `db:"workspace_id"` } // HasOfficeAdoption reports whether persisted Office configuration exists // anywhere in the backend. It is intentionally cheap because the shared // cron loop checks it before every recovery pass. func (r *Repository) HasOfficeAdoption(ctx context.Context) (bool, error) { var adopted bool err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT EXISTS ( SELECT 1 FROM workspaces WHERE COALESCE(office_workflow_id, '') != '' ) OR EXISTS ( SELECT 1 FROM office_projects WHERE COALESCE(workspace_id, '') != '' ) `)).Scan(&adopted) return adopted, err } // ListUnstartedTasks returns TODO tasks with an assignee, not archived, // within the lookback window, that have no active run (queued/claimed/finished). // CountTasksByWorkspace returns the number of non-archived, non-ephemeral tasks // for a workspace. func (r *Repository) CountTasksByWorkspace(ctx context.Context, workspaceID string) (int, error) { var count int err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND archived_at IS NULL AND is_ephemeral = 0`+andNotAutomationOrigin+` `), workspaceID).Scan(&count) return count, err } // ListUnstartedTasks feeds scheduler recovery, which launches whatever it // finds. Automation runs are excluded: they are started once, explicitly, at // trigger time, and recovery picking one up would launch it a second time // through a lifecycle path that knows nothing about the automation's run row // or its concurrency cap. func (r *Repository) ListUnstartedTasks( ctx context.Context, lookbackHours int, limit int, ) ([]*UnstartedTaskRow, error) { var rows []*UnstartedTaskRow err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(` SELECT t.id, `+RunnerProjection("t")+` AS assignee_agent_profile_id, t.workspace_id FROM tasks t WHERE t.state = 'TODO' AND `+taskrepo.IsFromOfficePredicate("t")+` AND `+RunnerProjection("t")+` != '' AND t.archived_at IS NULL`+andNotAutomationOriginT+` AND t.created_at >= datetime('now', '-' || ? || ' hours') AND NOT EXISTS ( SELECT 1 FROM runs w WHERE json_extract(w.payload, '$.task_id') = t.id AND w.status IN ('queued', 'claimed', 'finished') ) LIMIT ? `), lookbackHours, limit) if err != nil { return nil, err } if rows == nil { rows = []*UnstartedTaskRow{} } return rows, nil }