summaryrefslogtreecommitdiff
path: root/internal/storage
diff options
context:
space:
mode:
Diffstat (limited to 'internal/storage')
-rw-r--r--internal/storage/db.go31
-rw-r--r--internal/storage/db_test.go105
2 files changed, 132 insertions, 4 deletions
diff --git a/internal/storage/db.go b/internal/storage/db.go
index 69bcf68..a77b1b1 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -146,6 +146,10 @@ func (s *DB) ListTasks(filter TaskFilter) ([]*task.Task, error) {
query += " AND state = ?"
args = append(args, string(filter.State))
}
+ if !filter.Since.IsZero() {
+ query += " AND updated_at > ?"
+ args = append(args, filter.Since.UTC())
+ }
query += " ORDER BY created_at DESC"
if filter.Limit > 0 {
query += " LIMIT ?"
@@ -350,6 +354,33 @@ func (s *DB) UpdateTask(id string, u TaskUpdate) error {
type TaskFilter struct {
State task.State
Limit int
+ Since time.Time
+}
+
+// GetMaxUpdatedAt returns the most recent updated_at timestamp across all tasks.
+func (s *DB) GetMaxUpdatedAt() (time.Time, error) {
+ var t sql.NullString
+ err := s.db.QueryRow(`SELECT MAX(updated_at) FROM tasks`).Scan(&t)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if !t.Valid || t.String == "" {
+ return time.Time{}, nil
+ }
+ // Try parsing different formats SQLite might return
+ formats := []string{
+ "2006-01-02 15:04:05.999999999-07:00",
+ "2006-01-02T15:04:05Z07:00",
+ time.RFC3339,
+ "2006-01-02 15:04:05",
+ }
+ for _, f := range formats {
+ parsed, err := time.Parse(f, t.String)
+ if err == nil {
+ return parsed.UTC(), nil
+ }
+ }
+ return time.Time{}, fmt.Errorf("could not parse max updated_at: %q", t.String)
}
// Execution represents a single run of a task.
diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go
index a16311d..752c5b1 100644
--- a/internal/storage/db_test.go
+++ b/internal/storage/db_test.go
@@ -148,11 +148,108 @@ func TestUpdateTaskState_InvalidTransition(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid state transition PENDING → COMPLETED")
}
+ }
- // State must not have changed.
- got, _ := db.GetTask("task-invalid")
- if got.State != task.StatePending {
- t.Errorf("state must remain PENDING, got %v", got.State)
+ func TestGetMaxUpdatedAt(t *testing.T) {
+ db := testDB(t)
+
+ // Initial: should be zero time (SQLite might return a placeholder)
+ t0, err := db.GetMaxUpdatedAt()
+ if err != nil {
+ t.Fatalf("GetMaxUpdatedAt (empty): %v", err)
+ }
+ if !t0.IsZero() && t0.Year() != 1 {
+ t.Errorf("expected zero time for empty table, got %v", t0)
+ }
+
+ now := time.Now().UTC().Truncate(time.Second)
+ tk := &task.Task{
+ ID: "max-1",
+ Name: "Max Updated",
+ Agent: task.AgentConfig{Type: "claude", Instructions: "test"},
+ Priority: task.PriorityNormal,
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
+ Tags: []string{},
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ if err := db.CreateTask(tk); err != nil {
+ t.Fatalf("CreateTask: %v", err)
+ }
+
+ t1, err := db.GetMaxUpdatedAt()
+ if err != nil {
+ t.Fatalf("GetMaxUpdatedAt (1 task): %v", err)
+ }
+ if !t1.Equal(now) {
+ t.Errorf("expected %v, got %v", now, t1)
+ }
+
+ if err := db.UpdateTaskState("max-1", task.StateQueued); err != nil {
+ t.Fatalf("UpdateTaskState: %v", err)
+ }
+ // Note: UpdateTaskState in db.go uses time.Now().UTC() for updated_at.
+ // We can't easily control it in the test without changing db.go to accept a time.
+ // So we just check that it's after or equal now.
+ t2, err := db.GetMaxUpdatedAt()
+ if err != nil {
+ t.Fatalf("GetMaxUpdatedAt (updated): %v", err)
+ }
+ if !t2.After(now) && !t2.Equal(now) {
+ t.Errorf("expected max updated_at to be >= %v, got %v", now, t2)
+ }
+ }
+
+ func TestListTasksSince(t *testing.T) {
+ db := testDB(t)
+ t1 := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)
+ t2 := time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC)
+ t3 := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC)
+
+ tasks := []*task.Task{
+ {ID: "since-1", Name: "T1", CreatedAt: t1, UpdatedAt: t1, Agent: task.AgentConfig{Instructions: "t1"}},
+ {ID: "since-2", Name: "T2", CreatedAt: t2, UpdatedAt: t2, Agent: task.AgentConfig{Instructions: "t2"}},
+ {ID: "since-3", Name: "T3", CreatedAt: t3, UpdatedAt: t3, Agent: task.AgentConfig{Instructions: "t3"}},
+ }
+ for _, tk := range tasks {
+ if err := db.CreateTask(tk); err != nil {
+ t.Fatalf("CreateTask: %v", err)
+ }
+ }
+
+ // Fetch all
+ all, err := db.ListTasks(TaskFilter{})
+ if err != nil || len(all) != 3 {
+ t.Fatalf("ListTasks(all): err=%v, len=%d", err, len(all))
+ }
+
+ // Fetch since T1
+ sinceT1, err := db.ListTasks(TaskFilter{Since: t1})
+ if err != nil {
+ t.Fatalf("ListTasks(since T1): %v", err)
+ }
+ if len(sinceT1) != 2 {
+ t.Errorf("expected 2 tasks (T2, T3), got %d", len(sinceT1))
+ }
+
+ // Fetch since T2
+ sinceT2, err := db.ListTasks(TaskFilter{Since: t2})
+ if err != nil {
+ t.Fatalf("ListTasks(since T2): %v", err)
+ }
+ if len(sinceT2) != 1 {
+ t.Errorf("expected 1 task (T3), got %d", len(sinceT2))
+ }
+
+ // Fetch since T3
+ sinceT3, err := db.ListTasks(TaskFilter{Since: t3})
+ if err != nil {
+ t.Fatalf("ListTasks(since T3): %v", err)
+ }
+ if len(sinceT3) != 0 {
+ t.Errorf("expected 0 tasks, got %d", len(sinceT3))
}
}