diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/server.go | 18 | ||||
| -rw-r--r-- | internal/storage/db.go | 31 | ||||
| -rw-r--r-- | internal/storage/db_test.go | 105 |
3 files changed, 149 insertions, 5 deletions
diff --git a/internal/api/server.go b/internal/api/server.go index 8290738..59d59eb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -384,7 +384,15 @@ func (s *Server) handleListWorkspaces(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + lastUpdated, err := s.store.GetMaxUpdatedAt() + if err != nil { + s.logger.Error("failed to get max updated_at", "error", err) + lastUpdated = time.Time{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "last_updated": lastUpdated, + }) } func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { @@ -477,6 +485,14 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) { } filter.State = ts } + if since := r.URL.Query().Get("since"); since != "" { + t, err := time.Parse(time.RFC3339, since) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid since: " + since}) + return + } + filter.Since = t + } tasks, err := s.store.ListTasks(filter) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) 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)) } } |
