481359ddb2
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1039 lines
38 KiB
Go
1039 lines
38 KiB
Go
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"`
|
|
// AssigneeUserID is the human assignee. It is only projected by the
|
|
// queries that need it (detail, workspace list); sqlx leaves it zero
|
|
// for the others rather than failing, so adding a projection later is
|
|
// additive.
|
|
AssigneeUserID string `db:"assignee_user_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.assignee_user_id, '') AS assignee_user_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.assignee_user_id, '') AS assignee_user_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
|
|
}
|