Files
kandev/apps/backend/internal/task/repository/sqlite/task.go
T
João Salavisa dcfec0d23c refactor(backend): classify errors via sentinels instead of substring matches (#1057)
* refactor(backend): classify errors via sentinels instead of substring matches

Introduce package-local sentinel errors in the github, task repo, agent
lifecycle, and office/skills packages. Producers wrap with fmt.Errorf("%w: ...")
so callers can classify via errors.Is/errors.As instead of brittle
strings.Contains(err.Error(), ...) checks that break on library upgrades.

Sentinels added (or relocated to errors.go):
- github: ErrInvalidToken (new); ErrInvalidPRURL, ErrTaskNotFound, ErrSelfApprove
  moved from service.go.
- task/repository/sqlite: ErrTaskNotFound, ErrOfficeSessionRaceConflict.
- lifecycle: ErrAgentAlreadyRunning, ErrAgentReported.
- office/skills: ErrSkillNotFound, ErrSkillFileNotFound.

Consumers updated to errors.Is/errors.As across orchestrator, task/handlers,
github, and office/skills. AssociateExistingPRByURL switches %v -> %w so the
inner parsePRURL error stays in the chain. process_handlers now uses
errors.Is(err, syscall.ECONNREFUSED) and lifecycle.ErrNoExecutionForSession
plus a small isTimeoutError helper backed by errors.As.

A new taskDeleterAdapter in cmd/kandev/turn_adapters.go translates
taskrepo.ErrTaskNotFound to github.ErrTaskNotFound at the wiring boundary,
avoiding a layering violation between the github and task packages.

The one remaining substring check, in CreateTaskSession, is encapsulated
inside the repo and converts the SQLite UNIQUE-violation message into the
typed ErrOfficeSessionRaceConflict sentinel so callers never see the driver
string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend): add TestErrorsAreClassifiable for refactored packages

Pin the new sentinel-based contract for each refactored package. Each test
exercises the producer paths and asserts errors.Is reaches the sentinel
through the wrap chain, and (where applicable) confirms untyped lookalike
strings no longer classify.

- github: isTaskNotFound, AssociateExistingPRByURL/ErrInvalidPRURL,
  ConfigureToken/ErrInvalidToken (using a noop secret manager).
- task/repository/sqlite: GetTask, DeleteTask, UpdateTaskState wrap
  ErrTaskNotFound.
- task/handlers: isNotFound + isAgentReportedError follow the typed chain.
- orchestrator: each isXError classifies exact + wrapped sentinels, rejects
  untyped lookalikes.
- office/skills: readUserHomeSkillInventoryFile wraps ErrSkillFileNotFound;
  ErrSkillNotFound survives fmt.Errorf("%w", ...).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(github): drop network-hitting subtest from errors_test

The "ConfigureToken wraps ErrInvalidToken when validation fails" subtest
constructed a real PATClient and called GetAuthenticatedUser, which makes
a live HTTP request to api.github.com. That's flaky in CI / offline dev
and violates the unit-test boundary (caught in review).

Replace with a sentinel-survives-wrap-pattern check that locks in the
exact %w/%w shape used by ConfigureToken, plus an assertion that the
inner cause stays reachable through errors.Is. The full ConfigureToken
flow stays covered by service_token_test.go and integration tests.

Remove the now-unused noopSecretManager stub type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): restore "timed out" UX for plain-fmt.Errorf timeout producers

The original createPromptErrorMessage rendered the friendly "Request timed
out…" hint via strings.Contains(err.Error(), "timeout"). The refactor
replaced it with errors.Is(context.DeadlineExceeded) + a net.Error
Timeout() helper — strictly typed. But several upstream producers along
the prompt path (service.WaitForSessionReady, agent-stream connect waits
in lifecycle/session.go and manager_interaction.go, agentctl health waits
in agentctl/client.go and launcher.go, the orchestrator's own
waitForSessionReady) return plain fmt.Errorf("timeout …") with no typed
Timeout() method, so the typed check missed them and the user saw the
generic "Failed to send message to agent" instead.

