Files
kandev/apps/backend/internal/task/repository/sqlite/task.go
T
José Almeida 1ed2c59997 feat: persist and expire quick chats (#1612)
* feat: persist and expire quick chats

* fix: address quick chat review feedback

* fix: resolve quick chat review findings

* fix: close remaining quick chat review gaps

* fix: avoid repeated quick chat expiration rechecks

* fix: guard quick chat expiration deletes

* fix: expire abandoned starting quick chats

* fix: ignore missing quick chat expiration candidates
2026-07-08 12:11:28 +01:00

1173 lines
39 KiB
Go

package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"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"
usermodels "github.com/kandev/kandev/internal/user/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// defaultTaskAlias is the fallback alias the projection helpers use when
// the caller passes an empty string — i.e., when the SELECT references
// the `tasks` table directly rather than through a join alias.
const defaultTaskAlias = "tasks"
type taskScanColumn struct {
name string
selectExpr func(alias string) string
}
var taskScanColumns = []taskScanColumn{
{name: "id"},
{name: "workspace_id"},
{name: "workflow_id"},
{name: "workflow_step_id"},
{name: "title"},
{name: "description"},
{name: "state"},
{name: "priority"},
{name: "position"},
{name: "metadata"},
{name: "is_ephemeral"},
{name: "parent_id"},
{name: "archived_at"},
{name: "created_at"},
{name: "updated_at"},
{name: "assignee_agent_profile_id", selectExpr: func(alias string) string {
return runnerProjection(alias) + ` AS assignee_agent_profile_id`
}},
{name: "origin"},
{name: "project_id"},
{name: "labels"},
{name: "identifier"},
{name: "is_from_office", selectExpr: func(alias string) string {
return isFromOfficeProjection(alias) + ` AS is_from_office`
}},
}
// taskSelectColumns returns the column projection (with runner subquery)
// for a SELECT against tasks aliased as `alias`. The output column order
// matches scanSingleTask / scanTasks.
func taskSelectColumns(alias string) string {
if alias == "" {
alias = defaultTaskAlias
}
return taskScanColumnSQL(alias, true)
}
func taskProjectedColumns(alias string) string {
if alias == "" {
alias = defaultTaskAlias
}
return taskScanColumnSQL(alias, false)
}
func taskScanColumnSQL(alias string, useExpressions bool) string {
prefix := alias + "."
cols := make([]string, 0, len(taskScanColumns))
for _, col := range taskScanColumns {
if useExpressions && col.selectExpr != nil {
cols = append(cols, col.selectExpr(alias))
continue
}
cols = append(cols, prefix+col.name)
}
return strings.Join(cols, ", ")
}
// isFromOfficeProjection returns a SQL boolean expression that is true
// when the task is owned by office: either it has a non-empty project_id
// (explicit office task) or its workflow matches the workspace's
// office_workflow_id (the canonical "office workflow"). Kanban tasks live
// in any other workflow and have no project.
func isFromOfficeProjection(alias string) string {
if alias == "" {
alias = defaultTaskAlias
}
return `(
COALESCE(` + alias + `.project_id, '') != ''
OR EXISTS (
SELECT 1 FROM workspaces w
WHERE w.id = ` + alias + `.workspace_id
AND COALESCE(w.office_workflow_id, '') != ''
AND w.office_workflow_id = ` + alias + `.workflow_id
)
)`
}
func excludeConfigModePredicate(driver, col string) string {
if dialect.IsPostgres(driver) {
// Repository writes always marshal metadata as JSON; dirty Postgres rows
// with malformed JSON should fail loudly instead of being silently skipped.
return fmt.Sprintf("COALESCE(%s, '') NOT IN ('true', '1')", dialect.JSONExtract(driver, col, "config_mode"))
}
return fmt.Sprintf("%s IS NOT 1", dialect.JSONExtract(driver, col, "config_mode"))
}
// runnerProjection produces the correlated subquery (without alias) that
// resolves the runner for the row of `tasks` with the given alias. Used
// inline in SELECT projections.
func runnerProjection(alias string) string {
if alias == "" {
alias = defaultTaskAlias
}
return `COALESCE(
(SELECT wsp.agent_profile_id FROM workflow_step_participants wsp
WHERE wsp.step_id = ` + alias + `.workflow_step_id
AND wsp.task_id = ` + alias + `.id
AND wsp.role = 'runner'
ORDER BY wsp.position ASC, wsp.id ASC LIMIT 1),
(SELECT ws.agent_profile_id FROM workflow_steps ws WHERE ws.id = ` + alias + `.workflow_step_id),
''
)`
}
// CreateTask creates a new task. The assignee column has been removed
// (ADR 0005 Wave F); when the request carries AssigneeAgentProfileID we
// upsert a 'runner' row in workflow_step_participants instead.
func (r *Repository) CreateTask(ctx context.Context, task *models.Task) error {
if task.ID == "" {
task.ID = uuid.New().String()
}
now := time.Now().UTC()
task.CreatedAt = now
task.UpdatedAt = now
metadata, err := json.Marshal(task.Metadata)
if err != nil {
metadata = []byte("{}")
}
if task.Labels == "" {
task.Labels = "[]"
}
// Office migrations enforce a CHECK constraint on priority; default
// the empty zero value to 'medium' to match the column DEFAULT.
if task.Priority == "" {
task.Priority = "medium"
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, r.db.Rebind(`
INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, parent_id, created_at, updated_at, origin, project_id, labels, identifier)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.IsEphemeral, task.ParentID, task.CreatedAt, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return fmt.Errorf("failed to rollback task insert: %w", rollbackErr)
}
return err
}
if task.AssigneeAgentProfileID != "" && task.WorkflowStepID != "" {
if err := upsertRunnerInTx(ctx, tx, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("failed to rollback after runner write: %w", rbErr)
}
return err
}
}
return tx.Commit()
}
// upsertRunnerInTx writes (or replaces) a 'runner' participant row for
// (stepID, taskID) inside the provided transaction. Mirrors
// workflow.Repository.SetTaskRunner but reuses the caller's tx.
func upsertRunnerInTx(ctx context.Context, tx *sql.Tx, stepID, taskID, agentProfileID string) error {
if stepID == "" || taskID == "" || agentProfileID == "" {
return nil
}
var existing string
err := tx.QueryRowContext(ctx, `SELECT id FROM workflow_step_participants
WHERE step_id = ? AND task_id = ? AND role = 'runner' LIMIT 1`,
stepID, taskID).Scan(&existing)
if err == nil {
_, uerr := tx.ExecContext(ctx,
`UPDATE workflow_step_participants SET agent_profile_id = ? WHERE id = ?`,
agentProfileID, existing)
return uerr
}
if err != sql.ErrNoRows {
return err
}
id := uuid.New().String()
_, ierr := tx.ExecContext(ctx, `INSERT INTO workflow_step_participants
(id, step_id, task_id, role, agent_profile_id, decision_required, position)
VALUES (?, ?, ?, 'runner', ?, 0, 0)`,
id, stepID, taskID, agentProfileID)
return ierr
}
// clearRunnerInTx removes any 'runner' participant row for (stepID, taskID).
func clearRunnerInTx(ctx context.Context, tx *sql.Tx, stepID, taskID string) error {
if stepID == "" || taskID == "" {
return nil
}
_, err := tx.ExecContext(ctx,
`DELETE FROM workflow_step_participants
WHERE step_id = ? AND task_id = ? AND role = 'runner'`,
stepID, taskID)
return err
}
// syncRunnerInTx upserts the runner participant when agentProfileID is set,
// otherwise clears it. No-op when stepID is empty.
func syncRunnerInTx(ctx context.Context, tx *sql.Tx, stepID, taskID, agentProfileID string) error {
if stepID == "" {
return nil
}
if agentProfileID != "" {
return upsertRunnerInTx(ctx, tx, stepID, taskID, agentProfileID)
}
return clearRunnerInTx(ctx, tx, stepID, taskID)
}
// GetTask retrieves a task by ID
func (r *Repository) GetTask(ctx context.Context, id string) (*models.Task, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(
`SELECT `+taskSelectColumns("t")+` FROM tasks t WHERE t.id = ?`), id)
task, err := r.scanSingleTask(row)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: %s", ErrTaskNotFound, id)
}
return task, err
}
// UpdateTask updates an existing task. The runner write lands as an
// upsert/clear on workflow_step_participants inside the same tx as the
// task UPDATE.
func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error {
task.UpdatedAt = time.Now().UTC()
metadata, err := json.Marshal(task.Metadata)
if err != nil {
metadata = []byte("{}")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, r.db.Rebind(`
UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ?
WHERE id = ?
`), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID)
}
if err := syncRunnerInTx(ctx, tx, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil {
return err
}
return tx.Commit()
}
// DeleteTask deletes a task by ID
func (r *Repository) DeleteTask(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM tasks WHERE id = ?`), id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("%w: %s", ErrTaskNotFound, id)
}
return nil
}
// ListTasks returns all non-archived, non-ephemeral tasks for a workflow
func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) {
ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTasks")
defer span.End()
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+taskSelectColumns("t")+`
FROM tasks t
WHERE t.workflow_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0
ORDER BY t.created_at ASC
`), workflowID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// CountTasksByWorkflow returns the number of non-archived, non-ephemeral tasks in a workflow
func (r *Repository) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) {
var count int
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`SELECT COUNT(*) FROM tasks WHERE workflow_id = ? AND archived_at IS NULL AND is_ephemeral = 0`), workflowID).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
// CountTasksByWorkflowStep returns the number of non-archived, non-ephemeral tasks in a workflow step
func (r *Repository) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) {
var count int
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ? AND archived_at IS NULL AND is_ephemeral = 0`), stepID).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
// ListChildren returns non-archived, non-ephemeral children of parentID.
// Returns an empty list when parentID is empty (so root tasks resolve to
// "no children" cleanly).
func (r *Repository) ListChildren(ctx context.Context, parentID string) ([]*models.Task, error) {
if parentID == "" {
return []*models.Task{}, nil
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+taskSelectColumns("t")+`
FROM tasks t
WHERE t.parent_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0
ORDER BY t.created_at ASC, t.id ASC
`), parentID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// ListChildCompletionRows returns active direct children with the compact
// fields needed for on_children_completed readiness and operation idempotency.
func (r *Repository) ListChildCompletionRows(ctx context.Context, parentID string) ([]models.ChildCompletionRow, error) {
if parentID == "" {
return []models.ChildCompletionRow{}, nil
}
var rows []models.ChildCompletionRow
err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(`
SELECT id, state, title, updated_at
FROM tasks
WHERE parent_id = ? AND archived_at IS NULL AND is_ephemeral = 0
ORDER BY created_at ASC, id ASC
`), parentID)
if err != nil {
return nil, err
}
return rows, nil
}
// ListChildrenIncludingArchived returns every child task of parentID
// regardless of archived state. Used by the office task-handoffs
// unarchive cascade (phase 6) to walk a previously-archived descendant
// subtree.
func (r *Repository) ListChildrenIncludingArchived(ctx context.Context, parentID string) ([]*models.Task, error) {
if parentID == "" {
return []*models.Task{}, nil
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+taskSelectColumns("t")+`
FROM tasks t
WHERE t.parent_id = ? AND t.is_ephemeral = 0
ORDER BY t.created_at ASC, t.id ASC
`), parentID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// ReparentDirectChildren swaps the parent_id of every row matching
// oldParentID (archived or not) to newParentID. Used by no-cascade
// delete so the soon-to-be-orphaned direct children become roots
// instead of pointing at a row that's about to vanish.
func (r *Repository) ReparentDirectChildren(ctx context.Context, oldParentID, newParentID string) error {
if oldParentID == "" {
return nil
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE tasks SET parent_id = ?, updated_at = ?
WHERE parent_id = ?
`), newParentID, time.Now().UTC(), oldParentID)
return err
}
// ListSiblings returns non-archived, non-ephemeral sibling tasks. A task
// is a sibling when parent_id matches AND the parent_id is non-empty AND
// the workspace matches. Root tasks (empty parent_id) deliberately return
// an empty list so unrelated workspace roots don't surface as siblings.
func (r *Repository) ListSiblings(ctx context.Context, taskID string) ([]*models.Task, error) {
self, err := r.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
if self == nil || self.ParentID == "" {
return []*models.Task{}, nil
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+taskSelectColumns("t")+`
FROM tasks t
WHERE t.parent_id = ?
AND t.workspace_id = ?
AND t.id != ?
AND t.archived_at IS NULL
AND t.is_ephemeral = 0
ORDER BY t.created_at ASC, t.id ASC
`), self.ParentID, self.WorkspaceID, self.ID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// ListTasksByWorkflowStep returns all non-archived, non-ephemeral tasks in a workflow step
func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+taskSelectColumns("t")+`
FROM tasks t
WHERE t.workflow_step_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0 ORDER BY t.created_at ASC
`), workflowStepID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// ListTasksByWorkspace returns paginated tasks for a workspace with total count
// If query is non-empty, filters by task title, description, repository name, or repository path
// If includeArchived is false, archived tasks are excluded
// If includeEphemeral is false, ephemeral tasks are excluded
// If onlyEphemeral is true, only ephemeral tasks are returned
func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) {
ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTasksByWorkspace")
defer span.End()
// Calculate offset
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
sort = usermodels.NormalizeTasksListSort(sort)
// Build filter conditions
filter := ""
if onlyEphemeral {
// Only ephemeral tasks
filter += " AND is_ephemeral = 1"
} else if !includeEphemeral {
// Exclude ephemeral tasks
filter += " AND is_ephemeral = 0"
}
// If includeEphemeral is true and onlyEphemeral is false, include both
if !includeArchived {
filter += " AND archived_at IS NULL"
}
if excludeConfig {
filter += " AND " + excludeConfigModePredicate(r.ro.DriverName(), "metadata")
}
var rows *sql.Rows
var total int
var err error
if query == "" {
rows, total, err = r.queryAllTasks(ctx, workspaceID, filter, workflowID, repositoryID, pageSize, offset, sort)
} else {
rows, total, err = r.searchTasks(ctx, workspaceID, query, filter, workflowID, repositoryID, pageSize, offset, sort, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig)
}
if err != nil {
return nil, 0, err
}
defer func() { _ = rows.Close() }()
tasks, err := r.scanTasks(rows)
if err != nil {
return nil, 0, err
}
return tasks, total, nil
}
// queryAllTasks fetches all tasks (no search) for a workspace with pagination.
func (r *Repository) queryAllTasks(ctx context.Context, workspaceID, taskFilter, workflowID, repositoryID string, pageSize, offset int, sort string) (*sql.Rows, int, error) {
args := []interface{}{workspaceID}
if workflowID != "" {
taskFilter += " AND workflow_id = ?"
args = append(args, workflowID)
}
if repositoryID != "" {
taskFilter += " AND id IN (SELECT task_id FROM task_repositories WHERE repository_id = ?)"
args = append(args, repositoryID)
}
var total int
if err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`+taskFilter), args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+taskSelectColumns("t")+`
FROM tasks t
WHERE t.workspace_id = ?`+rewriteFilterForAlias(taskFilter, "t")+`
ORDER BY `+taskListOrderBy(r.ro.DriverName(), "t", sort)+`
LIMIT ? OFFSET ?
`), append(append([]interface{}{}, args...), pageSize, offset)...)
return rows, total, err
}
func taskListOrderBy(driver, alias, sort string) string {
prefix := alias + "."
switch sort {
case usermodels.TasksListSortUpdatedAsc:
return prefix + "updated_at ASC, " + taskTitleOrder(driver, prefix, "ASC") + ", " + prefix + "id ASC"
case usermodels.TasksListSortCreatedDesc:
return prefix + "created_at DESC, " + taskTitleOrder(driver, prefix, "ASC") + ", " + prefix + "id ASC"
case usermodels.TasksListSortCreatedAsc:
return prefix + "created_at ASC, " + taskTitleOrder(driver, prefix, "ASC") + ", " + prefix + "id ASC"
case usermodels.TasksListSortTitleAsc:
return taskTitleOrder(driver, prefix, "ASC") + ", " + prefix + "updated_at DESC, " + prefix + "id ASC"
case usermodels.TasksListSortTitleDesc:
return taskTitleOrder(driver, prefix, "DESC") + ", " + prefix + "updated_at DESC, " + prefix + "id ASC"
case usermodels.TasksListSortUpdatedDesc:
fallthrough
default:
return prefix + "updated_at DESC, " + taskTitleOrder(driver, prefix, "ASC") + ", " + prefix + "id ASC"
}
}
func taskTitleOrder(driver, prefix, direction string) string {
if dialect.IsPostgres(driver) {
return "LOWER(" + prefix + "title) " + direction + ", " + prefix + "title " + direction
}
return prefix + "title COLLATE NOCASE " + direction
}
// rewriteFilterForAlias prefixes bare column references in `filter` with
// the given alias. Only used for the small, controlled fragments built
// in queryAllTasks and searchTasks.
func rewriteFilterForAlias(filter, alias string) string {
if alias == "" {
return filter
}
out := filter
for _, col := range []string{"is_ephemeral", "archived_at", "metadata", "workflow_id"} {
// Replace " <col>" only when not already prefixed by `alias.`.
out = simplePrefixCol(out, col, alias)
}
return out
}
// simplePrefixCol replaces standalone occurrences of column names in
// SQL fragments with their aliased form. Naïve string substitution but
// adequate for the filters built locally above.
func simplePrefixCol(s, col, alias string) string {
if s == "" {
return s
}
prefix := alias + "."
out := ""
i := 0
for i < len(s) {
idx := indexFrom(s, col, i)
if idx == -1 {
out += s[i:]
break
}
// Boundary check: char before must not be alphanumeric or '.'.
if idx > 0 {
c := s[idx-1]
if c == '.' || isWordByte(c) {
out += s[i : idx+len(col)]
i = idx + len(col)
continue
}
}
// Boundary after: char after must not be alphanumeric.
end := idx + len(col)
if end < len(s) && isWordByte(s[end]) {
out += s[i : idx+len(col)]
i = idx + len(col)
continue
}
out += s[i:idx] + prefix + col
i = end
}
return out
}
func indexFrom(s, sub string, from int) int {
for i := from; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
func isWordByte(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
// searchTasks fetches tasks matching a search query for a workspace with pagination.
func (r *Repository) searchTasks(ctx context.Context, workspaceID, query, filter, workflowID, repositoryID string, pageSize, offset int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) (*sql.Rows, int, error) {
searchPattern := "%" + query + "%"
like := dialect.Like(r.ro.DriverName())
// Build task filter
tFilter := ""
if onlyEphemeral {
tFilter += " AND t.is_ephemeral = 1"
} else if !includeEphemeral {
tFilter += " AND t.is_ephemeral = 0"
}
if !includeArchived {
tFilter += " AND t.archived_at IS NULL"
}
if excludeConfig {
tFilter += " AND " + excludeConfigModePredicate(r.ro.DriverName(), "t.metadata")
}
// Collect extra filter args in query-argument order
var extraArgs []interface{}
if workflowID != "" {
tFilter += " AND t.workflow_id = ?"
extraArgs = append(extraArgs, workflowID)
}
if repositoryID != "" {
tFilter += " AND tr.repository_id = ?"
extraArgs = append(extraArgs, repositoryID)
}
countQuery := fmt.Sprintf(`
SELECT COUNT(DISTINCT t.id) FROM tasks t
LEFT JOIN task_repositories tr ON t.id = tr.task_id
LEFT JOIN repositories r ON tr.repository_id = r.id
WHERE t.workspace_id = ?%s
AND (
t.title %s ? OR
t.description %s ? OR
r.name %s ? OR
r.local_path %s ?
)
`, tFilter, like, like, like, like)
countArgs := append(append([]interface{}{workspaceID}, extraArgs...), searchPattern, searchPattern, searchPattern, searchPattern)
var total int
if err := r.ro.QueryRowContext(ctx, r.ro.Rebind(countQuery), countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
selectQuery := taskSearchSelectQuery(r.ro.DriverName(), tFilter, like, sort)
selectArgs := append(append([]interface{}{}, countArgs...), pageSize, offset)
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(selectQuery), selectArgs...)
return rows, total, err
}
func taskSearchSelectQuery(driver, tFilter, like, sort string) string {
return fmt.Sprintf(`
SELECT %s
FROM (
SELECT DISTINCT %s
FROM tasks t
LEFT JOIN task_repositories tr ON t.id = tr.task_id
LEFT JOIN repositories r ON tr.repository_id = r.id
WHERE t.workspace_id = ?%s
AND (
t.title %s ? OR
t.description %s ? OR
r.name %s ? OR
r.local_path %s ?
)
) task_search
ORDER BY %s
LIMIT ? OFFSET ?
`, taskProjectedColumns("task_search"), taskSelectColumns("t"), tFilter, like, like, like, like, taskListOrderBy(driver, "task_search", sort))
}
// scanSingleTask scans a single row into a Task.
func (r *Repository) scanSingleTask(row *sql.Row) (*models.Task, error) {
task := &models.Task{}
var metadata string
var archivedAt sql.NullTime
var identifier sql.NullString
err := row.Scan(
&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID,
&task.Title, &task.Description, &task.State, &task.Priority, &task.Position,
&metadata, &task.IsEphemeral, &task.ParentID, &archivedAt,
&task.CreatedAt, &task.UpdatedAt,
&task.AssigneeAgentProfileID, &task.Origin, &task.ProjectID,
&task.Labels, &identifier, &task.IsFromOffice,
)
if err != nil {
return nil, err
}
if archivedAt.Valid {
task.ArchivedAt = &archivedAt.Time
}
if identifier.Valid {
task.Identifier = identifier.String
}
_ = json.Unmarshal([]byte(metadata), &task.Metadata)
return task, nil
}
// scanTasks is a helper to scan task rows
func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) {
var result []*models.Task
for rows.Next() {
task := &models.Task{}
var metadata string
var archivedAt sql.NullTime
var identifier sql.NullString
err := rows.Scan(
&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID,
&task.Title, &task.Description, &task.State, &task.Priority, &task.Position,
&metadata, &task.IsEphemeral, &task.ParentID, &archivedAt,
&task.CreatedAt, &task.UpdatedAt,
&task.AssigneeAgentProfileID, &task.Origin, &task.ProjectID,
&task.Labels, &identifier, &task.IsFromOffice,
)
if err != nil {
return nil, err
}
if archivedAt.Valid {
task.ArchivedAt = &archivedAt.Time
}
if identifier.Valid {
task.Identifier = identifier.String
}
_ = json.Unmarshal([]byte(metadata), &task.Metadata)
result = append(result, task)
}
return result, rows.Err()
}
// GetTasksByIDs fetches multiple tasks in a single query. Missing IDs are
// silently omitted; result order is not guaranteed, so callers that need a
// specific order should reorder by ID themselves.
func (r *Repository) GetTasksByIDs(ctx context.Context, ids []string) ([]*models.Task, error) {
if len(ids) == 0 {
return nil, nil
}
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(`SELECT %s FROM tasks t WHERE t.id IN (%s)`,
taskSelectColumns("t"), strings.Join(placeholders, ","))
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// ArchiveTask sets the archived_at timestamp on a task
func (r *Repository) ArchiveTask(ctx context.Context, id string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`UPDATE tasks SET archived_at = ?, updated_at = ? WHERE id = ?`), now, now, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("%w: %s", ErrTaskNotFound, id)
}
return nil
}
// ArchiveTaskIfActive is the CAS variant used by office task-handoffs
// cascade archives. The update only fires when the task is currently
// active (archived_at IS NULL); this lets the cascade walk all
// descendants and archive only the ones not already archived by an
// earlier (manual or cascade) archive. Returns whether the row was
// actually updated.
//
// cascadeID is stamped on the archived row so UnarchiveTaskByCascade
// can scope its restoration to exactly the tasks this cascade owned.
// Pass empty cascadeID to opt out of cascade tracking (single-task
// manual archive); the column will simply not get set.
func (r *Repository) ArchiveTaskIfActive(ctx context.Context, id, cascadeID string) (bool, error) {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE tasks SET archived_at = ?, archived_by_cascade_id = ?, updated_at = ?
WHERE id = ? AND archived_at IS NULL
`), now, cascadeID, now, id)
if err != nil {
return false, err
}
rows, _ := result.RowsAffected()
return rows > 0, nil
}
// UnarchiveTaskByCascade clears archived_at + archived_by_cascade_id only
// when the row was archived by the named cascade. Manually-archived tasks
// (empty cascade id) and tasks owned by a different cascade are left
// untouched — fixing the resurrection bug where unarchiving a parent
// would also un-archive descendants the user had archived manually
// before the cascade ran.
func (r *Repository) UnarchiveTaskByCascade(ctx context.Context, id, cascadeID string) (bool, error) {
if cascadeID == "" {
return false, fmt.Errorf("cascadeID is required")
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE tasks SET archived_at = NULL, archived_by_cascade_id = '', updated_at = ?
WHERE id = ? AND archived_by_cascade_id = ?
`), time.Now().UTC(), id, cascadeID)
if err != nil {
return false, err
}
rows, _ := result.RowsAffected()
return rows > 0, nil
}
// ListTasksForAutoArchive returns tasks eligible for auto-archiving based on workflow step settings
func (r *Repository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) {
drv := r.ro.DriverName()
query := fmt.Sprintf(`
SELECT %s
FROM tasks t
JOIN workflow_steps ws ON ws.id = t.workflow_step_id
WHERE ws.auto_archive_after_hours > 0
AND t.archived_at IS NULL
AND t.updated_at <= %s
`, taskSelectColumns("t"), dialect.NowMinusHours(drv, "ws.auto_archive_after_hours"))
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query))
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// ListExpiredQuickChatTasks returns quick-chat tasks whose last task/session
// activity is older than cutoff. Active sessions are excluded so in-use chats
// are never deleted by the idle sweeper.
func (r *Repository) ListExpiredQuickChatTasks(ctx context.Context, cutoff time.Time) ([]*models.Task, error) {
drv := r.ro.DriverName()
sessionActivity := "COALESCE(MAX(ts.updated_at), t.updated_at)"
lastActivity := dialect.GreatestTimestamp(drv, "t.updated_at", sessionActivity)
query := fmt.Sprintf(`
WITH candidates AS (
SELECT t.id, %s AS last_activity
FROM tasks t
LEFT JOIN task_sessions ts ON ts.task_id = t.id
WHERE t.is_ephemeral = 1
AND COALESCE(t.workflow_id, '') = ''
AND COALESCE(t.origin, '') != ?
AND %s
AND t.archived_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM task_sessions active
WHERE active.task_id = t.id
AND active.state IN (?, ?)
)
GROUP BY t.id, t.updated_at
HAVING %s < ?
)
SELECT %s
FROM tasks t
JOIN candidates c ON c.id = t.id
ORDER BY c.last_activity ASC
`,
lastActivity,
excludeConfigModePredicate(drv, "t.metadata"),
lastActivity,
taskSelectColumns("t"),
)
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query),
models.TaskOriginAutomationRun,
models.TaskSessionStateRunning,
models.TaskSessionStateIdle,
cutoff,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// DeleteExpiredQuickChatTask deletes id only when it still matches the expired
// quick-chat predicate at delete time.
func (r *Repository) DeleteExpiredQuickChatTask(ctx context.Context, id string, cutoff time.Time) (bool, error) {
drv := r.db.DriverName()
sessionActivity := "COALESCE(MAX(ts.updated_at), t.updated_at)"
lastActivity := dialect.GreatestTimestamp(drv, "t.updated_at", sessionActivity)
query := fmt.Sprintf(`
WITH candidate AS (
SELECT t.id, %s AS last_activity
FROM tasks t
LEFT JOIN task_sessions ts ON ts.task_id = t.id
WHERE t.id = ?
AND t.is_ephemeral = 1
AND COALESCE(t.workflow_id, '') = ''
AND COALESCE(t.origin, '') != ?
AND %s
AND t.archived_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM task_sessions active
WHERE active.task_id = t.id
AND active.state IN (?, ?)
)
GROUP BY t.id, t.updated_at
HAVING %s < ?
)
DELETE FROM tasks
WHERE id = ?
AND EXISTS (SELECT 1 FROM candidate)
`,
lastActivity,
excludeConfigModePredicate(drv, "t.metadata"),
lastActivity,
)
result, err := r.db.ExecContext(ctx, r.db.Rebind(query),
id,
models.TaskOriginAutomationRun,
models.TaskSessionStateRunning,
models.TaskSessionStateIdle,
cutoff,
id,
)
if err != nil {
return false, err
}
rows, _ := result.RowsAffected()
return rows > 0, nil
}
// isSafeMetadataKey reports whether s is a safe JSON metadata key to splice
// into a json_extract path. The key is concatenated into the SQL text (it
// cannot be a bind parameter inside the '$.<key>' path literal), so it must be
// constrained to a fixed identifier alphabet to keep the query injection-safe.
// Callers pass compile-time constants today (WatcherSource.WatchMetadataKey),
// but validating here keeps the repository safe regardless of caller.
func isSafeMetadataKey(s string) bool {
if s == "" {
return false
}
for _, c := range s {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_':
default:
return false
}
}
return true
}
// CountOpenWatcherCreatedTasks returns the number of open watcher-created tasks
// for a single watch, identified by the task-metadata key the integration
// writes (metadataKey, e.g. "sentry_issue_watch_id") and the watch id. "Open"
// means non-archived AND not in a terminal state (COMPLETED, FAILED,
// CANCELLED). Distinct integrations use distinct metadata keys, so counts are
// naturally scoped per integration without this repository knowing which
// integrations exist — the caller supplies the key.
//
// An empty watchID returns (0, nil) — no watch to count. A malformed
// metadataKey (not a bare [A-Za-z0-9_] identifier) returns an error rather
// than silently counting nothing, so a wiring bug surfaces in the logs (the
// throttle gate fails open on the error).
func (r *Repository) CountOpenWatcherCreatedTasks(ctx context.Context, metadataKey, watchID string) (int, error) {
if watchID == "" {
return 0, nil
}
if !isSafeMetadataKey(metadataKey) {
return 0, fmt.Errorf("invalid watcher metadata key %q", metadataKey)
}
query := r.ro.Rebind(fmt.Sprintf(`
SELECT COUNT(*) FROM tasks
WHERE archived_at IS NULL
AND state NOT IN (?, ?, ?)
AND %s = ?
`, dialect.JSONExtract(r.ro.DriverName(), "metadata", metadataKey)))
var n int
if err := r.ro.QueryRowxContext(ctx, query,
v1.TaskStateCompleted, v1.TaskStateFailed, v1.TaskStateCancelled, watchID,
).Scan(&n); err != nil {
return 0, err
}
return n, nil
}
// UpdateTaskState updates the state of a task
func (r *Repository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`), state, time.Now().UTC(), id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("%w: %s", ErrTaskNotFound, id)
}
return nil
}
// UpdateTaskStateIfCurrentIn transitions state inside a transaction, re-checking
// the current state on write so concurrent handlers cannot clobber a task that
// moved out of allowed between read and update.
func (r *Repository) UpdateTaskStateIfCurrentIn(
ctx context.Context, id string, state v1.TaskState, allowed []v1.TaskState,
) (v1.TaskState, bool, error) {
if len(allowed) == 0 {
return "", false, nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return "", false, err
}
defer func() { _ = tx.Rollback() }()
var currentState v1.TaskState
err = tx.QueryRowContext(ctx, r.db.Rebind(`SELECT state FROM tasks WHERE id = ?`), id).Scan(&currentState)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", false, fmt.Errorf("%w: %s", ErrTaskNotFound, id)
}
return "", false, err
}
if !taskStateInSet(currentState, allowed) {
return currentState, false, nil
}
result, err := tx.ExecContext(ctx, r.db.Rebind(`
UPDATE tasks SET state = ?, updated_at = ?
WHERE id = ? AND state = ?
`), state, time.Now().UTC(), id, currentState)
if err != nil {
return "", false, err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return currentState, false, nil
}
if err := tx.Commit(); err != nil {
return "", false, err
}
return currentState, true, nil
}
func taskStateInSet(state v1.TaskState, allowed []v1.TaskState) bool {
for _, candidate := range allowed {
if state == candidate {
return true
}
}
return false
}
// ListTasksByProject returns all non-archived, non-ephemeral tasks for a project.
func (r *Repository) ListTasksByProject(ctx context.Context, projectID string) ([]*models.Task, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+taskSelectColumns("t")+`
FROM tasks t
WHERE t.project_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0
ORDER BY t.created_at ASC
`), projectID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// ListTasksByAssignee returns all non-archived, non-ephemeral tasks assigned to an agent.
// The lookup goes through the runner participant projection so it picks
// up both per-task overrides and step-primary fallbacks (ADR 0005 Wave F).
func (r *Repository) ListTasksByAssignee(ctx context.Context, agentInstanceID string) ([]*models.Task, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+taskSelectColumns("t")+`
FROM tasks t
WHERE `+runnerProjection("t")+` = ?
AND t.archived_at IS NULL AND t.is_ephemeral = 0
ORDER BY t.created_at ASC
`), agentInstanceID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// ListTaskTree returns a flat list of non-archived tasks for a workspace, suitable for
// building a tree using each task's ParentID field.
func (r *Repository) ListTaskTree(ctx context.Context, workspaceID string, filters models.TaskTreeFilters) ([]*models.Task, error) {
query := `SELECT ` + taskSelectColumns("t") + ` FROM tasks t WHERE t.workspace_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0`
args := []interface{}{workspaceID}
if filters.ProjectID != "" {
query += " AND t.project_id = ?"
args = append(args, filters.ProjectID)
}
if filters.AssigneeID != "" {
query += " AND " + runnerProjection("t") + " = ?"
args = append(args, filters.AssigneeID)
}
if filters.WorkflowID != "" {
query += " AND t.workflow_id = ?"
args = append(args, filters.WorkflowID)
}
if filters.Origin != "" {
query += " AND t.origin = ?"
args = append(args, filters.Origin)
}
query += " ORDER BY t.created_at ASC"
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
// IncrementTaskSequence atomically increments the workspace task_sequence and returns the new value.
func (r *Repository) IncrementTaskSequence(ctx context.Context, workspaceID string) (int, error) {
var seq int
err := r.db.QueryRowContext(ctx, r.db.Rebind(`
UPDATE workspaces SET task_sequence = task_sequence + 1
WHERE id = ?
RETURNING task_sequence
`), workspaceID).Scan(&seq)
if err != nil {
return 0, fmt.Errorf("increment task sequence for workspace %s: %w", workspaceID, err)
}
return seq, nil
}
// GetWorkspaceTaskPrefix returns the task prefix and office workflow ID for a workspace.
func (r *Repository) GetWorkspaceTaskPrefix(ctx context.Context, workspaceID string) (prefix, officeWorkflowID string, err error) {
err = r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT COALESCE(task_prefix, 'KAN'), COALESCE(office_workflow_id, '')
FROM workspaces WHERE id = ?
`), workspaceID).Scan(&prefix, &officeWorkflowID)
return
}