0df2bbac0a
* docs(plugins): draft Host data API proto (read + write DTOs) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plugins): ADR + spec + plan for the Host data API Plugins access kandev data via capability-gated Host gRPC RPCs returning hand-mapped DTOs, never the DB directly. ADR 0042. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): add Host data API RPCs and DTOs to plugin proto Extends the existing service Host (per ADR 0042) with read RPCs (ListTasks/GetTask, ListWorkspaces, ListWorkflows, ListWorkflowSteps, ListAgentProfiles, ListRepositories, ListSessions, ListSessionCodeStats) and declares the deferred phase-2 write RPCs (CreateTask, UpdateTask, CreateComment) so the contract is stable for later tasks. Regenerates plugin.pb.go/plugin_grpc.pb.go via `make -C apps/backend proto`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): Host data API SDK accessors + session LOC aggregation Wave 1-2 of ADR 0042: pkg/pluginsdk gains typed reader accessors (host.Tasks(), host.Sessions().CodeStats(), host.Workflows(), ...) over the extended Host gRPC service, with proto<->native DTO conversions and an UnimplementedHostData seam for the kandev-side impl. internal/analytics gains the per-session LOC aggregation those readers surface. Backend handlers + capability gating land in the next wave. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): capability-gated Host data read handlers (ADR 0042) Implement pluginHost Tasks/Sessions/Workspaces/Workflows/AgentProfiles/ Repositories readers: each gated on api_read:<resource> (PermissionDenied otherwise), backed by the service layer via narrow injected interfaces, and hand-mapped to the pluginsdk DTOs. Sessions().CodeStats routes through the analytics aggregation; acp_session_id preserves the metadata->resume_token fallback. Wired through plugins.Service.SetDataSources + backendapp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(plugins): wire test for Host data reads + docs (ADR 0042 accepted) End-to-end test over the real go-plugin transport: session data round-trips, per-resource api_read gating returns PermissionDenied over the wire, cursor pagination. Document the Host data API + SDK accessors in GRPC-CONTRACT.md, reconcile the spec to shipped code, mark ADR 0042 accepted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): full session history + snapshot profile name in data API Sessions().List now fetches tasks with includeArchived=true — an archived task's sessions are still real sessions, and CodeStats already made the same call at the SQL layer, so the two session reads were inconsistent (26 of 359 sessions visible on a real instance). Tasks().List keeps archived tasks excluded. Session gains agent_profile_name (proto field 10) sourced from the profile snapshot, so sessions of since-uninstalled agents keep a human label — the AgentProfiles registry only lists live agents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plugins): renumber Host data API ADR 0042 -> 0043 main merged its own ADR 0042 (project shell output); renumber this one to the next free slot and update all references. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address Host data API review (perf, pagination, filters, proto) - paginate before DTO conversion so per-session ACP lookups run <=limit times - fetch all source pages (no silent 1000/workspace truncation); accurate HasMore - stable id tiebreaker on paginated sorts - AND SessionFilter.TaskIDs with WorkspaceIDs; exclude config-mode sessions from CodeStats (shared dialect.ExcludeConfigModePredicate) so List/CodeStats match - Get returns codes.NotFound consistently in-process and over the wire - wrap bare-DTO RPC responses (GetTaskResponse etc.) for Buf response uniqueness Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plugins): mark Host data API plan tasks done --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1308 lines
43 KiB
Go
1308 lines
43 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
|
|
)
|
|
)`
|
|
}
|
|
|
|
// excludeConfigModePredicate delegates to the shared dialect helper (also
|
|
// used by internal/analytics/repository/sqlite's CodeStats read, so both
|
|
// cover the same office config-mode exclusion).
|
|
func excludeConfigModePredicate(driver, col string) string {
|
|
return dialect.ExcludeConfigModePredicate(driver, col)
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// UpdateTaskIfWorkflowStepHasCapacity updates a task inside the same write
|
|
// transaction that checks a WIP-limited target step's current occupancy.
|
|
func (r *Repository) UpdateTaskIfWorkflowStepHasCapacity(ctx context.Context, task *models.Task, targetStepID, excludeTaskID string, limit int) 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() }()
|
|
|
|
var occupants int
|
|
if err := tx.QueryRowContext(ctx, r.db.Rebind(`
|
|
SELECT COUNT(*) FROM tasks
|
|
WHERE workflow_step_id = ?
|
|
AND id != ?
|
|
AND archived_at IS NULL
|
|
AND is_ephemeral = 0
|
|
`), targetStepID, excludeTaskID).Scan(&occupants); err != nil {
|
|
return err
|
|
}
|
|
if occupants >= limit {
|
|
return fmt.Errorf("WIP limit exceeded for workflow step %s: limit %d already occupied", targetStepID, limit)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// CountTasksByWorkflowStepExcludingTask returns active, visible occupants in
|
|
// a workflow step, excluding the task currently being moved.
|
|
func (r *Repository) CountTasksByWorkflowStepExcludingTask(ctx context.Context, stepID, excludeTaskID string) (int, error) {
|
|
var count int
|
|
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
|
|
SELECT COUNT(*) FROM tasks
|
|
WHERE workflow_step_id = ?
|
|
AND id != ?
|
|
AND archived_at IS NULL
|
|
AND is_ephemeral = 0
|
|
`), stepID, excludeTaskID).Scan(&count)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// NextPullCandidate returns the next active, visible task from a feeder step.
|
|
func (r *Repository) NextPullCandidate(ctx context.Context, stepID, excludeTaskID string) (*models.Task, error) {
|
|
excludeTaskIDs := []string(nil)
|
|
if excludeTaskID != "" {
|
|
excludeTaskIDs = append(excludeTaskIDs, excludeTaskID)
|
|
}
|
|
return r.NextPullCandidateExcluding(ctx, stepID, excludeTaskIDs)
|
|
}
|
|
|
|
// NextPullCandidateExcluding returns the next active, visible task from a
|
|
// feeder step, skipping any candidate IDs the caller already tried.
|
|
func (r *Repository) NextPullCandidateExcluding(ctx context.Context, stepID string, excludeTaskIDs []string) (*models.Task, error) {
|
|
args := []any{stepID}
|
|
excludeClause := ""
|
|
if len(excludeTaskIDs) > 0 {
|
|
placeholders := make([]string, 0, len(excludeTaskIDs))
|
|
for _, id := range excludeTaskIDs {
|
|
if id == "" {
|
|
continue
|
|
}
|
|
placeholders = append(placeholders, "?")
|
|
args = append(args, id)
|
|
}
|
|
if len(placeholders) > 0 {
|
|
excludeClause = " AND t.id NOT IN (" + strings.Join(placeholders, ", ") + ")"
|
|
}
|
|
}
|
|
row := r.ro.QueryRowContext(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
|
|
`+excludeClause+`
|
|
ORDER BY
|
|
t.position ASC,
|
|
CASE LOWER(COALESCE(t.priority, ''))
|
|
WHEN 'critical' THEN 0
|
|
WHEN 'high' THEN 1
|
|
WHEN 'medium' THEN 2
|
|
WHEN 'low' THEN 3
|
|
WHEN 'none' THEN 4
|
|
ELSE 4
|
|
END ASC,
|
|
t.created_at ASC,
|
|
t.id ASC
|
|
LIMIT 1
|
|
`), args...)
|
|
task, err := r.scanSingleTask(row)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return task, err
|
|
}
|
|
|
|
// 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, workflow_step_id, 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
|
|
}
|
|
|
|
// UnarchiveTask clears archived_at for a manually/legacy-archived task
|
|
// (no cascade stamp). The CAS guard on archived_by_cascade_id keeps a
|
|
// delayed manual unarchive from erasing a newer cascade archive that
|
|
// landed between the caller's read and this update — cascade-stamped
|
|
// rows are only restored via UnarchiveTaskByCascade. Returns whether a
|
|
// row was actually updated.
|
|
func (r *Repository) UnarchiveTask(ctx context.Context, id string) (bool, error) {
|
|
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_at IS NOT NULL
|
|
AND (archived_by_cascade_id = '' OR archived_by_cascade_id IS NULL)
|
|
`), time.Now().UTC(), id)
|
|
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(¤tState)
|
|
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
|
|
}
|