Add a substring fallback inside isTimeoutError as a UX safety net while
keeping the primary typed paths intact. Lock the contract with table
tests covering all three classes (net.Error timeout, plain string,
context.DeadlineExceeded) so any future trim of the fallback breaks
loudly. Migrating each upstream producer to a typed sentinel is tracked
as out-of-scope follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend): tighten classification tests to exercise typed-sentinel contracts

Addresses bot-review feedback on PR #1057:

- task/handlers/errors_test.go: cover all four not-found sentinels
  (taskrepo.ErrTaskNotFound, service.ErrDocumentNotFound,
  service.ErrTaskPlanNotFound, service.ErrRevisionNotFound) in
  TestErrorsAreClassifiable via a table-driven loop, instead of only
  one. Drop the spurious Deadline() *time.Time method on timeoutNetErr
  — it isn't part of net.Error and the existing `var _ net.Error =
  timeoutNetErr{}` line already pins the interface.

- orchestrator/scheduler/scheduler_test.go: testTaskRepository was
  still returning the local scheduler.ErrTaskNotFound, which doesn't
  satisfy the production processTasks check (now errors.Is on
  taskrepo.ErrTaskNotFound). Mirror the real sqlite repo's wrap so the
  mock and prod paths agree. The local scheduler.ErrTaskNotFound is
  truly dead after that, so drop it from scheduler.go.

- office/skills: the wrap-pattern subtest in errors_test.go was only
  testing fmt.Errorf, not production code. Move the contract assertion
  to service_test.go (external skills_test package) where it drives
  the real GetSkillFromConfig lookup path against an empty in-memory
  repository.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(backend): drop redundant context.DeadlineExceeded check and fix doc grammar

Addresses bot-review feedback on PR #1057:

- task/handlers/message_handlers.go::createPromptErrorMessage: the
  explicit errors.Is(promptErr, context.DeadlineExceeded) is redundant
  with isTimeoutError, which already matches via errors.As against
  Timeout() — context.DeadlineExceeded implements Timeout()==true.
  Inline comment documents the consolidation.

- github/errors.go: fix grammar in the ErrTaskNotFound doc comment
  ("is the sentinel cleanup paths check for to distinguish" →
  "is the sentinel that cleanup paths check to distinguish").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:07:46 +01:00

834 lines
28 KiB
Go

package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"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"
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"
// 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
}
prefix := alias + "."
return prefix + "id, " + prefix + "workspace_id, " + prefix + "workflow_id, " + prefix + "workflow_step_id, " +
prefix + "title, " + prefix + "description, " + prefix + "state, " + prefix + "priority, " + prefix + "position, " +
prefix + "metadata, " + prefix + "is_ephemeral, " + prefix + "parent_id, " + prefix + "archived_at, " +
prefix + "created_at, " + prefix + "updated_at, " +
runnerProjection(alias) + ` AS assignee_agent_profile_id, ` +
prefix + "origin, " + prefix + "project_id, " + prefix + "labels, " + prefix + "identifier, " +
isFromOfficeProjection(alias) + ` AS is_from_office`
}
// 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
)
)`
}
// 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)
}
// 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, 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
}
// 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 (metadata IS NULL OR json_extract(metadata, '$.config_mode') IS NOT 1)"
}
var rows *sql.Rows
var total int
var err error
if query == "" {
rows, total, err = r.queryAllTasks(ctx, workspaceID, filter, workflowID, repositoryID, pageSize, offset)
} else {
rows, total, err = r.searchTasks(ctx, workspaceID, query, filter, workflowID, repositoryID, pageSize, offset, 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) (*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 t.updated_at DESC
LIMIT ? OFFSET ?
`), append(append([]interface{}{}, args...), pageSize, offset)...)
return rows, total, err
}
// 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, 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 (t.metadata IS NULL OR json_extract(t.metadata, '$.config_mode') IS NOT 1)"
}
// 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 := fmt.Sprintf(`
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 ?
)
ORDER BY t.updated_at DESC
LIMIT ? OFFSET ?
`, taskSelectColumns("t"), tFilter, like, like, like, like)
selectArgs := append(append([]interface{}{}, countArgs...), pageSize, offset)
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(selectQuery), selectArgs...)
return rows, total, err
}
// 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()
}
// 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)
}
// 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
}
// 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
}