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 /internal/api | |
| 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>
Diffstat (limited to 'internal/api')
| -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 |
10 files changed, 1195 insertions, 8 deletions
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()) + } +} |
