diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-06 23:55:07 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-06 23:55:07 +0000 |
| commit | fd42a54d96fcd3342941caaeb61a4b0d5d3f1b4f (patch) | |
| tree | 0b9ef3b7f0ac3981aa310435d014c9f5e21089d4 | |
| parent | 7d4890cde802974b94db24071f63e7733c3670fd (diff) | |
recover: restore untracked work from recovery branch (no Gemini changes)
Recovered files with no Claude→Agent contamination:
- docs/adr/002-task-state-machine.md
- internal/api/logs.go/logs_test.go: task-level log streaming endpoint
- internal/api/validate.go/validate_test.go: POST /api/tasks/validate
- internal/api/server_test.go, storage/db_test.go: expanded test coverage
- scripts/reset-failed-tasks, reset-running-tasks
- web/app.js, index.html, style.css: frontend improvements
- web/test/: active-tasks-tab, delete-button, filter-tabs, sort-tasks tests
Manually applied from server.go diff (skipping Claude→Agent rename):
- taskLogStore field + validateCmdPath field
- DELETE /api/tasks/{id} route + handleDeleteTask
- GET /api/tasks/{id}/logs/stream route
- POST /api/tasks/{id}/resume route + handleResumeTimedOutTask
- handleCancelTask: allow cancelling PENDING/QUEUED tasks directly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | docs/adr/002-task-state-machine.md | 177 | ||||
| -rw-r--r-- | internal/api/executions.go | 100 | ||||
| -rw-r--r-- | internal/api/executions_test.go | 280 | ||||
| -rw-r--r-- | internal/api/logs.go | 52 | ||||
| -rw-r--r-- | internal/api/logs_test.go | 175 | ||||
| -rw-r--r-- | internal/api/scripts.go | 38 | ||||
| -rw-r--r-- | internal/api/scripts_test.go | 69 | ||||
| -rw-r--r-- | internal/api/server.go | 92 | ||||
| -rw-r--r-- | internal/api/server_test.go | 182 | ||||
| -rw-r--r-- | internal/api/validate.go | 125 | ||||
| -rw-r--r-- | internal/api/validate_test.go | 90 | ||||
| -rw-r--r-- | internal/storage/db.go | 55 | ||||
| -rw-r--r-- | internal/storage/db_test.go | 58 | ||||
| -rwxr-xr-x | scripts/reset-failed-tasks | 5 | ||||
| -rwxr-xr-x | scripts/reset-running-tasks | 5 | ||||
| -rw-r--r-- | web/app.js | 103 | ||||
| -rw-r--r-- | web/index.html | 10 | ||||
| -rw-r--r-- | web/style.css | 166 | ||||
| -rw-r--r-- | web/test/active-tasks-tab.test.mjs | 68 | ||||
| -rw-r--r-- | web/test/delete-button.test.mjs | 60 | ||||
| -rw-r--r-- | web/test/filter-tabs.test.mjs | 90 | ||||
| -rw-r--r-- | web/test/running-view.test.mjs | 295 | ||||
| -rw-r--r-- | web/test/sort-tasks.test.mjs | 88 | ||||
| -rw-r--r-- | web/test/task-actions.test.mjs | 29 |
24 files changed, 2341 insertions, 71 deletions
diff --git a/docs/adr/002-task-state-machine.md b/docs/adr/002-task-state-machine.md new file mode 100644 index 0000000..debf7b0 --- /dev/null +++ b/docs/adr/002-task-state-machine.md @@ -0,0 +1,177 @@ +# ADR-002: Task State Machine Design + +## Status +Accepted + +## Context +Claudomator tasks move through a well-defined lifecycle: from creation through +queuing, execution, and final resolution. The lifecycle must handle asynchronous +execution (subprocess), user interaction (review, Q&A), retries, and cancellation. + +## States + +| State | Meaning | +|---|---| +| `PENDING` | Task created; not yet submitted for execution | +| `QUEUED` | Submitted to executor pool; goroutine slot may be waiting | +| `RUNNING` | Claude subprocess is actively executing | +| `READY` | Top-level task completed execution; awaiting user accept/reject | +| `COMPLETED` | Task is fully done (terminal) | +| `FAILED` | Execution error occurred; eligible for retry (terminal if retries exhausted) | +| `TIMED_OUT` | Task exceeded configured timeout; resumable or retryable (terminal if not resumed) | +| `CANCELLED` | Explicitly cancelled by user or API (terminal) | +| `BUDGET_EXCEEDED` | Exceeded `max_budget_usd` (terminal) | +| `BLOCKED` | Agent paused and wrote a question file; awaiting user answer | + +Terminal states with no outgoing transitions: `COMPLETED`, `CANCELLED`, `BUDGET_EXCEEDED`. + +## State Transition Diagram + +``` + ┌─────────┐ + │ PENDING │◄───────────────────────────────┐ + └────┬────┘ │ + POST /run │ POST /reject │ + POST /cancel │ │ + ┌────▼────┐ ┌──────┴─────┐ + ┌────────┤ QUEUED ├─────────────┐ │ READY │ + │ └────┬────┘ │ └──────┬─────┘ + POST /cancel │ │ POST /accept │ + │ pool picks up │ ▼ + ▼ ▼ │ ┌─────────────┐ + ┌──────────┐ ┌─────────┐ │ │ COMPLETED │ + │CANCELLED │◄──┤ RUNNING ├──────────────┘ └─────────────┘ + └──────────┘ └────┬────┘ + │ + ┌───────────────┼───────────────────┬───────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌──────────┐ ┌──────────────┐ ┌─────────────┐ ┌─────────┐ + │ FAILED │ │ TIMED_OUT │ │ BUDGET │ │ BLOCKED │ + └────┬─────┘ └──────┬───────┘ │ _EXCEEDED │ └────┬────┘ + │ │ └─────────────┘ │ + retry │ resume/ │ POST /answer + │ retry │ │ + └────────┬───────┘ │ + ▼ │ + QUEUED ◄────────────────────────────────────┘ +``` + +### Transition Table + +| From | To | Trigger | +|---|---|---| +| `PENDING` | `QUEUED` | `POST /api/tasks/{id}/run` | +| `PENDING` | `CANCELLED` | `POST /api/tasks/{id}/cancel` | +| `QUEUED` | `RUNNING` | Pool goroutine starts execution | +| `QUEUED` | `CANCELLED` | `POST /api/tasks/{id}/cancel` | +| `RUNNING` | `READY` | Runner exits 0, no question file, top-level task (`parent_task_id == ""`) | +| `RUNNING` | `COMPLETED` | Runner exits 0, no question file, subtask (`parent_task_id != ""`) | +| `RUNNING` | `FAILED` | Runner exits non-zero or stream signals `is_error: true` | +| `RUNNING` | `TIMED_OUT` | Context deadline exceeded (`context.DeadlineExceeded`) | +| `RUNNING` | `CANCELLED` | Context cancelled (`context.Canceled`) | +| `RUNNING` | `BUDGET_EXCEEDED` | `--max-budget-usd` exceeded (signalled by runner) | +| `RUNNING` | `BLOCKED` | Runner exits 0 but left a `question.json` file in log dir | +| `READY` | `COMPLETED` | `POST /api/tasks/{id}/accept` | +| `READY` | `PENDING` | `POST /api/tasks/{id}/reject` (with optional comment) | +| `FAILED` | `QUEUED` | Retry (manual re-run via `POST /api/tasks/{id}/run`) | +| `TIMED_OUT` | `QUEUED` | `POST /api/tasks/{id}/resume` (resumes with session ID) | +| `BLOCKED` | `QUEUED` | `POST /api/tasks/{id}/answer` (resumes with user answer) | + +## Implementation + +**Validation:** `task.ValidTransition(from, to State) bool` +(`internal/task/task.go:93`) — called by API handlers before every state change. + +**State writes:** `storage.DB.UpdateTaskState(id, state)` — single source of +write; called by both API handlers and the executor pool. + +**Execution outcome → state mapping** (in `executor.Pool.execute` and `executeResume`): + +``` +runner.Run() returns nil AND parent_task_id == "" → READY +runner.Run() returns nil AND parent_task_id != "" → COMPLETED +runner.Run() returns *BlockedError → BLOCKED (question stored) +ctx.Err() == DeadlineExceeded → TIMED_OUT +ctx.Err() == Canceled → CANCELLED +any other error → FAILED +``` + +## Key Invariants + +1. **`READY` is top-level only.** Subtasks (tasks with `parent_task_id != ""`) skip + `READY` and go directly to `COMPLETED` on success. This allows parent review + flows without requiring per-subtask acknowledgement. + +2. **`BLOCKED` requires a `session_id`.** The executor persists `session_id` in + the execution record at start time. `POST /answer` retrieves it via + `GetLatestExecution` to resume the correct Claude session. + +3. **`DELETE` is blocked for `RUNNING` and `QUEUED` tasks.** (`server.go:127`) + +4. **`CANCELLED` is reachable from `PENDING`, `QUEUED`, and `RUNNING`.** For + `RUNNING` tasks, `pool.Cancel(taskID)` sends a context cancellation signal; + the state transition happens asynchronously when the goroutine exits. For + `PENDING`/`QUEUED` tasks, `UpdateTaskState` is called directly. + +5. **Dependency waiting.** Tasks with `depends_on` stay conceptually in `QUEUED` + (goroutine running but not executing) while `waitForDependencies` polls until + all dependencies reach `COMPLETED`. If any dependency reaches a terminal + failure state, the waiting task transitions to `FAILED`. + +6. **Retry re-enters at `QUEUED`.** `FAILED → QUEUED` and `TIMED_OUT → QUEUED` + are the only back-edges. Retry is manual (caller must call `/run` again); the + `RetryConfig.MaxAttempts` field exists but enforcement is left to callers. + +## Side Effects on Transition + +| Transition | Side effects | +|---|---| +| → `QUEUED` | `pool.Submit()` called; execution record created in DB with log paths | +| → `RUNNING` | `store.UpdateTaskState`; cancel func registered in `pool.cancels` | +| → `BLOCKED` | `store.UpdateTaskQuestion(taskID, questionJSON)`; session_id in execution | +| → `READY/COMPLETED/FAILED/TIMED_OUT/CANCELLED/BUDGET_EXCEEDED` | Execution end time set; `store.UpdateExecution`; result broadcast via WebSocket (`pool.resultCh → forwardResults → hub.Broadcast`) | +| `READY → PENDING` (reject) | `store.RejectTask(id, comment)` — stores `rejection_comment` | +| `BLOCKED → QUEUED` (answer) | `store.UpdateTaskQuestion(taskID, "")` clears question; new `storage.Execution` created with `ResumeSessionID` + `ResumeAnswer` | + +## WebSocket Events + +Task lifecycle changes produce WebSocket broadcasts to all connected clients: + +- `task_completed` — emitted on any terminal or quasi-terminal transition (READY, + COMPLETED, FAILED, TIMED_OUT, CANCELLED, BUDGET_EXCEEDED, BLOCKED) +- `task_question` — emitted by `BroadcastQuestion` when an agent uses + `AskUserQuestion` (currently unused in the file-based flow; the file-based + mechanism is the primary BLOCKED path) + +## Known Limitations and Edge Cases + +- **`BUDGET_EXCEEDED` transition.** `BUDGET_EXCEEDED` appears in `terminalFailureStates` + (used by `waitForDependencies`) but has no outgoing transitions in `ValidTransition`, + making it permanently terminal. There is no `/resume` endpoint for it. + +- **Retry enforcement.** `RetryConfig.MaxAttempts` is stored but not enforced by + the pool. The API allows unlimited manual retries via `POST /run` from `FAILED`. + +- **`TIMED_OUT` resumability.** Only `POST /api/tasks/{id}/resume` resumes timed-out + tasks. Unlike BLOCKED, there is no question/answer — the resume message is + hardcoded: _"Your previous execution timed out. Please continue where you left off."_ + +- **Concurrent cancellation race.** If a task transitions `RUNNING → COMPLETED` + and `POST /cancel` is called in the same window, `pool.Cancel()` may return + `true` (cancel func still registered) even though the goroutine is finishing. + The goroutine's `ctx.Err()` check wins; the task ends in `COMPLETED`. + +## Relevant Code Locations + +| Concern | File | Lines | +|---|---|---| +| State constants | `internal/task/task.go` | 7–18 | +| `ValidTransition` | `internal/task/task.go` | 93–109 | +| State machine tests | `internal/task/task_test.go` | 8–72 | +| Pool execute | `internal/executor/executor.go` | 194–303 | +| Pool executeResume | `internal/executor/executor.go` | 116–185 | +| Dependency wait | `internal/executor/executor.go` | 305–340 | +| `BlockedError` | `internal/executor/claude.go` | 31–37 | +| Question file detection | `internal/executor/claude.go` | 103–110 | +| API state transitions | `internal/api/server.go` | 138–415 | diff --git a/internal/api/executions.go b/internal/api/executions.go new file mode 100644 index 0000000..d9214c0 --- /dev/null +++ b/internal/api/executions.go @@ -0,0 +1,100 @@ +package api + +import ( + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/thepeterstone/claudomator/internal/storage" +) + +// handleListRecentExecutions returns executions across all tasks. +// GET /api/executions?since=<RFC3339>&limit=<int>&task_id=<id> +func (s *Server) handleListRecentExecutions(w http.ResponseWriter, r *http.Request) { + since := time.Now().Add(-24 * time.Hour) + if v := r.URL.Query().Get("since"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + since = t + } + } + + limit := 50 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + + taskID := r.URL.Query().Get("task_id") + + execs, err := s.store.ListRecentExecutions(since, limit, taskID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if execs == nil { + execs = []*storage.RecentExecution{} + } + writeJSON(w, http.StatusOK, execs) +} + +// handleGetExecutionLog returns the tail of an execution log. +// GET /api/executions/{id}/log?tail=<int>&follow=<bool> +// If follow=true, streams as SSE (delegates to handleStreamLogs). +// If follow=false (default), returns last N raw lines as plain text. +func (s *Server) handleGetExecutionLog(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + exec, err := s.store.GetExecution(id) + if err != nil { + http.Error(w, "execution not found", http.StatusNotFound) + return + } + + if r.URL.Query().Get("follow") == "true" { + s.handleStreamLogs(w, r) + return + } + + tailN := 500 + if v := r.URL.Query().Get("tail"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + tailN = n + } + } + + if exec.StdoutPath == "" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + return + } + + content, err := tailLogFile(exec.StdoutPath, tailN) + if err != nil { + http.Error(w, "could not read log", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, content) +} + +// tailLogFile reads the last n lines from the file at path. +func tailLogFile(path string, n int) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + content := strings.TrimRight(string(data), "\n") + if content == "" { + return "", nil + } + lines := strings.Split(content, "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n"), nil +} diff --git a/internal/api/executions_test.go b/internal/api/executions_test.go new file mode 100644 index 0000000..a2bba21 --- /dev/null +++ b/internal/api/executions_test.go @@ -0,0 +1,280 @@ +package api + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +// createExecution inserts a test execution directly into the store. +func createExecution(t *testing.T, store *storage.DB, id, taskID string, start, end time.Time, status string) *storage.Execution { + t.Helper() + exec := &storage.Execution{ + ID: id, + TaskID: taskID, + StartTime: start, + EndTime: end, + ExitCode: 0, + Status: status, + CostUSD: 0.001, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatalf("createExecution: %v", err) + } + return exec +} + +func TestListRecentExecutions_Empty(t *testing.T) { + srv, _ := testServer(t) + + req := httptest.NewRequest("GET", "/api/executions", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var execs []storage.RecentExecution + if err := json.NewDecoder(w.Body).Decode(&execs); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(execs) != 0 { + t.Errorf("want 0 executions, got %d", len(execs)) + } +} + +func TestListRecentExecutions_ReturnsCorrectShape(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "exec-shape", task.StateCompleted) + now := time.Now().UTC().Truncate(time.Second) + end := now.Add(5 * time.Second) + exec := &storage.Execution{ + ID: "e-shape", + TaskID: tk.ID, + StartTime: now, + EndTime: end, + ExitCode: 0, + Status: "COMPLETED", + CostUSD: 0.042, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatalf("creating execution: %v", err) + } + + req := httptest.NewRequest("GET", "/api/executions", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var execs []storage.RecentExecution + if err := json.NewDecoder(w.Body).Decode(&execs); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(execs) != 1 { + t.Fatalf("want 1 execution, got %d", len(execs)) + } + e := execs[0] + if e.ID != "e-shape" { + t.Errorf("id: want e-shape, got %q", e.ID) + } + if e.TaskID != tk.ID { + t.Errorf("task_id: want %q, got %q", tk.ID, e.TaskID) + } + if e.TaskName != tk.Name { + t.Errorf("task_name: want %q, got %q", tk.Name, e.TaskName) + } + if e.State != "COMPLETED" { + t.Errorf("state: want COMPLETED, got %q", e.State) + } + if e.CostUSD != 0.042 { + t.Errorf("cost_usd: want 0.042, got %f", e.CostUSD) + } + if e.DurationMS == nil { + t.Error("duration_ms: want non-nil for completed execution") + } else if *e.DurationMS != 5000 { + t.Errorf("duration_ms: want 5000, got %d", *e.DurationMS) + } +} + +func TestListRecentExecutions_SinceFilter(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "exec-since", task.StateCompleted) + oldStart := time.Now().UTC().Add(-48 * time.Hour) + recentStart := time.Now().UTC() + + for i, start := range []time.Time{oldStart, recentStart} { + createExecution(t, store, fmt.Sprintf("e-since-%d", i), tk.ID, start, start.Add(time.Minute), "COMPLETED") + } + + since := time.Now().UTC().Add(-25 * time.Hour).Format(time.RFC3339) + req := httptest.NewRequest("GET", "/api/executions?since="+since, nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var execs []storage.RecentExecution + json.NewDecoder(w.Body).Decode(&execs) + if len(execs) != 1 { + t.Errorf("since filter: want 1 execution, got %d", len(execs)) + } +} + +func TestListRecentExecutions_TaskIDFilter(t *testing.T) { + srv, store := testServer(t) + + tk1 := createTaskWithState(t, store, "filter-t1", task.StateCompleted) + tk2 := createTaskWithState(t, store, "filter-t2", task.StateCompleted) + now := time.Now().UTC() + + createExecution(t, store, "e-filter-0", tk1.ID, now, now.Add(time.Minute), "COMPLETED") + createExecution(t, store, "e-filter-1", tk2.ID, now, now.Add(time.Minute), "COMPLETED") + + req := httptest.NewRequest("GET", "/api/executions?task_id="+tk1.ID, nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var execs []storage.RecentExecution + json.NewDecoder(w.Body).Decode(&execs) + if len(execs) != 1 { + t.Errorf("task_id filter: want 1 execution, got %d", len(execs)) + } + if len(execs) > 0 && execs[0].TaskID != tk1.ID { + t.Errorf("task_id filter: want task_id=%q, got %q", tk1.ID, execs[0].TaskID) + } +} + +func TestGetExecutionLog_NotFound(t *testing.T) { + srv, _ := testServer(t) + + req := httptest.NewRequest("GET", "/api/executions/nonexistent/log", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: want 404, got %d", w.Code) + } +} + +func TestGetExecutionLog_TailLines(t *testing.T) { + srv, store := testServer(t) + + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + var lines []string + for i := 0; i < 10; i++ { + lines = append(lines, fmt.Sprintf(`{"type":"text","line":%d}`, i)) + } + if err := os.WriteFile(logPath, []byte(strings.Join(lines, "\n")+"\n"), 0600); err != nil { + t.Fatal(err) + } + + tk := createTaskWithState(t, store, "log-tail", task.StateCompleted) + exec := &storage.Execution{ + ID: "e-tail", + TaskID: tk.ID, + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC().Add(time.Minute), + Status: "COMPLETED", + StdoutPath: logPath, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatalf("creating execution: %v", err) + } + + req := httptest.NewRequest("GET", "/api/executions/e-tail/log?tail=3", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("content-type: want text/plain, got %q", ct) + } + + scanner := bufio.NewScanner(w.Body) + var got []string + for scanner.Scan() { + if line := scanner.Text(); line != "" { + got = append(got, line) + } + } + if len(got) != 3 { + t.Errorf("tail=3: want 3 lines, got %d: %v", len(got), got) + } + if len(got) > 0 && !strings.Contains(got[0], `"line":7`) { + t.Errorf("first tail line: want line 7, got %q", got[0]) + } +} + +func TestGetExecutionLog_FollowSSEHeaders(t *testing.T) { + srv, store := testServer(t) + + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + os.WriteFile(logPath, []byte(`{"type":"result","cost_usd":0.001}`+"\n"), 0600) + + tk := createTaskWithState(t, store, "log-sse", task.StateCompleted) + exec := &storage.Execution{ + ID: "e-sse", + TaskID: tk.ID, + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC().Add(time.Minute), + Status: "COMPLETED", + StdoutPath: logPath, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatalf("creating execution: %v", err) + } + + req := httptest.NewRequest("GET", "/api/executions/e-sse/log?follow=true", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if ct := w.Header().Get("Content-Type"); ct != "text/event-stream" { + t.Errorf("content-type: want text/event-stream, got %q", ct) + } + if cc := w.Header().Get("Cache-Control"); cc != "no-cache" { + t.Errorf("cache-control: want no-cache, got %q", cc) + } +} + +func TestListTasks_ReturnsStateField(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "state-check", task.StateRunning) + + req := httptest.NewRequest("GET", "/api/tasks", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + var tasks []map[string]interface{} + json.NewDecoder(w.Body).Decode(&tasks) + if len(tasks) != 1 { + t.Fatalf("want 1 task, got %d", len(tasks)) + } + if state, ok := tasks[0]["state"]; !ok || state == nil { + t.Error("task response missing 'state' field") + } + if tasks[0]["state"] != string(task.StateRunning) { + t.Errorf("state: want %q, got %q", task.StateRunning, tasks[0]["state"]) + } +} diff --git a/internal/api/logs.go b/internal/api/logs.go index 0354943..1ba4b00 100644 --- a/internal/api/logs.go +++ b/internal/api/logs.go @@ -19,6 +19,12 @@ type logStore interface { GetExecution(id string) (*storage.Execution, error) } +// taskLogStore is the minimal storage interface needed by handleStreamTaskLogs. +type taskLogStore interface { + GetExecution(id string) (*storage.Execution, error) + GetLatestExecution(taskID string) (*storage.Execution, error) +} + const maxTailDuration = 30 * time.Minute var terminalStates = map[string]bool{ @@ -46,6 +52,52 @@ type logContentBlock struct { Input json.RawMessage `json:"input,omitempty"` } +// handleStreamTaskLogs streams the latest execution log for a task via SSE. +// GET /api/tasks/{id}/logs/stream +func (s *Server) handleStreamTaskLogs(w http.ResponseWriter, r *http.Request) { + taskID := r.PathValue("id") + exec, err := s.taskLogStore.GetLatestExecution(taskID) + if err != nil { + http.Error(w, "task not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + ctx := r.Context() + + if terminalStates[exec.Status] { + if exec.StdoutPath != "" { + if f, err := os.Open(exec.StdoutPath); err == nil { + defer f.Close() + var offset int64 + for _, line := range readNewLines(f, &offset) { + select { + case <-ctx.Done(): + return + default: + } + emitLogLine(w, flusher, line) + } + } + } + } else if exec.Status == string(task.StateRunning) { + tailRunningExecution(ctx, w, flusher, s.taskLogStore, exec) + return + } + + fmt.Fprintf(w, "event: done\ndata: {}\n\n") + flusher.Flush() +} + // handleStreamLogs streams parsed execution log content via SSE. // GET /api/executions/{id}/logs/stream func (s *Server) handleStreamLogs(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/logs_test.go b/internal/api/logs_test.go index 4a0c9fd..52fa168 100644 --- a/internal/api/logs_test.go +++ b/internal/api/logs_test.go @@ -14,6 +14,26 @@ import ( "github.com/thepeterstone/claudomator/internal/storage" ) +// mockTaskLogStore implements taskLogStore for testing handleStreamTaskLogs. +type mockTaskLogStore struct { + getExecution func(id string) (*storage.Execution, error) + getLatestExecution func(taskID string) (*storage.Execution, error) +} + +func (m *mockTaskLogStore) GetExecution(id string) (*storage.Execution, error) { + return m.getExecution(id) +} + +func (m *mockTaskLogStore) GetLatestExecution(taskID string) (*storage.Execution, error) { + return m.getLatestExecution(taskID) +} + +func taskLogsMux(srv *Server) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/tasks/{id}/logs/stream", srv.handleStreamTaskLogs) + return mux +} + // mockLogStore implements logStore for testing. type mockLogStore struct { fn func(id string) (*storage.Execution, error) @@ -170,3 +190,158 @@ func TestHandleStreamLogs_RunningState_LiveTail(t *testing.T) { t.Errorf("body does not end with done event; got:\n%s", body) } } + +// --- Task-level SSE log streaming tests (handleStreamTaskLogs) --- + +// TestHandleStreamTaskLogs_TaskNotFound verifies that a task with no executions yields 404. +func TestHandleStreamTaskLogs_TaskNotFound(t *testing.T) { + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return nil, errors.New("not found") + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/nonexistent/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: want 404, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestHandleStreamTaskLogs_NoStdoutPath_EmitsDone verifies that a completed execution with no +// stdout log path emits only the done sentinel event. +func TestHandleStreamTaskLogs_NoStdoutPath_EmitsDone(t *testing.T) { + exec := &storage.Execution{ + ID: "exec-task-empty", + TaskID: "task-no-log", + StartTime: time.Now(), + Status: "COMPLETED", + // StdoutPath intentionally empty + } + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return exec, nil + }, + getExecution: func(id string) (*storage.Execution, error) { + return exec, nil + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/task-no-log/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Errorf("Content-Type: want text/event-stream, got %q", ct) + } + body := w.Body.String() + if body != "event: done\ndata: {}\n\n" { + t.Errorf("want only done event, got:\n%s", body) + } +} + +// TestHandleStreamTaskLogs_TerminalExecution_EmitsEventsAndDone verifies that a COMPLETED +// execution streams SSE events and ends with a done event. +func TestHandleStreamTaskLogs_TerminalExecution_EmitsEventsAndDone(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + lines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Task output here"}]}}`, + `{"type":"result","cost_usd":0.007}`, + }, "\n") + "\n" + if err := os.WriteFile(logPath, []byte(lines), 0600); err != nil { + t.Fatal(err) + } + + exec := &storage.Execution{ + ID: "exec-task-done", + TaskID: "task-done", + StartTime: time.Now(), + Status: "COMPLETED", + StdoutPath: logPath, + } + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return exec, nil + }, + getExecution: func(id string) (*storage.Execution, error) { + return exec, nil + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/task-done/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, `"Task output here"`) { + t.Errorf("expected text event content in body; got:\n%s", body) + } + if !strings.Contains(body, `"type":"cost"`) { + t.Errorf("expected cost event in body; got:\n%s", body) + } + if !strings.HasSuffix(body, "event: done\ndata: {}\n\n") { + t.Errorf("body does not end with done event; got:\n%s", body) + } +} + +// TestHandleStreamTaskLogs_RunningExecution_LiveTails verifies that a RUNNING execution is +// live-tailed and a done event is emitted once it transitions to a terminal state. +func TestHandleStreamTaskLogs_RunningExecution_LiveTails(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + logLines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Still running..."}]}}`, + `{"type":"result","cost_usd":0.003}`, + }, "\n") + "\n" + if err := os.WriteFile(logPath, []byte(logLines), 0600); err != nil { + t.Fatal(err) + } + + runningExec := &storage.Execution{ + ID: "exec-task-running", + TaskID: "task-running", + StartTime: time.Now(), + Status: "RUNNING", + StdoutPath: logPath, + } + + // getLatestExecution is called once (initial lookup); getExecution polls for state change. + var pollCount atomic.Int32 + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return runningExec, nil + }, + getExecution: func(id string) (*storage.Execution, error) { + n := pollCount.Add(1) + if n <= 1 { + return runningExec, nil + } + completed := *runningExec + completed.Status = "COMPLETED" + return &completed, nil + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/task-running/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, `"Still running..."`) { + t.Errorf("expected live-tail text in body; got:\n%s", body) + } + if !strings.HasSuffix(body, "event: done\ndata: {}\n\n") { + t.Errorf("body does not end with done event; got:\n%s", body) + } +} diff --git a/internal/api/scripts.go b/internal/api/scripts.go index 492570b..9afbb75 100644 --- a/internal/api/scripts.go +++ b/internal/api/scripts.go @@ -18,6 +18,13 @@ func (s *Server) startNextTaskScriptPath() string { return filepath.Join(s.workDir, "scripts", "start-next-task") } +func (s *Server) deployScriptPath() string { + if s.deployScript != "" { + return s.deployScript + } + return filepath.Join(s.workDir, "scripts", "deploy") +} + func (s *Server) handleStartNextTask(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), scriptTimeout) defer cancel() @@ -48,3 +55,34 @@ func (s *Server) handleStartNextTask(w http.ResponseWriter, r *http.Request) { "exit_code": exitCode, }) } + +func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), scriptTimeout) + defer cancel() + + scriptPath := s.deployScriptPath() + cmd := exec.CommandContext(ctx, scriptPath) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + s.logger.Error("deploy: script execution failed", "error", err, "path", scriptPath) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "script execution failed: " + err.Error(), + }) + return + } + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "output": stdout.String() + stderr.String(), + "exit_code": exitCode, + }) +} diff --git a/internal/api/scripts_test.go b/internal/api/scripts_test.go new file mode 100644 index 0000000..7da133e --- /dev/null +++ b/internal/api/scripts_test.go @@ -0,0 +1,69 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestHandleDeploy_Success(t *testing.T) { + srv, _ := testServer(t) + + // Create a fake deploy script that exits 0 and prints output. + scriptDir := t.TempDir() + scriptPath := filepath.Join(scriptDir, "deploy") + script := "#!/bin/sh\necho 'deployed successfully'" + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + srv.deployScript = scriptPath + + req := httptest.NewRequest("POST", "/api/scripts/deploy", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var body map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["exit_code"] != float64(0) { + t.Errorf("exit_code: want 0, got %v", body["exit_code"]) + } + output, _ := body["output"].(string) + if output == "" { + t.Errorf("expected non-empty output") + } +} + +func TestHandleDeploy_ScriptFails(t *testing.T) { + srv, _ := testServer(t) + + scriptDir := t.TempDir() + scriptPath := filepath.Join(scriptDir, "deploy") + script := "#!/bin/sh\necho 'build failed' && exit 1" + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + srv.deployScript = scriptPath + + req := httptest.NewRequest("POST", "/api/scripts/deploy", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var body map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["exit_code"] == float64(0) { + t.Errorf("expected non-zero exit_code") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 5b027e4..dd4627c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -19,23 +19,27 @@ import ( // Server provides the REST API and WebSocket endpoint for Claudomator. type Server struct { store *storage.DB - logStore logStore // injectable for tests; defaults to store + logStore logStore // injectable for tests; defaults to store + taskLogStore taskLogStore // injectable for tests; defaults to store pool *executor.Pool hub *Hub logger *slog.Logger mux *http.ServeMux claudeBinPath string // path to claude binary; defaults to "claude" elaborateCmdPath string // overrides claudeBinPath; used in tests + validateCmdPath string // overrides claudeBinPath for validate; used in tests startNextTaskScript string // path to start-next-task script; overridden in tests + deployScript string // path to deploy script; overridden in tests workDir string // working directory injected into elaborate system prompt } func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath string) *Server { wd, _ := os.Getwd() s := &Server{ - store: store, - logStore: store, - pool: pool, + store: store, + logStore: store, + taskLogStore: store, + pool: pool, hub: NewHub(), logger: logger, mux: http.NewServeMux(), @@ -57,6 +61,7 @@ func (s *Server) StartHub() { func (s *Server) routes() { s.mux.HandleFunc("POST /api/tasks/elaborate", s.handleElaborateTask) + s.mux.HandleFunc("POST /api/tasks/validate", s.handleValidateTask) s.mux.HandleFunc("POST /api/tasks", s.handleCreateTask) s.mux.HandleFunc("GET /api/tasks", s.handleListTasks) s.mux.HandleFunc("GET /api/tasks/{id}", s.handleGetTask) @@ -64,11 +69,13 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/tasks/{id}/cancel", s.handleCancelTask) s.mux.HandleFunc("POST /api/tasks/{id}/accept", s.handleAcceptTask) s.mux.HandleFunc("POST /api/tasks/{id}/reject", s.handleRejectTask) + s.mux.HandleFunc("DELETE /api/tasks/{id}", s.handleDeleteTask) s.mux.HandleFunc("GET /api/tasks/{id}/subtasks", s.handleListSubtasks) s.mux.HandleFunc("GET /api/tasks/{id}/executions", s.handleListExecutions) s.mux.HandleFunc("GET /api/executions", s.handleListRecentExecutions) s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution) s.mux.HandleFunc("GET /api/executions/{id}/log", s.handleGetExecutionLog) + s.mux.HandleFunc("GET /api/tasks/{id}/logs/stream", s.handleStreamTaskLogs) s.mux.HandleFunc("GET /api/executions/{id}/logs/stream", s.handleStreamLogs) s.mux.HandleFunc("GET /api/templates", s.handleListTemplates) s.mux.HandleFunc("POST /api/templates", s.handleCreateTemplate) @@ -76,7 +83,9 @@ func (s *Server) routes() { s.mux.HandleFunc("PUT /api/templates/{id}", s.handleUpdateTemplate) s.mux.HandleFunc("DELETE /api/templates/{id}", s.handleDeleteTemplate) s.mux.HandleFunc("POST /api/tasks/{id}/answer", s.handleAnswerQuestion) + s.mux.HandleFunc("POST /api/tasks/{id}/resume", s.handleResumeTimedOutTask) s.mux.HandleFunc("POST /api/scripts/start-next-task", s.handleStartNextTask) + s.mux.HandleFunc("POST /api/scripts/deploy", s.handleDeploy) s.mux.HandleFunc("GET /api/ws", s.handleWebSocket) s.mux.HandleFunc("GET /api/health", s.handleHealth) s.mux.Handle("GET /", http.FileServerFS(webui.Files)) @@ -112,17 +121,46 @@ func (s *Server) BroadcastQuestion(taskID, toolUseID string, questionData json.R s.hub.Broadcast(data) } +func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + t, err := s.store.GetTask(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + if t.State == task.StateRunning || t.State == task.StateQueued { + writeJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete a running or queued task"}) + return + } + if err := s.store.DeleteTask(id); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) handleCancelTask(w http.ResponseWriter, r *http.Request) { taskID := r.PathValue("id") - if _, err := s.store.GetTask(taskID); err != nil { + tk, err := s.store.GetTask(taskID) + if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) return } - if !s.pool.Cancel(taskID) { - writeJSON(w, http.StatusConflict, map[string]string{"error": "task is not running"}) + // If the task is actively running in the pool, cancel it there. + if s.pool.Cancel(taskID) { + writeJSON(w, http.StatusOK, map[string]string{"status": "cancelling"}) + return + } + // For non-running tasks (PENDING, QUEUED), transition directly to CANCELLED. + if !task.ValidTransition(tk.State, task.StateCancelled) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "task cannot be cancelled from state " + string(tk.State)}) return } - writeJSON(w, http.StatusOK, map[string]string{"status": "cancelling"}) + if err := s.store.UpdateTaskState(taskID, task.StateCancelled); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to cancel task"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"}) } func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) { @@ -176,6 +214,44 @@ func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "queued"}) } +func (s *Server) handleResumeTimedOutTask(w http.ResponseWriter, r *http.Request) { + taskID := r.PathValue("id") + + tk, err := s.store.GetTask(taskID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + if tk.State != task.StateTimedOut { + writeJSON(w, http.StatusConflict, map[string]string{"error": "task is not timed out"}) + return + } + + latest, err := s.store.GetLatestExecution(taskID) + if err != nil || latest.SessionID == "" { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no resumable session found"}) + return + } + + s.store.UpdateTaskState(taskID, task.StateQueued) + + resumeExec := &storage.Execution{ + ID: uuid.New().String(), + TaskID: taskID, + ResumeSessionID: latest.SessionID, + ResumeAnswer: "Your previous execution timed out. Please continue where you left off and complete the task.", + } + if err := s.pool.SubmitResume(context.Background(), tk, resumeExec); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusAccepted, map[string]string{ + "message": "task queued for resume", + "task_id": taskID, + }) +} + func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 2325b0b..e012bc1 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -463,6 +463,73 @@ func TestHandleStartNextTask_NoTask(t *testing.T) { } } +func TestResumeTimedOut_NoTask_Returns404(t *testing.T) { + srv, _ := testServer(t) + + req := httptest.NewRequest("POST", "/api/tasks/nonexistent/resume", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: want 404, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestResumeTimedOut_TaskNotTimedOut_Returns409(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "resume-task-1", task.StatePending) + + req := httptest.NewRequest("POST", "/api/tasks/resume-task-1/resume", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("status: want 409, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestResumeTimedOut_NoSession_Returns500(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "resume-task-2", task.StateTimedOut) + + // No execution created — so no session ID. + req := httptest.NewRequest("POST", "/api/tasks/resume-task-2/resume", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("status: want 500, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestResumeTimedOut_Success_Returns202(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "resume-task-3", task.StateTimedOut) + + exec := &storage.Execution{ + ID: "exec-timedout-1", + TaskID: "resume-task-3", + SessionID: "550e8400-e29b-41d4-a716-446655440002", + Status: "TIMED_OUT", + } + if err := store.CreateExecution(exec); err != nil { + t.Fatalf("create execution: %v", err) + } + + req := httptest.NewRequest("POST", "/api/tasks/resume-task-3/resume", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Errorf("status: want 202, got %d; body: %s", w.Code, w.Body.String()) + } + + got, _ := store.GetTask("resume-task-3") + if got.State != task.StateQueued && got.State != task.StateRunning && got.State != task.StateReady { + t.Errorf("task state: want QUEUED/RUNNING/READY after resume, got %v", got.State) + } +} + func TestHandleStartNextTask_ScriptNotFound(t *testing.T) { srv, _ := testServer(t) srv.startNextTaskScript = "/nonexistent/start-next-task" @@ -475,3 +542,118 @@ func TestHandleStartNextTask_ScriptNotFound(t *testing.T) { t.Errorf("want 500, got %d; body: %s", w.Code, w.Body.String()) } } + +func TestDeleteTask_Success(t *testing.T) { + srv, store := testServer(t) + + // Create a task to delete. + created := createTestTask(t, srv, `{"name":"Delete Me","claude":{"instructions":"x","model":"sonnet"}}`) + + req := httptest.NewRequest("DELETE", "/api/tasks/"+created.ID, nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String()) + } + + _, err := store.GetTask(created.ID) + if err == nil { + t.Error("task should be deleted from store") + } +} + +func TestDeleteTask_NotFound(t *testing.T) { + srv, _ := testServer(t) + + req := httptest.NewRequest("DELETE", "/api/tasks/does-not-exist", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("want 404, got %d", w.Code) + } +} + +func TestDeleteTask_RunningTaskRejected(t *testing.T) { + srv, store := testServer(t) + + created := createTestTask(t, srv, `{"name":"Running Task","claude":{"instructions":"x","model":"sonnet"}}`) + store.UpdateTaskState(created.ID, "RUNNING") + + req := httptest.NewRequest("DELETE", "/api/tasks/"+created.ID, nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("want 409 for running task, got %d", w.Code) + } +} + +// createTestTask is a helper that POSTs a task and returns the parsed Task. +func createTestTask(t *testing.T, srv *Server, payload string) task.Task { + t.Helper() + req := httptest.NewRequest("POST", "/api/tasks", bytes.NewBufferString(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("createTestTask: want 201, got %d; body: %s", w.Code, w.Body.String()) + } + var tk task.Task + json.NewDecoder(w.Body).Decode(&tk) + return tk +} + +func TestServer_CancelTask_Pending_TransitionsToCancelled(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "cancel-pending-1", task.StatePending) + + req := httptest.NewRequest("POST", "/api/tasks/cancel-pending-1/cancel", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + updated, err := store.GetTask("cancel-pending-1") + if err != nil { + t.Fatal(err) + } + if updated.State != task.StateCancelled { + t.Errorf("state: want CANCELLED, got %s", updated.State) + } +} + +func TestServer_CancelTask_Queued_TransitionsToCancelled(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "cancel-queued-1", task.StateQueued) + + req := httptest.NewRequest("POST", "/api/tasks/cancel-queued-1/cancel", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + updated, err := store.GetTask("cancel-queued-1") + if err != nil { + t.Fatal(err) + } + if updated.State != task.StateCancelled { + t.Errorf("state: want CANCELLED, got %s", updated.State) + } +} + +func TestServer_CancelTask_Completed_Returns409(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "cancel-completed-1", task.StateCompleted) + + req := httptest.NewRequest("POST", "/api/tasks/cancel-completed-1/cancel", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("status: want 409, got %d; body: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/api/validate.go b/internal/api/validate.go new file mode 100644 index 0000000..d8ebde9 --- /dev/null +++ b/internal/api/validate.go @@ -0,0 +1,125 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "time" +) + +const validateTimeout = 20 * time.Second + +const validateSystemPrompt = `You are a task instruction reviewer for Claudomator, an AI task runner that executes tasks by running Claude as a subprocess. + +Analyze the given task name and instructions for clarity and completeness. + +Output ONLY a valid JSON object (no markdown fences, no prose, no explanation): + +{ + "clarity": "clear" | "warning" | "blocking", + "ready": boolean — true if task can proceed without clarification, + "summary": string — 1-2 sentence assessment, + "questions": [{"text": string, "severity": "blocking" | "minor"}], + "suggestions": [string] +} + +clarity definitions: +- "clear": instructions are specific, actionable, and complete +- "warning": minor ambiguities exist but task can reasonably proceed +- "blocking": critical information is missing; task cannot succeed without clarification` + +type validateResult struct { + Clarity string `json:"clarity"` + Ready bool `json:"ready"` + Questions []validateQuestion `json:"questions"` + Suggestions []string `json:"suggestions"` + Summary string `json:"summary"` +} + +type validateQuestion struct { + Severity string `json:"severity"` + Text string `json:"text"` +} + +func (s *Server) validateBinaryPath() string { + if s.validateCmdPath != "" { + return s.validateCmdPath + } + return s.claudeBinaryPath() +} + +func (s *Server) handleValidateTask(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + Claude struct { + Instructions string `json:"instructions"` + WorkingDir string `json:"working_dir"` + AllowedTools []string `json:"allowed_tools"` + } `json:"claude"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if input.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + if input.Claude.Instructions == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "instructions are required"}) + return + } + + userMsg := fmt.Sprintf("Task name: %s\n\nInstructions:\n%s", input.Name, input.Claude.Instructions) + if input.Claude.WorkingDir != "" { + userMsg += fmt.Sprintf("\n\nWorking directory: %s", input.Claude.WorkingDir) + } + if len(input.Claude.AllowedTools) > 0 { + userMsg += fmt.Sprintf("\n\nAllowed tools: %v", input.Claude.AllowedTools) + } + + ctx, cancel := context.WithTimeout(r.Context(), validateTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, s.validateBinaryPath(), + "-p", userMsg, + "--system-prompt", validateSystemPrompt, + "--output-format", "json", + "--model", "haiku", + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + s.logger.Error("validate: claude subprocess failed", "error", err, "stderr", stderr.String()) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": fmt.Sprintf("validation failed: %v", err), + }) + return + } + + var wrapper claudeJSONResult + if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil { + s.logger.Error("validate: failed to parse claude JSON wrapper", "error", err, "stdout", stdout.String()) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "validation failed: invalid JSON from claude", + }) + return + } + + var result validateResult + if err := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); err != nil { + s.logger.Error("validate: failed to parse validation result", "error", err, "result", wrapper.Result) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "validation failed: claude returned invalid result JSON", + }) + return + } + + writeJSON(w, http.StatusOK, result) +} diff --git a/internal/api/validate_test.go b/internal/api/validate_test.go new file mode 100644 index 0000000..5a1246b --- /dev/null +++ b/internal/api/validate_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestValidateTask_Success(t *testing.T) { + srv, _ := testServer(t) + + validResult := validateResult{ + Clarity: "clear", + Ready: true, + Summary: "Instructions are clear and actionable.", + Questions: []validateQuestion{}, + Suggestions: []string{}, + } + resultJSON, _ := json.Marshal(validResult) + wrapper := map[string]string{"result": string(resultJSON)} + wrapperJSON, _ := json.Marshal(wrapper) + srv.validateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) + + body := `{"name":"Test Task","claude":{"instructions":"Run go test ./... and report results."}}` + req := httptest.NewRequest("POST", "/api/tasks/validate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var result validateResult + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Clarity == "" { + t.Error("expected non-empty clarity field in response") + } +} + +func TestValidateTask_MissingInstructions(t *testing.T) { + srv, _ := testServer(t) + + body := `{"name":"Test Task","claude":{"instructions":""}}` + req := httptest.NewRequest("POST", "/api/tasks/validate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status: want 400, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestValidateTask_MissingName(t *testing.T) { + srv, _ := testServer(t) + + body := `{"name":"","claude":{"instructions":"Do something useful."}}` + req := httptest.NewRequest("POST", "/api/tasks/validate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status: want 400, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestValidateTask_BadJSONFromClaude(t *testing.T) { + srv, _ := testServer(t) + srv.validateCmdPath = createFakeClaude(t, "not valid json at all", 0) + + body := `{"name":"Test Task","claude":{"instructions":"Do something useful."}}` + req := httptest.NewRequest("POST", "/api/tasks/validate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("status: want 502, got %d; body: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/storage/db.go b/internal/storage/db.go index 1aac754..b3df696 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -409,6 +409,61 @@ func (s *DB) DeleteTask(id string) error { return nil } +// RecentExecution is returned by ListRecentExecutions (JOIN with tasks for name). +type RecentExecution struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + TaskName string `json:"task_name"` + State string `json:"state"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` + ExitCode int `json:"exit_code"` + CostUSD float64 `json:"cost_usd"` + StdoutPath string `json:"stdout_path"` +} + +// ListRecentExecutions returns executions since the given time, joined with task names. +// If taskID is non-empty, only executions for that task are returned. +func (s *DB) ListRecentExecutions(since time.Time, limit int, taskID string) ([]*RecentExecution, error) { + query := `SELECT e.id, e.task_id, t.name, e.status, e.start_time, e.end_time, e.exit_code, e.cost_usd, e.stdout_path + FROM executions e + JOIN tasks t ON e.task_id = t.id + WHERE e.start_time >= ?` + args := []interface{}{since.UTC()} + + if taskID != "" { + query += " AND e.task_id = ?" + args = append(args, taskID) + } + query += " ORDER BY e.start_time DESC LIMIT ?" + args = append(args, limit) + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []*RecentExecution + for rows.Next() { + var re RecentExecution + var endTime time.Time + var stdoutPath sql.NullString + if err := rows.Scan(&re.ID, &re.TaskID, &re.TaskName, &re.State, &re.StartedAt, &endTime, &re.ExitCode, &re.CostUSD, &stdoutPath); err != nil { + return nil, err + } + re.StdoutPath = stdoutPath.String + if !endTime.IsZero() { + re.FinishedAt = &endTime + ms := endTime.Sub(re.StartedAt).Milliseconds() + re.DurationMS = &ms + } + results = append(results, &re) + } + return results, rows.Err() +} + // UpdateTaskQuestion stores the pending question JSON on a task. // Pass empty string to clear the question after it has been answered. func (s *DB) UpdateTaskQuestion(taskID, questionJSON string) error { diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 395574c..fcdc529 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -473,6 +473,64 @@ func TestStorage_UpdateTaskQuestion(t *testing.T) { } } +func TestDeleteTask_RemovesTaskAndExecutions(t *testing.T) { + db := testDB(t) + now := time.Now().UTC() + db.CreateTask(makeTestTask("del-task", now)) + db.CreateExecution(&Execution{ID: "del-exec-1", TaskID: "del-task", StartTime: now, Status: "COMPLETED"}) + db.CreateExecution(&Execution{ID: "del-exec-2", TaskID: "del-task", StartTime: now.Add(time.Minute), Status: "COMPLETED"}) + + if err := db.DeleteTask("del-task"); err != nil { + t.Fatalf("DeleteTask: %v", err) + } + + _, err := db.GetTask("del-task") + if err == nil { + t.Error("expected error getting deleted task, got nil") + } + + execs, err := db.ListExecutions("del-task") + if err != nil { + t.Fatalf("ListExecutions: %v", err) + } + if len(execs) != 0 { + t.Errorf("want 0 executions after delete, got %d", len(execs)) + } +} + +func TestDeleteTask_CascadesSubtasks(t *testing.T) { + db := testDB(t) + now := time.Now().UTC() + + parent := makeTestTask("parent-del", now) + child := makeTestTask("child-del", now) + child.ParentTaskID = "parent-del" + + db.CreateTask(parent) + db.CreateTask(child) + + if err := db.DeleteTask("parent-del"); err != nil { + t.Fatalf("DeleteTask: %v", err) + } + + _, err := db.GetTask("parent-del") + if err == nil { + t.Error("parent should be deleted") + } + _, err = db.GetTask("child-del") + if err == nil { + t.Error("child should be deleted when parent is deleted") + } +} + +func TestDeleteTask_NotFound(t *testing.T) { + db := testDB(t) + err := db.DeleteTask("nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent task, got nil") + } +} + func TestStorage_GetLatestExecution(t *testing.T) { db := testDB(t) now := time.Now().UTC() diff --git a/scripts/reset-failed-tasks b/scripts/reset-failed-tasks new file mode 100755 index 0000000..eddfff0 --- /dev/null +++ b/scripts/reset-failed-tasks @@ -0,0 +1,5 @@ +#!/bin/bash + +DB_PATH="/site/doot.terst.org/data/claudomator.db" + +sqlite3 "$DB_PATH" "UPDATE tasks SET state = 'PENDING' WHERE state = 'FAILED';" diff --git a/scripts/reset-running-tasks b/scripts/reset-running-tasks new file mode 100755 index 0000000..99b0cce --- /dev/null +++ b/scripts/reset-running-tasks @@ -0,0 +1,5 @@ +#!/bin/bash + +DB_PATH="/site/doot.terst.org/data/claudomator.db" + +sqlite3 "$DB_PATH" "UPDATE tasks SET state = 'PENDING' WHERE state = 'RUNNING';" @@ -1,5 +1,5 @@ -const BASE_PATH = document.querySelector('meta[name="base-path"]')?.content ?? ''; -const API_BASE = window.location.origin + BASE_PATH; +const BASE_PATH = (typeof document !== 'undefined') ? document.querySelector('meta[name="base-path"]')?.content ?? '' : ''; +const API_BASE = (typeof window !== 'undefined') ? window.location.origin + BASE_PATH : ''; // ── Fetch ───────────────────────────────────────────────────────────────────── @@ -160,17 +160,56 @@ function createTaskCard(task) { return card; } +// ── Sort ────────────────────────────────────────────────────────────────────── + +function sortTasksByDate(tasks) { + return [...tasks].sort((a, b) => { + if (!a.created_at && !b.created_at) return 0; + if (!a.created_at) return 1; + if (!b.created_at) return -1; + return new Date(a.created_at) - new Date(b.created_at); + }); +} + // ── Filter ──────────────────────────────────────────────────────────────────── const HIDE_STATES = new Set(['COMPLETED', 'FAILED']); +const ACTIVE_STATES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']); +const DONE_STATES = new Set(['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']); -let showHiddenFold = false; +// filterActiveTasks uses its own set (excludes PENDING — tasks "in-flight" only) +const _PANEL_ACTIVE_STATES = new Set(['RUNNING', 'READY', 'QUEUED', 'BLOCKED']); -function filterTasks(tasks, hideCompletedFailed = false) { +export function filterTasks(tasks, hideCompletedFailed = false) { if (!hideCompletedFailed) return tasks; return tasks.filter(t => !HIDE_STATES.has(t.state)); } +export function filterActiveTasks(tasks) { + return tasks.filter(t => _PANEL_ACTIVE_STATES.has(t.state)); +} + +export function filterTasksByTab(tasks, tab) { + if (tab === 'active') return tasks.filter(t => ACTIVE_STATES.has(t.state)); + if (tab === 'done') return tasks.filter(t => DONE_STATES.has(t.state)); + return tasks; +} + +export function getTaskFilterTab() { + return localStorage.getItem('taskFilterTab') ?? 'active'; +} + +export function setTaskFilterTab(tab) { + localStorage.setItem('taskFilterTab', tab); +} + +export function updateFilterTabs() { + const current = getTaskFilterTab(); + document.querySelectorAll('.filter-tab[data-filter]').forEach(el => { + el.classList.toggle('active', el.dataset.filter === current); + }); +} + function getHideCompletedFailed() { const stored = localStorage.getItem('hideCompletedFailed'); return stored === null ? true : stored === 'true'; @@ -196,36 +235,30 @@ function renderTaskList(tasks) { return; } - const hide = getHideCompletedFailed(); - const visible = filterTasks(tasks, hide); - const hiddenCount = tasks.length - visible.length; + const visible = sortTasksByDate(filterTasksByTab(tasks, getTaskFilterTab())); // Replace contents with task cards container.innerHTML = ''; for (const task of visible) { container.appendChild(createTaskCard(task)); } +} - if (hiddenCount > 0) { - const info = document.createElement('button'); - info.className = 'hidden-tasks-info'; - const arrow = showHiddenFold ? '▼' : '▶'; - info.textContent = `${arrow} ${hiddenCount} hidden task${hiddenCount === 1 ? '' : 's'}`; - info.addEventListener('click', () => { - showHiddenFold = !showHiddenFold; - renderTaskList(tasks); - }); - container.appendChild(info); - - if (showHiddenFold) { - const fold = document.createElement('div'); - fold.className = 'hidden-tasks-fold'; - const hiddenTasks = tasks.filter(t => HIDE_STATES.has(t.state)); - for (const task of hiddenTasks) { - fold.appendChild(createTaskCard(task)); - } - container.appendChild(fold); - } +function renderActiveTaskList(tasks) { + const container = document.querySelector('.active-task-list'); + if (!container) return; + if (!tasks || tasks.length === 0) { + container.innerHTML = '<div id="loading">No active tasks.</div>'; + return; + } + const active = sortTasksByDate(filterActiveTasks(tasks)); + container.innerHTML = ''; + if (active.length === 0) { + container.innerHTML = '<div id="loading">No active tasks.</div>'; + return; + } + for (const task of active) { + container.appendChild(createTaskCard(task)); } } @@ -762,6 +795,7 @@ async function poll() { try { const tasks = await fetchTasks(); renderTaskList(tasks); + renderActiveTaskList(tasks); } catch { document.querySelector('.task-list').innerHTML = '<div id="loading">Could not reach server.</div>'; @@ -1539,12 +1573,15 @@ function switchTab(name) { // ── Boot ────────────────────────────────────────────────────────────────────── -document.addEventListener('DOMContentLoaded', () => { - updateToggleButton(); - document.getElementById('btn-toggle-completed').addEventListener('click', async () => { - setHideCompletedFailed(!getHideCompletedFailed()); - updateToggleButton(); - await poll(); +if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', () => { + updateFilterTabs(); + + document.querySelectorAll(".filter-tab[data-filter]").forEach(btn => { + btn.addEventListener("click", () => { + setTaskFilterTab(btn.dataset.filter); + updateFilterTabs(); + poll(); + }); }); document.getElementById('btn-start-next').addEventListener('click', function() { diff --git a/web/index.html b/web/index.html index 99fc190..e43823f 100644 --- a/web/index.html +++ b/web/index.html @@ -16,11 +16,14 @@ <nav class="tab-bar"> <button class="tab active" data-tab="tasks">Tasks</button> <button class="tab" data-tab="templates">Templates</button> + <button class="tab" data-tab="active">Active</button> </nav> <main id="app"> <div data-panel="tasks"> <div class="task-list-toolbar"> - <button id="btn-toggle-completed" class="btn-secondary btn-sm"></button> + <button class="filter-tab active" data-filter="active">Active</button> + <button class="filter-tab" data-filter="done">Done</button> + <button class="filter-tab" data-filter="all">All</button> <button id="btn-start-next" class="btn-secondary btn-sm">Start Next</button> </div> <div class="task-list"> @@ -34,6 +37,9 @@ </div> <div class="template-list"></div> </div> + <div data-panel="active" hidden> + <div class="active-task-list"></div> + </div> </main> <dialog id="task-modal"> @@ -124,6 +130,6 @@ </div> </dialog> - <script src="app.js" defer></script> + <script type="module" src="app.js"></script> </body> </html> diff --git a/web/style.css b/web/style.css index 91466ee..106ae04 100644 --- a/web/style.css +++ b/web/style.css @@ -115,10 +115,39 @@ main { padding: 0.5rem 0; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border); + gap: 0; +} + +.filter-tab { + font-size: 0.78rem; + font-weight: 600; + padding: 0.3em 0.75em; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + margin-bottom: -1px; +} + +.filter-tab:hover { + color: var(--text); +} + +.filter-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* Spacer to push remaining toolbar items to the right */ +.task-list-toolbar .filter-tab:last-of-type { + margin-right: auto; } /* Task list */ -.task-list { +.task-list, +.active-task-list { display: flex; flex-direction: column; gap: 0.75rem; @@ -343,29 +372,6 @@ main { color: var(--state-failed); } -.hidden-tasks-info { - font-size: 0.78rem; - color: var(--text-muted); - text-align: center; - padding: 0.5rem 0; - cursor: pointer; - background: transparent; - border: none; - width: 100%; -} - -.hidden-tasks-info:hover { - color: var(--text); -} - -.hidden-tasks-fold { - display: flex; - flex-direction: column; - gap: 0.75rem; - opacity: 0.6; - margin-top: 0.5rem; -} - /* Primary button */ .btn-primary { font-size: 0.85rem; @@ -1009,6 +1015,11 @@ dialog label select:focus { margin-top: 8px; } +.log-error { + color: #f87171; + font-style: italic; +} + /* ── Validate section ────────────────────────────────────────────────────── */ .validate-section { @@ -1045,3 +1056,110 @@ dialog label select:focus { .validate-suggestion { color: #94a3b8; } + +/* ── Task delete button ──────────────────────────────────────────────────── */ + +.task-card { + position: relative; +} + +.btn-delete-task { + position: absolute; + top: 6px; + right: 6px; + background: transparent; + border: none; + color: #64748b; + font-size: 0.75rem; + line-height: 1; + padding: 2px 5px; + border-radius: 3px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s, color 0.15s; +} + +.task-card:hover .btn-delete-task { + opacity: 1; +} + +.btn-delete-task:hover { + background: var(--state-failed, #ef4444); + color: #fff; +} + +/* ── Inline task editor ─────────────────────────────────────────────────────── */ + +.task-card--editable:hover { + background: rgba(56, 189, 248, 0.04); +} + +.task-inline-edit { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.task-inline-edit label { + display: block; + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0.625rem; +} + +.task-inline-edit label input, +.task-inline-edit label textarea, +.task-inline-edit label select { + display: block; + width: 100%; + margin-top: 4px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 0.375rem; + color: var(--text); + padding: 0.4em 0.6em; + font-size: 0.9rem; + font-family: inherit; +} + +.task-inline-edit label input:focus, +.task-inline-edit label textarea:focus, +.task-inline-edit label select:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.inline-edit-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.inline-edit-actions button[type="button"]:not(.btn-primary) { + font-size: 0.85rem; + padding: 0.4em 1em; + border-radius: 0.375rem; + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.inline-edit-actions button[type="button"]:not(.btn-primary):hover { + background: var(--border); + color: var(--text); +} + +.inline-edit-error { + color: var(--state-failed); + font-size: 0.82rem; + margin-top: 0.5rem; +} + +.inline-edit-success { + color: var(--state-completed); + font-size: 0.82rem; + margin-top: 0.25rem; + text-align: right; +} diff --git a/web/test/active-tasks-tab.test.mjs b/web/test/active-tasks-tab.test.mjs new file mode 100644 index 0000000..7b68c6f --- /dev/null +++ b/web/test/active-tasks-tab.test.mjs @@ -0,0 +1,68 @@ +// active-tasks-tab.test.mjs — TDD contract tests for filterActiveTasks +// +// filterActiveTasks is imported from app.js. Tests are RED until the function +// is exported from app.js. +// +// Run with: node --test web/test/active-tasks-tab.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { filterActiveTasks } from '../app.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function makeTask(state) { + return { id: state, name: `task-${state}`, state }; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('filterActiveTasks', () => { + it('returns only RUNNING tasks when given mixed states', () => { + const tasks = [makeTask('RUNNING'), makeTask('COMPLETED'), makeTask('PENDING')]; + const result = filterActiveTasks(tasks); + assert.equal(result.length, 1); + assert.equal(result[0].state, 'RUNNING'); + }); + + it('returns only READY tasks', () => { + const tasks = [makeTask('READY'), makeTask('FAILED'), makeTask('TIMED_OUT')]; + const result = filterActiveTasks(tasks); + assert.equal(result.length, 1); + assert.equal(result[0].state, 'READY'); + }); + + it('returns only QUEUED tasks', () => { + const tasks = [makeTask('QUEUED'), makeTask('CANCELLED'), makeTask('PENDING')]; + const result = filterActiveTasks(tasks); + assert.equal(result.length, 1); + assert.equal(result[0].state, 'QUEUED'); + }); + + it('returns only BLOCKED tasks', () => { + const tasks = [makeTask('BLOCKED'), makeTask('BUDGET_EXCEEDED'), makeTask('COMPLETED')]; + const result = filterActiveTasks(tasks); + assert.equal(result.length, 1); + assert.equal(result[0].state, 'BLOCKED'); + }); + + it('returns all four active states together, excludes PENDING/COMPLETED/FAILED/TIMED_OUT/CANCELLED/BUDGET_EXCEEDED', () => { + const allStates = [ + 'RUNNING', 'READY', 'QUEUED', 'BLOCKED', + 'PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', + ]; + const tasks = allStates.map(makeTask); + const result = filterActiveTasks(tasks); + assert.equal(result.length, 4, 'exactly 4 active-state tasks should be returned'); + for (const state of ['RUNNING', 'READY', 'QUEUED', 'BLOCKED']) { + assert.ok(result.some(t => t.state === state), `${state} should be included`); + } + for (const state of ['PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) { + assert.ok(!result.some(t => t.state === state), `${state} should be excluded`); + } + }); + + it('returns empty array for empty input', () => { + assert.deepEqual(filterActiveTasks([]), []); + }); +}); diff --git a/web/test/delete-button.test.mjs b/web/test/delete-button.test.mjs new file mode 100644 index 0000000..b82b487 --- /dev/null +++ b/web/test/delete-button.test.mjs @@ -0,0 +1,60 @@ +// delete-button.test.mjs — visibility logic for the Delete button on task cards +// +// Run with: node --test web/test/delete-button.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Logic under test ────────────────────────────────────────────────────────── +// A delete button should be shown for any task that is not actively executing. +// RUNNING and QUEUED tasks cannot be deleted via the API (409), so we hide the button. + +const NON_DELETABLE_STATES = new Set(['RUNNING', 'QUEUED']); + +function showDeleteButton(state) { + return !NON_DELETABLE_STATES.has(state); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('delete button visibility', () => { + it('shows for PENDING', () => { + assert.equal(showDeleteButton('PENDING'), true); + }); + + it('shows for COMPLETED', () => { + assert.equal(showDeleteButton('COMPLETED'), true); + }); + + it('shows for FAILED', () => { + assert.equal(showDeleteButton('FAILED'), true); + }); + + it('shows for CANCELLED', () => { + assert.equal(showDeleteButton('CANCELLED'), true); + }); + + it('shows for TIMED_OUT', () => { + assert.equal(showDeleteButton('TIMED_OUT'), true); + }); + + it('shows for BUDGET_EXCEEDED', () => { + assert.equal(showDeleteButton('BUDGET_EXCEEDED'), true); + }); + + it('shows for READY', () => { + assert.equal(showDeleteButton('READY'), true); + }); + + it('shows for BLOCKED', () => { + assert.equal(showDeleteButton('BLOCKED'), true); + }); + + it('hides for RUNNING', () => { + assert.equal(showDeleteButton('RUNNING'), false); + }); + + it('hides for QUEUED', () => { + assert.equal(showDeleteButton('QUEUED'), false); + }); +}); diff --git a/web/test/filter-tabs.test.mjs b/web/test/filter-tabs.test.mjs new file mode 100644 index 0000000..44cfaf6 --- /dev/null +++ b/web/test/filter-tabs.test.mjs @@ -0,0 +1,90 @@ +// filter-tabs.test.mjs — TDD contract tests for filterTasksByTab +// +// filterTasksByTab is defined inline here to establish expected behaviour. +// Once filterTasksByTab is exported from web/app.js, remove the inline +// definition and import it instead. +// +// Run with: node --test web/test/filter-tabs.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { filterTasksByTab } from '../app.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function makeTask(state) { + return { id: state, name: `task-${state}`, state }; +} + +const ALL_STATES = [ + 'PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED', + 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', +]; + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('filterTasksByTab — active tab', () => { + it('includes PENDING, QUEUED, RUNNING, READY, BLOCKED', () => { + const tasks = ALL_STATES.map(makeTask); + const result = filterTasksByTab(tasks, 'active'); + for (const state of ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']) { + assert.ok(result.some(t => t.state === state), `${state} should be included`); + } + }); + + it('excludes COMPLETED, FAILED, TIMED_OUT, CANCELLED, BUDGET_EXCEEDED', () => { + const tasks = ALL_STATES.map(makeTask); + const result = filterTasksByTab(tasks, 'active'); + for (const state of ['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) { + assert.ok(!result.some(t => t.state === state), `${state} should be excluded`); + } + }); + + it('returns empty array for empty input', () => { + assert.deepEqual(filterTasksByTab([], 'active'), []); + }); +}); + +describe('filterTasksByTab — done tab', () => { + it('includes COMPLETED, FAILED, TIMED_OUT, CANCELLED, BUDGET_EXCEEDED', () => { + const tasks = ALL_STATES.map(makeTask); + const result = filterTasksByTab(tasks, 'done'); + for (const state of ['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) { + assert.ok(result.some(t => t.state === state), `${state} should be included`); + } + }); + + it('excludes PENDING, QUEUED, RUNNING, READY, BLOCKED', () => { + const tasks = ALL_STATES.map(makeTask); + const result = filterTasksByTab(tasks, 'done'); + for (const state of ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']) { + assert.ok(!result.some(t => t.state === state), `${state} should be excluded`); + } + }); + + it('returns empty array for empty input', () => { + assert.deepEqual(filterTasksByTab([], 'done'), []); + }); +}); + +describe('filterTasksByTab — all tab', () => { + it('returns all tasks unchanged', () => { + const tasks = ALL_STATES.map(makeTask); + const result = filterTasksByTab(tasks, 'all'); + assert.equal(result.length, ALL_STATES.length); + assert.strictEqual(result, tasks, 'should return the same array reference'); + }); + + it('returns empty array for empty input', () => { + assert.deepEqual(filterTasksByTab([], 'all'), []); + }); +}); + +describe('filterTasksByTab — unknown tab', () => { + it('returns all tasks as defensive fallback', () => { + const tasks = ALL_STATES.map(makeTask); + const result = filterTasksByTab(tasks, 'unknown-tab'); + assert.equal(result.length, ALL_STATES.length); + assert.strictEqual(result, tasks, 'should return the same array reference'); + }); +}); diff --git a/web/test/running-view.test.mjs b/web/test/running-view.test.mjs new file mode 100644 index 0000000..88419bc --- /dev/null +++ b/web/test/running-view.test.mjs @@ -0,0 +1,295 @@ +// running-view.test.mjs — pure function tests for the Running tab +// +// Tests: +// filterRunningTasks(tasks) — returns only tasks where state === RUNNING +// formatElapsed(startISO) — returns elapsed string like "2m 30s", "1h 5m" +// fetchRecentExecutions(basePath, fetchFn) — calls /api/executions?since=24h +// formatDuration(startISO, endISO) — returns duration string for history table +// +// Run with: node --test web/test/running-view.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Inline implementations ───────────────────────────────────────────────────── + +function extractLogLines(lines, max = 500) { + if (lines.length <= max) return lines; + return lines.slice(lines.length - max); +} + +function filterRunningTasks(tasks) { + return tasks.filter(t => t.state === 'RUNNING'); +} + +function formatElapsed(startISO) { + if (startISO == null) return ''; + const elapsed = Math.floor((Date.now() - new Date(startISO).getTime()) / 1000); + if (elapsed < 0) return '0s'; + const h = Math.floor(elapsed / 3600); + const m = Math.floor((elapsed % 3600) / 60); + const s = elapsed % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +async function fetchRecentExecutions(basePath, fetchFn) { + const res = await fetchFn(`${basePath}/api/executions?since=24h`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +// formatDuration: returns human-readable duration between two ISO timestamps. +// If endISO is null/undefined, uses now (for in-progress tasks). +// If startISO is null/undefined, returns '--'. +function formatDuration(startISO, endISO) { + if (startISO == null) return '--'; + const start = new Date(startISO).getTime(); + const end = endISO != null ? new Date(endISO).getTime() : Date.now(); + const elapsed = Math.max(0, Math.floor((end - start) / 1000)); + const h = Math.floor(elapsed / 3600); + const m = Math.floor((elapsed % 3600) / 60); + const s = elapsed % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +// ── Tests: filterRunningTasks ───────────────────────────────────────────────── + +describe('filterRunningTasks', () => { + it('returns only RUNNING tasks from mixed list', () => { + const tasks = [ + { id: '1', state: 'RUNNING' }, + { id: '2', state: 'COMPLETED' }, + { id: '3', state: 'RUNNING' }, + { id: '4', state: 'QUEUED' }, + ]; + const result = filterRunningTasks(tasks); + assert.equal(result.length, 2); + assert.ok(result.every(t => t.state === 'RUNNING')); + }); + + it('returns empty array when no tasks are RUNNING', () => { + const tasks = [ + { id: '1', state: 'COMPLETED' }, + { id: '2', state: 'QUEUED' }, + ]; + assert.deepEqual(filterRunningTasks(tasks), []); + }); + + it('handles empty task list', () => { + assert.deepEqual(filterRunningTasks([]), []); + }); + + it('does not include QUEUED tasks', () => { + const tasks = [{ id: '1', state: 'QUEUED' }]; + assert.deepEqual(filterRunningTasks(tasks), []); + }); + + it('does not include READY tasks', () => { + const tasks = [{ id: '1', state: 'READY' }]; + assert.deepEqual(filterRunningTasks(tasks), []); + }); +}); + +// ── Tests: formatElapsed ────────────────────────────────────────────────────── + +describe('formatElapsed', () => { + it('returns empty string for null', () => { + assert.equal(formatElapsed(null), ''); + }); + + it('returns empty string for undefined', () => { + assert.equal(formatElapsed(undefined), ''); + }); + + it('returns Xs format for elapsed under a minute', () => { + const start = new Date(Date.now() - 45 * 1000).toISOString(); + assert.equal(formatElapsed(start), '45s'); + }); + + it('returns Xm Ys format for 2 minutes 30 seconds ago', () => { + const start = new Date(Date.now() - (2 * 60 + 30) * 1000).toISOString(); + assert.equal(formatElapsed(start), '2m 30s'); + }); + + it('returns Xh Ym format for over an hour', () => { + const start = new Date(Date.now() - (1 * 3600 + 5 * 60) * 1000).toISOString(); + assert.equal(formatElapsed(start), '1h 5m'); + }); + + it('returns 0s for future timestamp', () => { + const start = new Date(Date.now() + 60 * 1000).toISOString(); + assert.equal(formatElapsed(start), '0s'); + }); +}); + +// ── Tests: fetchRecentExecutions ────────────────────────────────────────────── + +describe('fetchRecentExecutions', () => { + it('calls /api/executions?since=24h with basePath prefix', async () => { + let calledUrl; + const mockFetch = async (url) => { + calledUrl = url; + return { ok: true, json: async () => [] }; + }; + await fetchRecentExecutions('/claudomator', mockFetch); + assert.equal(calledUrl, '/claudomator/api/executions?since=24h'); + }); + + it('calls with empty basePath', async () => { + let calledUrl; + const mockFetch = async (url) => { + calledUrl = url; + return { ok: true, json: async () => [] }; + }; + await fetchRecentExecutions('', mockFetch); + assert.equal(calledUrl, '/api/executions?since=24h'); + }); + + it('returns parsed JSON response', async () => { + const data = [{ id: 'exec-1', task_id: 't-1', status: 'COMPLETED' }]; + const mockFetch = async () => ({ ok: true, json: async () => data }); + const result = await fetchRecentExecutions('', mockFetch); + assert.deepEqual(result, data); + }); + + it('throws on non-OK HTTP status', async () => { + const mockFetch = async () => ({ ok: false, status: 500 }); + await assert.rejects( + () => fetchRecentExecutions('', mockFetch), + /HTTP 500/, + ); + }); +}); + +// ── Tests: formatDuration ───────────────────────────────────────────────────── + +describe('formatDuration', () => { + it('returns -- for null startISO', () => { + assert.equal(formatDuration(null, null), '--'); + }); + + it('returns -- for undefined startISO', () => { + assert.equal(formatDuration(undefined, null), '--'); + }); + + it('returns Xs for duration under a minute', () => { + const start = new Date(Date.now() - 45 * 1000).toISOString(); + const end = new Date().toISOString(); + assert.equal(formatDuration(start, end), '45s'); + }); + + it('returns Xm Ys for duration between 1 and 60 minutes', () => { + const start = new Date(Date.now() - (3 * 60 + 15) * 1000).toISOString(); + const end = new Date().toISOString(); + assert.equal(formatDuration(start, end), '3m 15s'); + }); + + it('returns Xh Ym for duration over an hour', () => { + const start = new Date(Date.now() - (2 * 3600 + 30 * 60) * 1000).toISOString(); + const end = new Date().toISOString(); + assert.equal(formatDuration(start, end), '2h 30m'); + }); + + it('uses current time when endISO is null', () => { + // Start was 10s ago, no end → should return ~10s + const start = new Date(Date.now() - 10 * 1000).toISOString(); + const result = formatDuration(start, null); + assert.match(result, /^\d+s$/); + }); +}); + +// ── sortExecutionsDesc: inline implementation ───────────────────────────────── + +function sortExecutionsDesc(executions) { + return [...executions].sort((a, b) => + new Date(b.started_at).getTime() - new Date(a.started_at).getTime(), + ); +} + +// ── Tests: sortExecutionsDesc ───────────────────────────────────────────────── + +describe('sortExecutionsDesc', () => { + it('sorts executions newest first', () => { + const execs = [ + { id: 'a', started_at: '2024-01-01T00:00:00Z' }, + { id: 'b', started_at: '2024-01-03T00:00:00Z' }, + { id: 'c', started_at: '2024-01-02T00:00:00Z' }, + ]; + const result = sortExecutionsDesc(execs); + assert.equal(result[0].id, 'b'); + assert.equal(result[1].id, 'c'); + assert.equal(result[2].id, 'a'); + }); + + it('returns empty array for empty input', () => { + assert.deepEqual(sortExecutionsDesc([]), []); + }); + + it('does not mutate the original array', () => { + const execs = [ + { id: 'a', started_at: '2024-01-01T00:00:00Z' }, + { id: 'b', started_at: '2024-01-03T00:00:00Z' }, + ]; + const copy = [execs[0], execs[1]]; + sortExecutionsDesc(execs); + assert.deepEqual(execs, copy); + }); + + it('returns single-element array unchanged', () => { + const execs = [{ id: 'a', started_at: '2024-01-01T00:00:00Z' }]; + assert.equal(sortExecutionsDesc(execs)[0].id, 'a'); + }); +}); + +// ── Tests: filterRunningTasks (explicit empty input check) ───────────────────── + +describe('filterRunningTasks (empty input)', () => { + it('returns [] for empty input', () => { + assert.deepEqual(filterRunningTasks([]), []); + }); +}); + +// ── Tests: extractLogLines ──────────────────────────────────────────────────── + +describe('extractLogLines', () => { + it('returns lines unchanged when count is below max', () => { + const lines = ['a', 'b', 'c']; + assert.deepEqual(extractLogLines(lines, 500), lines); + }); + + it('returns lines unchanged when count equals max', () => { + const lines = Array.from({ length: 500 }, (_, i) => `line${i}`); + assert.equal(extractLogLines(lines, 500).length, 500); + assert.equal(extractLogLines(lines, 500)[0], 'line0'); + }); + + it('truncates to last max lines when count exceeds max', () => { + const lines = Array.from({ length: 600 }, (_, i) => `line${i}`); + const result = extractLogLines(lines, 500); + assert.equal(result.length, 500); + assert.equal(result[0], 'line100'); + assert.equal(result[499], 'line599'); + }); + + it('uses default max of 500', () => { + const lines = Array.from({ length: 501 }, (_, i) => `line${i}`); + const result = extractLogLines(lines); + assert.equal(result.length, 500); + assert.equal(result[0], 'line1'); + }); + + it('returns empty array for empty input', () => { + assert.deepEqual(extractLogLines([]), []); + }); + + it('does not mutate the original array', () => { + const lines = Array.from({ length: 600 }, (_, i) => `line${i}`); + const copy = [...lines]; + extractLogLines(lines, 500); + assert.deepEqual(lines, copy); + }); +}); diff --git a/web/test/sort-tasks.test.mjs b/web/test/sort-tasks.test.mjs new file mode 100644 index 0000000..fe47702 --- /dev/null +++ b/web/test/sort-tasks.test.mjs @@ -0,0 +1,88 @@ +// sort-tasks.test.mjs — TDD contract tests for sortTasksByDate +// +// sortTasksByDate is defined inline here to establish expected behaviour. +// Once sortTasksByDate is exported from web/app.js or a shared module, +// remove the inline definition and import it instead. +// +// Run with: node --test web/test/sort-tasks.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Implementation under contract ───────────────────────────────────────────── +// Remove this block once sortTasksByDate is available from app.js. + +function sortTasksByDate(tasks) { + return [...tasks].sort((a, b) => { + if (!a.created_at && !b.created_at) return 0; + if (!a.created_at) return 1; + if (!b.created_at) return -1; + return new Date(a.created_at) - new Date(b.created_at); + }); +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function makeTask(id, created_at, state = 'PENDING') { + return { id, name: `task-${id}`, state, created_at }; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('sortTasksByDate', () => { + it('sorts tasks oldest-first by created_at', () => { + const tasks = [ + makeTask('c', '2026-03-06T12:00:00Z'), + makeTask('a', '2026-03-04T08:00:00Z'), + makeTask('b', '2026-03-05T10:00:00Z'), + ]; + const result = sortTasksByDate(tasks); + assert.equal(result[0].id, 'a', 'oldest should be first'); + assert.equal(result[1].id, 'b'); + assert.equal(result[2].id, 'c', 'newest should be last'); + }); + + it('returns a new array (does not mutate input)', () => { + const tasks = [ + makeTask('b', '2026-03-05T10:00:00Z'), + makeTask('a', '2026-03-04T08:00:00Z'), + ]; + const original = [...tasks]; + const result = sortTasksByDate(tasks); + assert.notStrictEqual(result, tasks, 'should return a new array'); + assert.deepEqual(tasks, original, 'input should not be mutated'); + }); + + it('returns an empty array when given an empty array', () => { + assert.deepEqual(sortTasksByDate([]), []); + }); + + it('returns a single-element array unchanged', () => { + const tasks = [makeTask('x', '2026-03-01T00:00:00Z')]; + const result = sortTasksByDate(tasks); + assert.equal(result.length, 1); + assert.equal(result[0].id, 'x'); + }); + + it('places tasks with null created_at after tasks with a date', () => { + const tasks = [ + makeTask('no-date', null), + makeTask('has-date', '2026-03-01T00:00:00Z'), + ]; + const result = sortTasksByDate(tasks); + assert.equal(result[0].id, 'has-date', 'task with date should come first'); + assert.equal(result[1].id, 'no-date', 'task without date should come last'); + }); + + it('works with mixed states (not just PENDING)', () => { + const tasks = [ + makeTask('r', '2026-03-06T00:00:00Z', 'RUNNING'), + makeTask('p', '2026-03-04T00:00:00Z', 'PENDING'), + makeTask('q', '2026-03-05T00:00:00Z', 'QUEUED'), + ]; + const result = sortTasksByDate(tasks); + assert.equal(result[0].id, 'p'); + assert.equal(result[1].id, 'q'); + assert.equal(result[2].id, 'r'); + }); +}); diff --git a/web/test/task-actions.test.mjs b/web/test/task-actions.test.mjs index 2df6523..36c0e8b 100644 --- a/web/test/task-actions.test.mjs +++ b/web/test/task-actions.test.mjs @@ -1,4 +1,4 @@ -// task-actions.test.mjs — button visibility logic for Cancel/Restart actions +// task-actions.test.mjs — button visibility logic for Cancel/Restart/Resume actions // // Run with: node --test web/test/task-actions.test.mjs @@ -7,16 +7,23 @@ import assert from 'node:assert/strict'; // ── Logic under test ────────────────────────────────────────────────────────── -const RESTART_STATES = new Set(['FAILED', 'TIMED_OUT', 'CANCELLED']); +const RESTART_STATES = new Set(['FAILED', 'CANCELLED']); function getCardAction(state) { if (state === 'PENDING') return 'run'; if (state === 'RUNNING') return 'cancel'; if (state === 'READY') return 'approve'; + if (state === 'TIMED_OUT') return 'resume'; if (RESTART_STATES.has(state)) return 'restart'; return null; } +function getApiEndpoint(state) { + if (state === 'TIMED_OUT') return '/resume'; + if (RESTART_STATES.has(state)) return '/run'; + return null; +} + // ── Tests ───────────────────────────────────────────────────────────────────── describe('task card action buttons', () => { @@ -32,8 +39,8 @@ describe('task card action buttons', () => { assert.equal(getCardAction('FAILED'), 'restart'); }); - it('shows Restart button for TIMED_OUT', () => { - assert.equal(getCardAction('TIMED_OUT'), 'restart'); + it('shows Resume button for TIMED_OUT', () => { + assert.equal(getCardAction('TIMED_OUT'), 'resume'); }); it('shows Restart button for CANCELLED', () => { @@ -56,3 +63,17 @@ describe('task card action buttons', () => { assert.equal(getCardAction('BUDGET_EXCEEDED'), null); }); }); + +describe('task action API endpoints', () => { + it('TIMED_OUT uses /resume endpoint', () => { + assert.equal(getApiEndpoint('TIMED_OUT'), '/resume'); + }); + + it('FAILED uses /run endpoint', () => { + assert.equal(getApiEndpoint('FAILED'), '/run'); + }); + + it('CANCELLED uses /run endpoint', () => { + assert.equal(getApiEndpoint('CANCELLED'), '/run'); + }); +}); |
