summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-06 23:55:07 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-06 23:55:07 +0000
commitfd42a54d96fcd3342941caaeb61a4b0d5d3f1b4f (patch)
tree0b9ef3b7f0ac3981aa310435d014c9f5e21089d4 /internal/api
parent7d4890cde802974b94db24071f63e7733c3670fd (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.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
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())
+ }
+}