diff options
| author | Claudomator Agent <agent@claudomator.local> | 2026-07-09 03:02:18 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator.local> | 2026-07-09 03:02:18 +0000 |
| commit | 5ea8f50b0c43a23af6e65bce1fe057a355cb1929 (patch) | |
| tree | 239b69867e5d321dab65f6eb2a58705765fe5b26 | |
| parent | 647befd36bae81d478101f8dc4909432f6807fc6 (diff) | |
feat(task,storage): add Task.AcceptanceCriteria, mirroring story.Story's pattern
| -rw-r--r-- | internal/storage/db.go | 76 | ||||
| -rw-r--r-- | internal/storage/db_test.go | 65 | ||||
| -rw-r--r-- | internal/task/task.go | 7 |
3 files changed, 123 insertions, 25 deletions
diff --git a/internal/storage/db.go b/internal/storage/db.go index 4eebd04..d388f72 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -48,6 +48,7 @@ func (s *DB) migrate() error { retry_json TEXT NOT NULL DEFAULT '{}', tags_json TEXT NOT NULL DEFAULT '[]', depends_on_json TEXT NOT NULL DEFAULT '[]', + acceptance_criteria_json TEXT NOT NULL DEFAULT '[]', parent_task_id TEXT, state TEXT NOT NULL DEFAULT 'PENDING', created_at DATETIME NOT NULL, @@ -198,6 +199,7 @@ func (s *DB) migrate() error { // system-authored fallback answer, so a human can find and double-check // it later via GET /api/tasks?needs_review=true. `ALTER TABLE tasks ADD COLUMN needs_review BOOLEAN NOT NULL DEFAULT 0`, + `ALTER TABLE tasks ADD COLUMN acceptance_criteria_json TEXT NOT NULL DEFAULT '[]'`, } for _, m := range migrations { if _, err := s.db.Exec(m); err != nil { @@ -233,6 +235,14 @@ func (s *DB) CreateTask(t *task.Task) error { if err != nil { return fmt.Errorf("marshaling depends_on: %w", err) } + acceptanceCriteria := t.AcceptanceCriteria + if acceptanceCriteria == nil { + acceptanceCriteria = []string{} + } + acceptanceCriteriaJSON, err := json.Marshal(acceptanceCriteria) + if err != nil { + return fmt.Errorf("marshaling acceptance_criteria: %w", err) + } tx, err := s.db.Begin() if err != nil { @@ -241,10 +251,10 @@ func (s *DB) CreateTask(t *task.Task) error { defer tx.Rollback() //nolint:errcheck if _, err = tx.Exec(` - INSERT INTO tasks (id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO tasks (id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, acceptance_criteria_json, parent_task_id, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, t.ID, t.Name, t.Description, t.ElaborationInput, t.Project, t.RepositoryURL, string(configJSON), string(t.Priority), - t.Timeout.Duration.Nanoseconds(), string(retryJSON), string(tagsJSON), string(depsJSON), + t.Timeout.Duration.Nanoseconds(), string(retryJSON), string(tagsJSON), string(depsJSON), string(acceptanceCriteriaJSON), t.ParentTaskID, string(t.State), t.CreatedAt.UTC(), t.UpdatedAt.UTC(), ); err != nil { return err @@ -270,15 +280,23 @@ func (s *DB) CreateTask(t *task.Task) error { return tx.Commit() } +// taskSelectColumns is the shared SELECT column list every task-reading +// query in this file uses — extracted so a future column addition is one +// edit instead of five kept in sync by hand. Mirrors storySelectColumns in +// internal/storage/story.go. +func taskSelectColumns() string { + return `SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, acceptance_criteria_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review` +} + // GetTask retrieves a task by ID. func (s *DB) GetTask(id string) (*task.Task, error) { - row := s.db.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review FROM tasks WHERE id = ?`, id) + row := s.db.QueryRow(taskSelectColumns()+` FROM tasks WHERE id = ?`, id) return scanTask(row) } // ListTasks returns tasks matching the given filter. func (s *DB) ListTasks(filter TaskFilter) ([]*task.Task, error) { - query := `SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review FROM tasks WHERE 1=1` + query := taskSelectColumns() + ` FROM tasks WHERE 1=1` var args []interface{} if filter.State != "" { @@ -318,7 +336,7 @@ func (s *DB) ListTasks(filter TaskFilter) ([]*task.Task, error) { // ListSubtasks returns all tasks whose parent_task_id matches the given ID. func (s *DB) ListSubtasks(parentID string) ([]*task.Task, error) { - rows, err := s.db.Query(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`, parentID) + rows, err := s.db.Query(taskSelectColumns()+` FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`, parentID) if err != nil { return nil, err } @@ -343,7 +361,7 @@ func (s *DB) ListSubtasks(parentID string) ([]*task.Task, error) { // Only direct dependents are returned — callers that need the full // transitive downstream subtree (e.g. cascade-cancellation) must recurse. func (s *DB) ListDependents(taskID string) ([]*task.Task, error) { - rows, err := s.db.Query(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review FROM tasks`) + rows, err := s.db.Query(taskSelectColumns() + ` FROM tasks`) if err != nil { return nil, err } @@ -418,7 +436,7 @@ func (s *DB) ResetTaskForRetry(id string) (*task.Task, error) { } defer tx.Rollback() //nolint:errcheck - t, err := scanTask(tx.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review FROM tasks WHERE id = ?`, id)) + t, err := scanTask(tx.QueryRow(taskSelectColumns()+` FROM tasks WHERE id = ?`, id)) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("task %q not found", id) @@ -1168,26 +1186,27 @@ type scanner interface { func scanTask(row scanner) (*task.Task, error) { var ( - t task.Task - configJSON string - retryJSON string - tagsJSON string - depsJSON string - state string - priority string - timeoutNS int64 - parentTaskID sql.NullString - elaborationInput sql.NullString - project sql.NullString - repositoryURL sql.NullString - rejectionComment sql.NullString - questionJSON sql.NullString - summary sql.NullString - interactionsJSON sql.NullString + t task.Task + configJSON string + retryJSON string + tagsJSON string + depsJSON string + acceptanceCriteriaJSON string + state string + priority string + timeoutNS int64 + parentTaskID sql.NullString + elaborationInput sql.NullString + project sql.NullString + repositoryURL sql.NullString + rejectionComment sql.NullString + questionJSON sql.NullString + summary sql.NullString + interactionsJSON sql.NullString ) err := row.Scan( &t.ID, &t.Name, &t.Description, &elaborationInput, &project, &repositoryURL, - &configJSON, &priority, &timeoutNS, &retryJSON, &tagsJSON, &depsJSON, + &configJSON, &priority, &timeoutNS, &retryJSON, &tagsJSON, &depsJSON, &acceptanceCriteriaJSON, &parentTaskID, &state, &t.CreatedAt, &t.UpdatedAt, &rejectionComment, &questionJSON, &summary, &interactionsJSON, &t.NeedsReview, ) @@ -1216,6 +1235,13 @@ func scanTask(row scanner) (*task.Task, error) { if err := json.Unmarshal([]byte(depsJSON), &t.DependsOn); err != nil { return nil, fmt.Errorf("unmarshaling depends_on: %w", err) } + acRaw := acceptanceCriteriaJSON + if acRaw == "" { + acRaw = "[]" + } + if err := json.Unmarshal([]byte(acRaw), &t.AcceptanceCriteria); err != nil { + return nil, fmt.Errorf("unmarshaling acceptance_criteria: %w", err) + } raw := interactionsJSON.String if raw == "" { raw = "[]" diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index b53234a..3adf17f 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -1057,6 +1057,71 @@ func TestCreateTask_Project_RoundTrip(t *testing.T) { } } +func TestCreateTask_AcceptanceCriteria_RoundTrip(t *testing.T) { + db := testDB(t) + now := time.Now().UTC().Truncate(time.Second) + + tk := &task.Task{ + ID: "ac-1", + Name: "Task With Criteria", + Agent: task.AgentConfig{Type: "claude", Instructions: "do it"}, + Priority: task.PriorityNormal, + Tags: []string{}, + DependsOn: []string{}, + AcceptanceCriteria: []string{"tests pass", "no new lint warnings"}, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.CreateTask(tk); err != nil { + t.Fatalf("creating task: %v", err) + } + + got, err := db.GetTask("ac-1") + if err != nil { + t.Fatalf("getting task: %v", err) + } + if len(got.AcceptanceCriteria) != 2 || got.AcceptanceCriteria[0] != "tests pass" || got.AcceptanceCriteria[1] != "no new lint warnings" { + t.Errorf("AcceptanceCriteria: want [tests pass, no new lint warnings], got %+v", got.AcceptanceCriteria) + } +} + +// TestCreateTask_AcceptanceCriteria_DefaultsEmpty proves a task created +// without AcceptanceCriteria round-trips as an empty (not nil) slice, the +// same "never null" convention story.Story.AcceptanceCriteria already uses. +func TestCreateTask_AcceptanceCriteria_DefaultsEmpty(t *testing.T) { + db := testDB(t) + now := time.Now().UTC().Truncate(time.Second) + + tk := &task.Task{ + ID: "ac-2", + Name: "Task Without Criteria", + Agent: task.AgentConfig{Type: "claude", Instructions: "do it"}, + Priority: task.PriorityNormal, + Tags: []string{}, + DependsOn: []string{}, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.CreateTask(tk); err != nil { + t.Fatalf("creating task: %v", err) + } + + got, err := db.GetTask("ac-2") + if err != nil { + t.Fatalf("getting task: %v", err) + } + if got.AcceptanceCriteria == nil { + t.Error("AcceptanceCriteria: want empty slice, got nil") + } + if len(got.AcceptanceCriteria) != 0 { + t.Errorf("AcceptanceCriteria: want empty, got %+v", got.AcceptanceCriteria) + } +} + // ── Push subscription tests ─────────────────────────────────────────────────── func TestPushSubscription_SaveAndList(t *testing.T) { diff --git a/internal/task/task.go b/internal/task/task.go index 464d05d..35da9a0 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -96,6 +96,13 @@ type Task struct { Priority Priority `yaml:"priority" json:"priority"` Tags []string `yaml:"tags" json:"tags"` DependsOn []string `yaml:"depends_on" json:"depends_on"` + // AcceptanceCriteria, when non-empty, states what this task's work must + // satisfy — set by a decomposing parent via spawn_subtask's + // acceptance_criteria parameter (mirrors story.Story.AcceptanceCriteria + // exactly). Unused by execution itself; a later phase's arbitrated-review + // generalization reads it when evaluating this task's work, falling back + // to the enclosing story's own AcceptanceCriteria when empty. + AcceptanceCriteria []string `yaml:"acceptance_criteria" json:"acceptance_criteria"` BranchName string `yaml:"-" json:"branch_name,omitempty"` State State `yaml:"-" json:"state"` RejectionComment string `yaml:"-" json:"rejection_comment,omitempty"` |
