summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/adr/002-task-state-machine.md177
-rw-r--r--internal/api/executions.go100
-rw-r--r--internal/api/executions_test.go280
-rw-r--r--internal/api/logs.go52
-rw-r--r--internal/api/logs_test.go175
-rw-r--r--internal/api/scripts.go38
-rw-r--r--internal/api/scripts_test.go69
-rw-r--r--internal/api/server.go92
-rw-r--r--internal/api/server_test.go182
-rw-r--r--internal/api/validate.go125
-rw-r--r--internal/api/validate_test.go90
-rw-r--r--internal/storage/db.go55
-rw-r--r--internal/storage/db_test.go58
-rwxr-xr-xscripts/reset-failed-tasks5
-rwxr-xr-xscripts/reset-running-tasks5
-rw-r--r--web/app.js103
-rw-r--r--web/index.html10
-rw-r--r--web/style.css166
-rw-r--r--web/test/active-tasks-tab.test.mjs68
-rw-r--r--web/test/delete-button.test.mjs60
-rw-r--r--web/test/filter-tabs.test.mjs90
-rw-r--r--web/test/running-view.test.mjs295
-rw-r--r--web/test/sort-tasks.test.mjs88
-rw-r--r--web/test/task-actions.test.mjs29
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';"
diff --git a/web/app.js b/web/app.js
index ce1394d..97721d3 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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');
+ });
+});