481359ddb2
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
887 lines
29 KiB
Go
887 lines
29 KiB
Go
package sqlite_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/office/repository/sqlite"
|
|
)
|
|
|
|
// newSearchTestRepo creates a test repo with a minimal tasks table for
|
|
// search tests. ADR 0005 Wave F dropped tasks.assignee_agent_profile_id;
|
|
// we keep the column on the test schema as a transient holding place so
|
|
// existing fixtures still compile, but the runner projection in repo
|
|
// queries reads from workflow_step_participants — those stub tables are
|
|
// created here too.
|
|
func newSearchTestRepo(t *testing.T) *sqlite.Repository {
|
|
t.Helper()
|
|
repo := newTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
CREATE TABLE IF NOT EXISTS workspaces (
|
|
id TEXT PRIMARY KEY,
|
|
office_workflow_id TEXT DEFAULT ''
|
|
)
|
|
`); err != nil {
|
|
t.Fatalf("create workspaces table: %v", err)
|
|
}
|
|
|
|
_, err := repo.ExecRaw(ctx, `
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL DEFAULT '',
|
|
workflow_id TEXT NOT NULL DEFAULT '',
|
|
workflow_step_id TEXT NOT NULL DEFAULT '',
|
|
title TEXT NOT NULL DEFAULT '',
|
|
assignee_user_id TEXT NOT NULL DEFAULT '',
|
|
description TEXT DEFAULT '',
|
|
state TEXT DEFAULT 'TODO',
|
|
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('critical','high','medium','low')),
|
|
parent_id TEXT DEFAULT '',
|
|
project_id TEXT DEFAULT '',
|
|
labels TEXT DEFAULT '[]',
|
|
identifier TEXT DEFAULT '',
|
|
is_ephemeral INTEGER DEFAULT 0,
|
|
origin TEXT DEFAULT 'manual',
|
|
metadata TEXT DEFAULT '{}',
|
|
checkout_agent_id TEXT,
|
|
checkout_at TIMESTAMP,
|
|
checkout_run_id TEXT,
|
|
archived_at TIMESTAMP,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("create tasks table: %v", err)
|
|
}
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
CREATE TABLE IF NOT EXISTS workflows (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL DEFAULT '',
|
|
workflow_template_id TEXT DEFAULT '',
|
|
name TEXT NOT NULL DEFAULT '',
|
|
description TEXT DEFAULT '',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`); err != nil {
|
|
t.Fatalf("create workflows table: %v", err)
|
|
}
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
CREATE TABLE IF NOT EXISTS workflow_steps (
|
|
id TEXT PRIMARY KEY,
|
|
agent_profile_id TEXT NOT NULL DEFAULT ''
|
|
)
|
|
`); err != nil {
|
|
t.Fatalf("create workflow_steps table: %v", err)
|
|
}
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
CREATE TABLE IF NOT EXISTS workflow_step_participants (
|
|
id TEXT PRIMARY KEY,
|
|
step_id TEXT NOT NULL DEFAULT '',
|
|
task_id TEXT NOT NULL DEFAULT '',
|
|
role TEXT NOT NULL DEFAULT '',
|
|
agent_profile_id TEXT NOT NULL DEFAULT '',
|
|
decision_required INTEGER NOT NULL DEFAULT 0,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00',
|
|
provenance TEXT NOT NULL DEFAULT 'manual'
|
|
)
|
|
`); err != nil {
|
|
t.Fatalf("create workflow_step_participants table: %v", err)
|
|
}
|
|
// AddTaskParticipant checks for a claimable auto-cast seat by joining
|
|
// against workflow_step_decisions (see findClaimableAutoSeat) — stub it
|
|
// out here so callers of AddTaskParticipant don't need their own copy.
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
CREATE TABLE IF NOT EXISTS workflow_step_decisions (
|
|
id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL DEFAULT '',
|
|
step_id TEXT NOT NULL DEFAULT '',
|
|
participant_id TEXT NOT NULL DEFAULT '',
|
|
decision TEXT NOT NULL DEFAULT '',
|
|
decided_at TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00',
|
|
superseded_at TIMESTAMP NULL
|
|
)
|
|
`); err != nil {
|
|
t.Fatalf("create workflow_step_decisions table: %v", err)
|
|
}
|
|
return repo
|
|
}
|
|
|
|
func insertTask(t *testing.T, repo *sqlite.Repository, ctx context.Context, id, wsID, title, desc, identifier string) {
|
|
t.Helper()
|
|
_, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO tasks (id, workspace_id, title, description, identifier, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
`, id, wsID, title, desc, identifier)
|
|
if err != nil {
|
|
t.Fatalf("insert task %s: %v", id, err)
|
|
}
|
|
}
|
|
|
|
func TestGetTaskExecutionFields_FallsBackToLatestTaskRunner(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO workflow_steps (id, agent_profile_id)
|
|
VALUES ('step-work', ''), ('step-review', ''), ('step-done', '')
|
|
`); err != nil {
|
|
t.Fatalf("insert steps: %v", err)
|
|
}
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO tasks (id, workspace_id, workflow_step_id, state, title, created_at, updated_at)
|
|
VALUES ('task-done', 'ws-1', 'step-done', 'DONE', 'Done task', datetime('now'), datetime('now'))
|
|
`); err != nil {
|
|
t.Fatalf("insert task: %v", err)
|
|
}
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO workflow_step_participants
|
|
(id, step_id, task_id, role, agent_profile_id, decision_required, position)
|
|
VALUES
|
|
('p-work', 'step-work', 'task-done', 'runner', 'runner-on-work', 0, 0),
|
|
('p-review', 'step-review', 'task-done', 'runner', 'runner-on-review', 0, 0)
|
|
`); err != nil {
|
|
t.Fatalf("insert runners: %v", err)
|
|
}
|
|
|
|
fields, err := repo.GetTaskExecutionFields(ctx, "task-done")
|
|
if err != nil {
|
|
t.Fatalf("execution fields: %v", err)
|
|
}
|
|
if fields.AssigneeAgentProfileID != "runner-on-review" {
|
|
t.Fatalf("expected runner-on-review, got %q", fields.AssigneeAgentProfileID)
|
|
}
|
|
}
|
|
|
|
func TestListUnstartedTasks_OnlyReturnsOfficeTasks(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO workspaces (id, office_workflow_id) VALUES
|
|
('ws-office', 'wf-office'),
|
|
('ws-kanban', '')
|
|
`); err != nil {
|
|
t.Fatalf("insert workspaces: %v", err)
|
|
}
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO workflow_steps (id, agent_profile_id) VALUES
|
|
('step-office', 'runner-office'),
|
|
('step-kanban', 'runner-kanban')
|
|
`); err != nil {
|
|
t.Fatalf("insert workflow steps: %v", err)
|
|
}
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO tasks
|
|
(id, workspace_id, workflow_id, workflow_step_id, project_id, state, title, created_at, updated_at)
|
|
VALUES
|
|
('task-office-workflow', 'ws-office', 'wf-office', 'step-office', '', 'TODO', 'Office workflow', datetime('now'), datetime('now')),
|
|
('task-office-project', 'ws-kanban', 'wf-kanban', 'step-kanban', 'project-1', 'TODO', 'Office project', datetime('now'), datetime('now')),
|
|
('task-kanban', 'ws-kanban', 'wf-kanban', 'step-kanban', '', 'TODO', 'Kanban task', datetime('now'), datetime('now'))
|
|
`); err != nil {
|
|
t.Fatalf("insert tasks: %v", err)
|
|
}
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO workflow_step_participants
|
|
(id, step_id, task_id, role, agent_profile_id, decision_required, position)
|
|
VALUES
|
|
('runner-office-workflow', 'step-office', 'task-office-workflow', 'runner', 'runner-office', 0, 0),
|
|
('runner-office-project', 'step-kanban', 'task-office-project', 'runner', 'runner-kanban', 0, 0),
|
|
('runner-kanban', 'step-kanban', 'task-kanban', 'runner', 'runner-kanban', 0, 0)
|
|
`); err != nil {
|
|
t.Fatalf("insert runners: %v", err)
|
|
}
|
|
|
|
rows, err := repo.ListUnstartedTasks(ctx, 24, 10)
|
|
if err != nil {
|
|
t.Fatalf("ListUnstartedTasks: %v", err)
|
|
}
|
|
got := make(map[string]bool, len(rows))
|
|
for _, row := range rows {
|
|
got[row.ID] = true
|
|
}
|
|
if !got["task-office-workflow"] || !got["task-office-project"] {
|
|
t.Fatalf("office tasks = %#v, want workflow and project tasks", got)
|
|
}
|
|
if got["task-kanban"] {
|
|
t.Fatalf("ordinary Kanban task was recovered: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchTasks_MatchesTitle(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Fix login bug", "desc here", "KAN-1")
|
|
insertTask(t, repo, ctx, "t2", "ws1", "Add payment flow", "payment desc", "KAN-2")
|
|
insertTask(t, repo, ctx, "t3", "ws1", "Refactor auth", "auth refactor", "KAN-3")
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "login", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(results))
|
|
}
|
|
if results[0].Title != "Fix login bug" {
|
|
t.Errorf("expected title 'Fix login bug', got %q", results[0].Title)
|
|
}
|
|
}
|
|
|
|
func TestSearchTasks_MatchesIdentifier(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Some task", "some desc", "KAN-42")
|
|
insertTask(t, repo, ctx, "t2", "ws1", "Another task", "another desc", "KAN-99")
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "KAN-42", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(results))
|
|
}
|
|
if results[0].Identifier != "KAN-42" {
|
|
t.Errorf("expected identifier 'KAN-42', got %q", results[0].Identifier)
|
|
}
|
|
}
|
|
|
|
func TestSearchTasks_MatchesDescription(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Title A", "fix the authentication module", "KAN-1")
|
|
insertTask(t, repo, ctx, "t2", "ws1", "Title B", "update the readme", "KAN-2")
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "authentication", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(results))
|
|
}
|
|
if results[0].ID != "t1" {
|
|
t.Errorf("expected task t1, got %q", results[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestSearchTasks_NoResults(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Some task", "desc", "KAN-1")
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "nonexistent", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Fatalf("expected 0 results, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestSearchTasks_RespectsLimit(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
for i := 0; i < 10; i++ {
|
|
id := fmt.Sprintf("t%d", i)
|
|
ident := fmt.Sprintf("KAN-%d", i)
|
|
insertTask(t, repo, ctx, id, "ws1", "Match task", "desc", ident)
|
|
}
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "Match", 3)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks: %v", err)
|
|
}
|
|
if len(results) != 3 {
|
|
t.Fatalf("expected 3 results (limit), got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestSearchTasks_WorkspaceIsolation(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Shared title", "desc", "KAN-1")
|
|
insertTask(t, repo, ctx, "t2", "ws2", "Shared title", "desc", "KAN-2")
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "Shared", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result for ws1, got %d", len(results))
|
|
}
|
|
if results[0].ID != "t1" {
|
|
t.Errorf("expected task from ws1, got %q", results[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestSearchTasks_ExcludesArchived(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Active task", "desc", "KAN-1")
|
|
insertTask(t, repo, ctx, "t2", "ws1", "Archived task", "desc", "KAN-2")
|
|
// Archive t2
|
|
_, err := repo.ExecRaw(ctx, `UPDATE tasks SET archived_at = datetime('now') WHERE id = ?`, "t2")
|
|
if err != nil {
|
|
t.Fatalf("archive task: %v", err)
|
|
}
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "task", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result (archived excluded), got %d", len(results))
|
|
}
|
|
if results[0].ID != "t1" {
|
|
t.Errorf("expected active task, got %q", results[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestSearchTasks_ExcludesEphemeral(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Normal task", "desc", "KAN-1")
|
|
insertTask(t, repo, ctx, "t2", "ws1", "Ephemeral task", "desc", "KAN-2")
|
|
// Mark t2 as ephemeral
|
|
_, err := repo.ExecRaw(ctx, `UPDATE tasks SET is_ephemeral = 1 WHERE id = ?`, "t2")
|
|
if err != nil {
|
|
t.Fatalf("set ephemeral: %v", err)
|
|
}
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "task", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result (ephemeral excluded), got %d", len(results))
|
|
}
|
|
if results[0].ID != "t1" {
|
|
t.Errorf("expected normal task, got %q", results[0].ID)
|
|
}
|
|
}
|
|
|
|
// -- FTS5 tests --
|
|
|
|
// newFTSTestRepo creates a test repo with a tasks table and FTS5 index.
|
|
// Skips the test if the SQLite build does not include FTS5.
|
|
func newFTSTestRepo(t *testing.T) *sqlite.Repository {
|
|
t.Helper()
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
// Create FTS5 virtual table and sync triggers.
|
|
_, err := repo.ExecRaw(ctx, `
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
|
|
title, description, identifier,
|
|
content='tasks',
|
|
content_rowid='rowid'
|
|
)
|
|
`)
|
|
if err != nil {
|
|
t.Skipf("FTS5 not available in this SQLite build: %v", err)
|
|
}
|
|
|
|
for _, stmt := range []string{
|
|
`CREATE TRIGGER IF NOT EXISTS tasks_fts_insert AFTER INSERT ON tasks BEGIN
|
|
INSERT INTO tasks_fts(rowid, title, description, identifier)
|
|
VALUES (new.rowid, new.title, COALESCE(new.description,''), COALESCE(new.identifier,''));
|
|
END`,
|
|
`CREATE TRIGGER IF NOT EXISTS tasks_fts_update AFTER UPDATE ON tasks BEGIN
|
|
INSERT INTO tasks_fts(tasks_fts, rowid, title, description, identifier)
|
|
VALUES('delete', old.rowid, old.title, COALESCE(old.description,''), COALESCE(old.identifier,''));
|
|
INSERT INTO tasks_fts(rowid, title, description, identifier)
|
|
VALUES (new.rowid, new.title, COALESCE(new.description,''), COALESCE(new.identifier,''));
|
|
END`,
|
|
`CREATE TRIGGER IF NOT EXISTS tasks_fts_delete AFTER DELETE ON tasks BEGIN
|
|
INSERT INTO tasks_fts(tasks_fts, rowid, title, description, identifier)
|
|
VALUES('delete', old.rowid, old.title, COALESCE(old.description,''), COALESCE(old.identifier,''));
|
|
END`,
|
|
} {
|
|
if _, err := repo.ExecRaw(ctx, stmt); err != nil {
|
|
t.Fatalf("create FTS trigger: %v", err)
|
|
}
|
|
}
|
|
|
|
return repo
|
|
}
|
|
|
|
func TestSearchTasksFTS_MatchesTitlePrefix(t *testing.T) {
|
|
repo := newFTSTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Fix login bug", "desc here", "KAN-1")
|
|
insertTask(t, repo, ctx, "t2", "ws1", "Add payment flow", "payment desc", "KAN-2")
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "login", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks FTS: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(results))
|
|
}
|
|
if results[0].Title != "Fix login bug" {
|
|
t.Errorf("expected title 'Fix login bug', got %q", results[0].Title)
|
|
}
|
|
}
|
|
|
|
func TestSearchTasksFTS_MatchesIdentifier(t *testing.T) {
|
|
repo := newFTSTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Some task", "some desc", "KAN-42")
|
|
insertTask(t, repo, ctx, "t2", "ws1", "Another task", "another desc", "KAN-99")
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "KAN-42", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks FTS: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(results))
|
|
}
|
|
if results[0].Identifier != "KAN-42" {
|
|
t.Errorf("expected identifier 'KAN-42', got %q", results[0].Identifier)
|
|
}
|
|
}
|
|
|
|
func TestSearchTasksFTS_PrefixMatch(t *testing.T) {
|
|
repo := newFTSTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Authentication module", "auth desc", "KAN-1")
|
|
insertTask(t, repo, ctx, "t2", "ws1", "Billing module", "bill desc", "KAN-2")
|
|
|
|
// "auth" should prefix-match "Authentication"
|
|
results, err := repo.SearchTasks(ctx, "ws1", "auth", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks FTS prefix: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result for prefix match, got %d", len(results))
|
|
}
|
|
if results[0].ID != "t1" {
|
|
t.Errorf("expected task t1, got %q", results[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestSearchTasksFTS_FallbackToLike(t *testing.T) {
|
|
// Use a repo without FTS5 table -- should fall back to LIKE search.
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Fix login bug", "desc here", "KAN-1")
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "login", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks fallback: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result via LIKE fallback, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestSearchTasksFTS_WorkspaceIsolation(t *testing.T) {
|
|
repo := newFTSTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "t1", "ws1", "Shared title", "desc", "KAN-1")
|
|
insertTask(t, repo, ctx, "t2", "ws2", "Shared title", "desc", "KAN-2")
|
|
|
|
results, err := repo.SearchTasks(ctx, "ws1", "Shared", 50)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks FTS: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result for ws1, got %d", len(results))
|
|
}
|
|
if results[0].ID != "t1" {
|
|
t.Errorf("expected task from ws1, got %q", results[0].ID)
|
|
}
|
|
}
|
|
|
|
// -- ListTasksByWorkspace and GetTaskByID tests --
|
|
|
|
func TestListTasksByWorkspace_ReturnsTasksForWorkspace(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "i1", "ws-a", "Issue Alpha", "alpha desc", "KAN-1")
|
|
insertTask(t, repo, ctx, "i2", "ws-a", "Issue Beta", "beta desc", "KAN-2")
|
|
insertTask(t, repo, ctx, "i3", "ws-b", "Other WS Issue", "other desc", "OTH-1")
|
|
|
|
issues, err := repo.ListTasksByWorkspace(ctx, "ws-a", true)
|
|
if err != nil {
|
|
t.Fatalf("ListTasksByWorkspace: %v", err)
|
|
}
|
|
if len(issues) != 2 {
|
|
t.Fatalf("expected 2 issues for ws-a, got %d", len(issues))
|
|
}
|
|
for _, iss := range issues {
|
|
if iss.WorkspaceID != "ws-a" {
|
|
t.Errorf("unexpected workspace_id %q", iss.WorkspaceID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListTasksByWorkspace_ExcludesArchivedAndEphemeral(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "n1", "ws-c", "Normal task", "", "KAN-1")
|
|
now := "2025-01-01 00:00:00"
|
|
_, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO tasks
|
|
(id, workspace_id, title, identifier, is_ephemeral, created_at, updated_at)
|
|
VALUES ('eph', 'ws-c', 'Ephemeral', 'KAN-2', 1, ?, ?)
|
|
`, now, now)
|
|
if err != nil {
|
|
t.Fatalf("insert ephemeral task: %v", err)
|
|
}
|
|
_, err = repo.ExecRaw(ctx, `
|
|
INSERT INTO tasks
|
|
(id, workspace_id, title, identifier, archived_at, created_at, updated_at)
|
|
VALUES ('arc', 'ws-c', 'Archived', 'KAN-3', ?, ?, ?)
|
|
`, now, now, now)
|
|
if err != nil {
|
|
t.Fatalf("insert archived task: %v", err)
|
|
}
|
|
|
|
issues, err := repo.ListTasksByWorkspace(ctx, "ws-c", true)
|
|
if err != nil {
|
|
t.Fatalf("ListTasksByWorkspace: %v", err)
|
|
}
|
|
if len(issues) != 1 {
|
|
t.Fatalf("expected 1 issue (normal only), got %d", len(issues))
|
|
}
|
|
if issues[0].ID != "n1" {
|
|
t.Errorf("expected task n1, got %q", issues[0].ID)
|
|
}
|
|
}
|
|
|
|
// TestListTasksByWorkspace_SystemTaskFilter verifies that tasks living
|
|
// in a kandev-managed system workflow (template_id = "routine") are
|
|
// excluded by default and re-included when includeSystem=true. IsSystem
|
|
// is set on the projection so the UI can mark them.
|
|
func TestListTasksByWorkspace_SystemTaskFilter(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO workflows (id, workspace_id, workflow_template_id, name, created_at, updated_at)
|
|
VALUES ('wf-user', 'ws-x', 'office-default', 'User', datetime('now'), datetime('now')),
|
|
('wf-sys', 'ws-x', 'routine', 'System', datetime('now'), datetime('now'))
|
|
`); err != nil {
|
|
t.Fatalf("seed workflows: %v", err)
|
|
}
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO tasks (id, workspace_id, workflow_id, title, identifier, created_at, updated_at)
|
|
VALUES ('user-1', 'ws-x', 'wf-user', 'User Task', 'KAN-1', datetime('now'), datetime('now')),
|
|
('sys-1', 'ws-x', 'wf-sys', 'Routine fire','KAN-2', datetime('now'), datetime('now'))
|
|
`); err != nil {
|
|
t.Fatalf("seed tasks: %v", err)
|
|
}
|
|
|
|
user, err := repo.ListTasksByWorkspace(ctx, "ws-x", false)
|
|
if err != nil {
|
|
t.Fatalf("ListTasksByWorkspace(false): %v", err)
|
|
}
|
|
if len(user) != 1 || user[0].ID != "user-1" {
|
|
t.Errorf("default list should hide system task, got %+v", user)
|
|
}
|
|
if user[0].IsSystem {
|
|
t.Errorf("user task should not be flagged is_system")
|
|
}
|
|
|
|
all, err := repo.ListTasksByWorkspace(ctx, "ws-x", true)
|
|
if err != nil {
|
|
t.Fatalf("ListTasksByWorkspace(true): %v", err)
|
|
}
|
|
if len(all) != 2 {
|
|
t.Fatalf("includeSystem should return both, got %d", len(all))
|
|
}
|
|
var sys *sqlite.TaskRow
|
|
for _, r := range all {
|
|
if r.ID == "sys-1" {
|
|
sys = r
|
|
}
|
|
}
|
|
if sys == nil {
|
|
t.Fatal("system task missing when included")
|
|
}
|
|
if !sys.IsSystem {
|
|
t.Errorf("system task should be flagged is_system, got %+v", sys)
|
|
}
|
|
}
|
|
|
|
func TestGetTaskByID_ReturnsIssue(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "get1", "ws-d", "Get me", "get desc", "KAN-10")
|
|
|
|
iss, err := repo.GetTaskByID(ctx, "get1")
|
|
if err != nil {
|
|
t.Fatalf("GetTaskByID: %v", err)
|
|
}
|
|
if iss == nil {
|
|
t.Fatal("expected issue, got nil")
|
|
}
|
|
if iss.ID != "get1" {
|
|
t.Errorf("expected id get1, got %q", iss.ID)
|
|
}
|
|
if iss.Title != "Get me" {
|
|
t.Errorf("expected title 'Get me', got %q", iss.Title)
|
|
}
|
|
}
|
|
|
|
func TestGetTaskByID_ReturnsNilForMissingID(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
iss, err := repo.GetTaskByID(ctx, "nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("expected no error for missing id, got: %v", err)
|
|
}
|
|
if iss != nil {
|
|
t.Errorf("expected nil for missing id, got %+v", iss)
|
|
}
|
|
}
|
|
|
|
// -- CountActionableTasksForAgent tests --
|
|
|
|
// insertAssignedTask inserts a task with a given state and assignee.
|
|
// ADR 0005 Wave F: assignee lives in workflow_step_participants.
|
|
// We seed a synthetic step id derived from the task id so the test
|
|
// keeps the (step, task) participant key well-formed.
|
|
func insertAssignedTask(
|
|
t *testing.T, repo *sqlite.Repository, ctx context.Context,
|
|
id, wsID, agentID, state string, archivedAt string,
|
|
) {
|
|
t.Helper()
|
|
stepID := "step-" + id
|
|
if archivedAt != "" {
|
|
_, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO tasks (id, workspace_id, workflow_step_id, state, title, archived_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
`, id, wsID, stepID, state, id, archivedAt)
|
|
if err != nil {
|
|
t.Fatalf("insert archived task %s: %v", id, err)
|
|
}
|
|
} else {
|
|
_, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO tasks (id, workspace_id, workflow_step_id, state, title, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
`, id, wsID, stepID, state, id)
|
|
if err != nil {
|
|
t.Fatalf("insert task %s: %v", id, err)
|
|
}
|
|
}
|
|
if agentID != "" {
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO workflow_step_participants
|
|
(id, step_id, task_id, role, agent_profile_id, decision_required, position)
|
|
VALUES (?, ?, ?, 'runner', ?, 0, 0)
|
|
`, "p-"+id, stepID, id, agentID); err != nil {
|
|
t.Fatalf("insert runner participant for %s: %v", id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCountActionableTasksForAgent_CountsTodoAndInProgress(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
agentID := "agent-count-1"
|
|
|
|
insertAssignedTask(t, repo, ctx, "task-todo", "ws1", agentID, "TODO", "")
|
|
insertAssignedTask(t, repo, ctx, "task-inprogress", "ws1", agentID, "IN_PROGRESS", "")
|
|
insertAssignedTask(t, repo, ctx, "task-done", "ws1", agentID, "DONE", "")
|
|
insertAssignedTask(t, repo, ctx, "task-cancelled", "ws1", agentID, "CANCELLED", "")
|
|
insertAssignedTask(t, repo, ctx, "task-in-review", "ws1", agentID, "IN_REVIEW", "")
|
|
|
|
count, err := repo.CountActionableTasksForAgent(ctx, agentID)
|
|
if err != nil {
|
|
t.Fatalf("CountActionableTasksForAgent: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("expected 2 actionable tasks (TODO + IN_PROGRESS), got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestCountActionableTasksForAgent_ExcludesArchived(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
agentID := "agent-count-2"
|
|
|
|
insertAssignedTask(t, repo, ctx, "task-todo-active", "ws1", agentID, "TODO", "")
|
|
insertAssignedTask(t, repo, ctx, "task-todo-archived", "ws1", agentID, "TODO", "2025-01-01 00:00:00")
|
|
|
|
count, err := repo.CountActionableTasksForAgent(ctx, agentID)
|
|
if err != nil {
|
|
t.Fatalf("CountActionableTasksForAgent: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 (archived excluded), got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestCountActionableTasksForAgent_ReturnsZeroForNoTasks(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
count, err := repo.CountActionableTasksForAgent(ctx, "agent-no-tasks")
|
|
if err != nil {
|
|
t.Fatalf("CountActionableTasksForAgent: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("expected 0 for agent with no tasks, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestCountActionableTasksForAgent_AgentIsolation(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertAssignedTask(t, repo, ctx, "task-a", "ws1", "agent-a", "TODO", "")
|
|
insertAssignedTask(t, repo, ctx, "task-b", "ws1", "agent-b", "TODO", "")
|
|
insertAssignedTask(t, repo, ctx, "task-c", "ws1", "agent-b", "IN_PROGRESS", "")
|
|
|
|
countA, err := repo.CountActionableTasksForAgent(ctx, "agent-a")
|
|
if err != nil {
|
|
t.Fatalf("CountActionableTasksForAgent agent-a: %v", err)
|
|
}
|
|
if countA != 1 {
|
|
t.Errorf("agent-a: expected 1, got %d", countA)
|
|
}
|
|
|
|
countB, err := repo.CountActionableTasksForAgent(ctx, "agent-b")
|
|
if err != nil {
|
|
t.Fatalf("CountActionableTasksForAgent agent-b: %v", err)
|
|
}
|
|
if countB != 2 {
|
|
t.Errorf("agent-b: expected 2, got %d", countB)
|
|
}
|
|
}
|
|
|
|
// Automation runs are hidden from the task list by their origin, not by
|
|
// is_ephemeral: they are ordinary persistent tasks with their own destination
|
|
// (docs/specs/office/requirements/automations-settings.md). The quick chat alongside them
|
|
// still behaves exactly as it did.
|
|
func TestOfficeTaskListsExcludeAutomationOriginTasks(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertTask(t, repo, ctx, "human", "ws-auto", "Nightly sweep report", "", "KAN-1")
|
|
now := "2025-01-01 00:00:00"
|
|
if _, err := repo.ExecRaw(ctx, `
|
|
INSERT INTO tasks
|
|
(id, workspace_id, title, identifier, origin, created_at, updated_at)
|
|
VALUES ('auto', 'ws-auto', 'Nightly sweep run', 'KAN-2', 'automation_run', ?, ?)
|
|
`, now, now); err != nil {
|
|
t.Fatalf("insert automation task: %v", err)
|
|
}
|
|
|
|
listed, err := repo.ListTasksByWorkspace(ctx, "ws-auto", true)
|
|
if err != nil {
|
|
t.Fatalf("ListTasksByWorkspace: %v", err)
|
|
}
|
|
if len(listed) != 1 || listed[0].ID != "human" {
|
|
t.Fatalf("expected only the human task, got %+v", listed)
|
|
}
|
|
|
|
found, err := repo.SearchTasks(ctx, "ws-auto", "Nightly sweep", 10)
|
|
if err != nil {
|
|
t.Fatalf("SearchTasks: %v", err)
|
|
}
|
|
for _, task := range found {
|
|
if task.ID == "auto" {
|
|
t.Fatalf("automation-origin task must not be searchable in the task list, got %+v", found)
|
|
}
|
|
}
|
|
|
|
count, err := repo.CountTasksByWorkspace(ctx, "ws-auto")
|
|
if err != nil {
|
|
t.Fatalf("CountTasksByWorkspace: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("CountTasksByWorkspace = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
// An automation configured against a workflow step resolves to that step's
|
|
// runner. Its run is long-lived and never leaves IN_PROGRESS on its own, so
|
|
// counting it would inflate the agent's load permanently and starve it of the
|
|
// real work the count exists to balance.
|
|
func TestCountActionableTasksForAgent_ExcludesAutomationRuns(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
agentID := "agent-automation"
|
|
insertAssignedTask(t, repo, ctx, "task-human", "ws1", agentID, "IN_PROGRESS", "")
|
|
insertAssignedTask(t, repo, ctx, "task-automation", "ws1", agentID, "IN_PROGRESS", "")
|
|
if _, err := repo.ExecRaw(ctx,
|
|
`UPDATE tasks SET origin = 'automation_run' WHERE id = ?`, "task-automation",
|
|
); err != nil {
|
|
t.Fatalf("tag automation origin: %v", err)
|
|
}
|
|
|
|
count, err := repo.CountActionableTasksForAgent(ctx, agentID)
|
|
if err != nil {
|
|
t.Fatalf("CountActionableTasksForAgent: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 actionable task (automation run excluded), got %d", count)
|
|
}
|
|
}
|
|
|
|
// Scheduler recovery launches whatever this returns. An automation run is
|
|
// started once, explicitly, at trigger time; recovery picking it up would
|
|
// start it a second time through a path that knows nothing about the run row
|
|
// or the automation's concurrency cap.
|
|
func TestListUnstartedTasks_ExcludesAutomationRuns(t *testing.T) {
|
|
repo := newSearchTestRepo(t)
|
|
ctx := context.Background()
|
|
|
|
insertAssignedTask(t, repo, ctx, "unstarted-human", "ws1", "agent-recovery", "TODO", "")
|
|
insertAssignedTask(t, repo, ctx, "unstarted-automation", "ws1", "agent-recovery", "TODO", "")
|
|
// ListUnstartedTasks only considers office tasks, so both rows need a
|
|
// project before origin can be what separates them — otherwise the query
|
|
// returns nothing and the test passes for the wrong reason.
|
|
if _, err := repo.ExecRaw(ctx,
|
|
`UPDATE tasks SET project_id = 'project-recovery' WHERE id IN (?, ?)`,
|
|
"unstarted-human", "unstarted-automation",
|
|
); err != nil {
|
|
t.Fatalf("scope tasks to a project: %v", err)
|
|
}
|
|
if _, err := repo.ExecRaw(ctx,
|
|
`UPDATE tasks SET origin = 'automation_run' WHERE id = ?`, "unstarted-automation",
|
|
); err != nil {
|
|
t.Fatalf("tag automation origin: %v", err)
|
|
}
|
|
|
|
rows, err := repo.ListUnstartedTasks(ctx, 24, 50)
|
|
if err != nil {
|
|
t.Fatalf("ListUnstartedTasks: %v", err)
|
|
}
|
|
for _, row := range rows {
|
|
if row.ID == "unstarted-automation" {
|
|
t.Fatalf("scheduler recovery must not pick up an automation run, got %+v", rows)
|
|
}
|
|
}
|
|
if len(rows) != 1 || rows[0].ID != "unstarted-human" {
|
|
t.Fatalf("expected only the human task, got %+v", rows)
|
|
}
|
|
}
|