diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-08 20:40:02 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-08 20:40:02 +0000 |
| commit | 8777bf226529f45a1c29b3e63ba8efee916e1726 (patch) | |
| tree | cf0d6f8cd0f0b030bbd20fe5807dbd24642450df /internal/storage/db.go | |
| parent | 1f36e2312d316969db65a601ac7d9793fbc3bc4c (diff) | |
storage: enforce valid state transitions in UpdateTaskState
UpdateTaskState now validates the transition using ValidTransition inside
a transaction. Invalid transitions return an error rather than blindly
updating. Tests for retry-limit and running-task-rejection test setup
are updated to create tasks with the target state directly via CreateTask
to bypass the transition guard in setup code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/storage/db.go')
| -rw-r--r-- | internal/storage/db.go | 26 |
1 files changed, 18 insertions, 8 deletions
diff --git a/internal/storage/db.go b/internal/storage/db.go index b3df696..cbbd97c 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -193,21 +193,31 @@ func (s *DB) ListSubtasks(parentID string) ([]*task.Task, error) { return tasks, rows.Err() } -// UpdateTaskState atomically updates a task's state. +// UpdateTaskState atomically updates a task's state, enforcing valid transitions. func (s *DB) UpdateTaskState(id string, newState task.State) error { - now := time.Now().UTC() - result, err := s.db.Exec(`UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`, string(newState), now, id) + tx, err := s.db.Begin() if err != nil { return err } - n, err := result.RowsAffected() - if err != nil { + defer tx.Rollback() //nolint:errcheck + + var currentState string + if err := tx.QueryRow(`SELECT state FROM tasks WHERE id = ?`, id).Scan(¤tState); err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("task %q not found", id) + } return err } - if n == 0 { - return fmt.Errorf("task %q not found", id) + + if !task.ValidTransition(task.State(currentState), newState) { + return fmt.Errorf("invalid state transition %s → %s for task %q", currentState, newState, id) } - return nil + + now := time.Now().UTC() + if _, err := tx.Exec(`UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`, string(newState), now, id); err != nil { + return err + } + return tx.Commit() } // RejectTask sets a task's state to PENDING and stores the rejection comment. |
