diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-03 21:15:50 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-03 21:15:50 +0000 |
| commit | 74cc740398cf2d90804ab19db728c844c2e056b7 (patch) | |
| tree | e8532d1da9273e1613beb7b762b16134da0de286 /internal/api | |
| parent | f527972f4d8311a09e639ede6c4da4ca669cfd5e (diff) | |
Add elaborate, logs-stream, templates, and subtask-list endpoints
- POST /api/tasks/elaborate: calls claude to draft a task config from
a natural-language prompt
- GET /api/executions/{id}/logs/stream: SSE tail of stdout.log
- CRUD /api/templates: create/list/get/update/delete reusable task configs
- GET /api/tasks/{id}/subtasks: list child tasks
- Server.NewServer accepts claudeBinPath for elaborate; injectable
elaborateCmdPath and logStore for test isolation
- Valid-transition guard added to POST /api/tasks/{id}/run
- CLI passes claude binary path through to the server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/elaborate.go | 145 | ||||
| -rw-r--r-- | internal/api/elaborate_test.go | 171 | ||||
| -rw-r--r-- | internal/api/logs.go | 235 | ||||
| -rw-r--r-- | internal/api/logs_test.go | 172 | ||||
| -rw-r--r-- | internal/api/server.go | 91 | ||||
| -rw-r--r-- | internal/api/server_test.go | 84 | ||||
| -rw-r--r-- | internal/api/templates.go | 144 | ||||
| -rw-r--r-- | internal/api/templates_test.go | 183 |
8 files changed, 1196 insertions, 29 deletions
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go new file mode 100644 index 0000000..357bf3c --- /dev/null +++ b/internal/api/elaborate.go @@ -0,0 +1,145 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "strings" + "time" +) + +const elaborateTimeout = 30 * time.Second + +const elaborateSystemPrompt = `You are a task configuration assistant for Claudomator, an AI task runner that executes tasks by running Claude as a subprocess. + +Your ONLY job is to convert any user request into a Claudomator task JSON object. You MUST always output valid JSON. Never ask clarifying questions. Never explain. Never refuse. Make reasonable assumptions and produce the JSON. + +If the request is about Claudomator itself (e.g. "add a feature", "fix a bug", "hide completed tasks"), treat it as a software engineering task to be implemented in the Claudomator codebase at /root/workspace/claudomator. + +Output ONLY a valid JSON object matching this schema (no markdown fences, no prose, no explanation): + +{ + "name": string — short imperative title (≤60 chars), + "description": string — 1-2 sentence summary, + "claude": { + "model": string — "sonnet" unless the task obviously needs opus, + "instructions": string — detailed, step-by-step instructions for Claude, + "working_dir": string — use "/root/workspace/claudomator" for Claudomator tasks, empty string otherwise, + "max_budget_usd": number — conservative estimate (0.25–5.00), + "allowed_tools": array — only tools the task genuinely needs + }, + "timeout": string — e.g. "15m", + "priority": string — "normal" | "high" | "low", + "tags": array — relevant lowercase tags +}` + +// elaboratedTask mirrors the task creation schema for elaboration responses. +type elaboratedTask struct { + Name string `json:"name"` + Description string `json:"description"` + Claude elaboratedClaude `json:"claude"` + Timeout string `json:"timeout"` + Priority string `json:"priority"` + Tags []string `json:"tags"` +} + +type elaboratedClaude struct { + Model string `json:"model"` + Instructions string `json:"instructions"` + WorkingDir string `json:"working_dir"` + MaxBudgetUSD float64 `json:"max_budget_usd"` + AllowedTools []string `json:"allowed_tools"` +} + +// claudeJSONResult is the top-level object returned by `claude --output-format json`. +type claudeJSONResult struct { + Result string `json:"result"` +} + +// extractJSON returns the first top-level JSON object found in s, stripping +// surrounding prose or markdown code fences the model may have added. +func extractJSON(s string) string { + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start == -1 || end == -1 || end < start { + return s + } + return s[start : end+1] +} + +func (s *Server) claudeBinaryPath() string { + if s.elaborateCmdPath != "" { + return s.elaborateCmdPath + } + if s.claudeBinPath != "" { + return s.claudeBinPath + } + return "claude" +} + +func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { + var input struct { + Prompt string `json:"prompt"` + } + 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.Prompt == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "prompt is required"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, s.claudeBinaryPath(), + "-p", input.Prompt, + "--system-prompt", elaborateSystemPrompt, + "--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("elaborate: claude subprocess failed", "error", err, "stderr", stderr.String()) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": fmt.Sprintf("elaboration failed: %v", err), + }) + return + } + + // claude --output-format json wraps the text result in {"result": "...", ...} + var wrapper claudeJSONResult + if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil { + s.logger.Error("elaborate: failed to parse claude JSON wrapper", "error", err, "stdout", stdout.String()) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "elaboration failed: invalid JSON from claude", + }) + return + } + + var result elaboratedTask + if err := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); err != nil { + s.logger.Error("elaborate: failed to parse elaborated task JSON", "error", err, "result", wrapper.Result) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "elaboration failed: claude returned invalid task JSON", + }) + return + } + + if result.Name == "" || result.Claude.Instructions == "" { + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "elaboration failed: missing required fields in response", + }) + return + } + + writeJSON(w, http.StatusOK, result) +} diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go new file mode 100644 index 0000000..ff158a8 --- /dev/null +++ b/internal/api/elaborate_test.go @@ -0,0 +1,171 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// createFakeClaude writes a shell script to a temp dir that prints output and exits with the +// given code. Returns the script path. Used to mock the claude binary in elaborate tests. +func createFakeClaude(t *testing.T, output string, exitCode int) string { + t.Helper() + dir := t.TempDir() + outputFile := filepath.Join(dir, "output.json") + if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil { + t.Fatal(err) + } + script := filepath.Join(dir, "claude") + content := fmt.Sprintf("#!/bin/sh\ncat %q\nexit %d\n", outputFile, exitCode) + if err := os.WriteFile(script, []byte(content), 0755); err != nil { + t.Fatal(err) + } + return script +} + +func TestElaborateTask_Success(t *testing.T) { + srv, _ := testServer(t) + + // Build fake Claude output: {"result": "<task-json>"} + task := elaboratedTask{ + Name: "Run Go tests with race detector", + Description: "Runs the Go test suite with -race flag and checks coverage.", + Claude: elaboratedClaude{ + Model: "sonnet", + Instructions: "Run go test -race ./... and report results.", + WorkingDir: "", + MaxBudgetUSD: 0.5, + AllowedTools: []string{"Bash"}, + }, + Timeout: "15m", + Priority: "normal", + Tags: []string{"testing", "ci"}, + } + taskJSON, err := json.Marshal(task) + if err != nil { + t.Fatal(err) + } + wrapper := map[string]string{"result": string(taskJSON)} + wrapperJSON, err := json.Marshal(wrapper) + if err != nil { + t.Fatal(err) + } + + srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) + + body := `{"prompt":"run the Go test suite with race detector and fail if coverage < 80%"}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", 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 elaboratedTask + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Name == "" { + t.Error("expected non-empty name") + } + if result.Claude.Instructions == "" { + t.Error("expected non-empty instructions") + } +} + +func TestElaborateTask_EmptyPrompt(t *testing.T) { + srv, _ := testServer(t) + + body := `{"prompt":""}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", 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()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["error"] == "" { + t.Error("expected error message in response") + } +} + +func TestElaborateTask_MarkdownFencedJSON(t *testing.T) { + srv, _ := testServer(t) + + // Build a valid task JSON but wrap it in markdown fences as haiku sometimes does. + task := elaboratedTask{ + Name: "Test task", + Description: "Does something.", + Claude: elaboratedClaude{ + Model: "sonnet", + Instructions: "Do the thing.", + MaxBudgetUSD: 0.5, + AllowedTools: []string{"Bash"}, + }, + Timeout: "15m", + Priority: "normal", + Tags: []string{"test"}, + } + taskJSON, _ := json.Marshal(task) + fenced := "```json\n" + string(taskJSON) + "\n```" + wrapper := map[string]string{"result": fenced} + wrapperJSON, _ := json.Marshal(wrapper) + + srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) + + body := `{"prompt":"do something"}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", 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 elaboratedTask + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Name != task.Name { + t.Errorf("name: want %q, got %q", task.Name, result.Name) + } +} + +func TestElaborateTask_InvalidJSONFromClaude(t *testing.T) { + srv, _ := testServer(t) + + // Fake Claude returns something that is not valid JSON. + srv.elaborateCmdPath = createFakeClaude(t, "not valid json at all", 0) + + body := `{"prompt":"do something"}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", 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()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["error"] == "" { + t.Error("expected error message in response") + } +} diff --git a/internal/api/logs.go b/internal/api/logs.go new file mode 100644 index 0000000..0354943 --- /dev/null +++ b/internal/api/logs.go @@ -0,0 +1,235 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +// logStore is the minimal storage interface needed by handleStreamLogs. +type logStore interface { + GetExecution(id string) (*storage.Execution, error) +} + +const maxTailDuration = 30 * time.Minute + +var terminalStates = map[string]bool{ + string(task.StateCompleted): true, + string(task.StateFailed): true, + string(task.StateTimedOut): true, + string(task.StateCancelled): true, + string(task.StateBudgetExceeded): true, +} + +type logStreamMsg struct { + Type string `json:"type"` + Message *logAssistMsg `json:"message,omitempty"` + CostUSD float64 `json:"cost_usd,omitempty"` +} + +type logAssistMsg struct { + Content []logContentBlock `json:"content"` +} + +type logContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +// handleStreamLogs streams parsed execution log content via SSE. +// GET /api/executions/{id}/logs/stream +func (s *Server) handleStreamLogs(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + exec, err := s.logStore.GetExecution(id) + if err != nil { + http.Error(w, "execution 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.logStore, exec) + return // tailRunningExecution sends the done event + } + + fmt.Fprintf(w, "event: done\ndata: {}\n\n") + flusher.Flush() +} + +// tailRunningExecution live-tails the stdout log for a RUNNING execution, polling every 250ms. +// It returns when the execution transitions to a terminal state, the context is cancelled, +// or after maxTailDuration (safety guard against goroutine leaks). +func tailRunningExecution(ctx context.Context, w http.ResponseWriter, flusher http.Flusher, store logStore, exec *storage.Execution) { + var ( + f *os.File + offset int64 + ) + + // Emit any content already written to the log file. + if exec.StdoutPath != "" { + var err error + f, err = os.Open(exec.StdoutPath) + if err == nil { + defer f.Close() + for _, line := range readNewLines(f, &offset) { + select { + case <-ctx.Done(): + return + default: + } + emitLogLine(w, flusher, line) + } + } + } + + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + deadline := time.NewTimer(maxTailDuration) + defer deadline.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-deadline.C: + fmt.Fprintf(w, "event: done\ndata: {}\n\n") + flusher.Flush() + return + case <-ticker.C: + // If the log file wasn't open yet, try now (may have been created since). + if f == nil && exec.StdoutPath != "" { + var err error + f, err = os.Open(exec.StdoutPath) + if err != nil { + f = nil + } else { + defer f.Close() + } + } + + // Emit any new complete lines. + if f != nil { + for _, line := range readNewLines(f, &offset) { + emitLogLine(w, flusher, line) + } + } + + // Re-fetch execution status from DB. + updated, err := store.GetExecution(exec.ID) + if err != nil { + fmt.Fprintf(w, "event: done\ndata: {}\n\n") + flusher.Flush() + return + } + if terminalStates[updated.Status] { + // Flush any remaining content written between the last read and now. + if f != nil { + for _, line := range readNewLines(f, &offset) { + emitLogLine(w, flusher, line) + } + } + fmt.Fprintf(w, "event: done\ndata: {}\n\n") + flusher.Flush() + return + } + } + } +} + +// readNewLines reads all complete lines from f starting at *offset. +// It advances *offset past the last newline so partial trailing lines +// are deferred to the next call (safe for live-tailing growing files). +func readNewLines(f *os.File, offset *int64) [][]byte { + if _, err := f.Seek(*offset, io.SeekStart); err != nil { + return nil + } + data, err := io.ReadAll(f) + if err != nil || len(data) == 0 { + return nil + } + lastNL := bytes.LastIndexByte(data, '\n') + if lastNL < 0 { + return nil // no complete line yet + } + *offset += int64(lastNL + 1) + return bytes.Split(data[:lastNL], []byte("\n")) +} + +// emitLogLine parses a single stream-json line and emits corresponding SSE events. +func emitLogLine(w http.ResponseWriter, flusher http.Flusher, line []byte) { + if len(line) == 0 { + return + } + var msg logStreamMsg + if err := json.Unmarshal(line, &msg); err != nil { + return + } + switch msg.Type { + case "assistant": + if msg.Message == nil { + return + } + for _, block := range msg.Message.Content { + var event map[string]string + switch block.Type { + case "text": + event = map[string]string{"type": "text", "content": block.Text} + case "tool_use": + summary := string(block.Input) + if len(summary) > 80 { + summary = summary[:80] + } + event = map[string]string{"type": "tool_use", "content": fmt.Sprintf("%s(%s)", block.Name, summary)} + default: + continue + } + data, _ := json.Marshal(event) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + case "result": + event := map[string]string{ + "type": "cost", + "content": fmt.Sprintf("%g", msg.CostUSD), + } + data, _ := json.Marshal(event) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } +} diff --git a/internal/api/logs_test.go b/internal/api/logs_test.go new file mode 100644 index 0000000..4a0c9fd --- /dev/null +++ b/internal/api/logs_test.go @@ -0,0 +1,172 @@ +package api + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/thepeterstone/claudomator/internal/storage" +) + +// mockLogStore implements logStore for testing. +type mockLogStore struct { + fn func(id string) (*storage.Execution, error) +} + +func (m *mockLogStore) GetExecution(id string) (*storage.Execution, error) { + return m.fn(id) +} + +func logsMux(srv *Server) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/executions/{id}/logs", srv.handleStreamLogs) + return mux +} + +// TestHandleStreamLogs_NotFound verifies that an unknown execution ID yields 404. +func TestHandleStreamLogs_NotFound(t *testing.T) { + srv := &Server{ + logStore: &mockLogStore{fn: func(id string) (*storage.Execution, error) { + return nil, errors.New("not found") + }}, + } + + req := httptest.NewRequest("GET", "/api/executions/nonexistent/logs", nil) + w := httptest.NewRecorder() + logsMux(srv).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: want 404, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestHandleStreamLogs_TerminalState_EmitsEventsAndDone verifies that a COMPLETED +// execution with a populated stdout.log streams SSE events and terminates with a done event. +func TestHandleStreamLogs_TerminalState_EmitsEventsAndDone(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + lines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}`, + `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}`, + `{"type":"result","cost_usd":0.0042}`, + }, "\n") + "\n" + if err := os.WriteFile(logPath, []byte(lines), 0600); err != nil { + t.Fatal(err) + } + + exec := &storage.Execution{ + ID: "exec-terminal-1", + TaskID: "task-terminal-1", + StartTime: time.Now(), + Status: "COMPLETED", + StdoutPath: logPath, + } + srv := &Server{ + logStore: &mockLogStore{fn: func(id string) (*storage.Execution, error) { + return exec, nil + }}, + } + + req := httptest.NewRequest("GET", "/api/executions/exec-terminal-1/logs", nil) + w := httptest.NewRecorder() + logsMux(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 !strings.Contains(body, "data: ") { + t.Error("expected at least one SSE 'data: ' event in body") + } + if !strings.Contains(body, "\n\n") { + t.Error("expected SSE double-newline event termination") + } + if !strings.HasSuffix(body, "event: done\ndata: {}\n\n") { + t.Errorf("body does not end with done event; got:\n%s", body) + } +} + +// TestHandleStreamLogs_EmptyLog verifies that a COMPLETED execution with no stdout path +// responds with only the done sentinel event. +func TestHandleStreamLogs_EmptyLog(t *testing.T) { + exec := &storage.Execution{ + ID: "exec-empty-1", + TaskID: "task-empty-1", + StartTime: time.Now(), + Status: "COMPLETED", + // StdoutPath intentionally empty + } + srv := &Server{ + logStore: &mockLogStore{fn: func(id string) (*storage.Execution, error) { + return exec, nil + }}, + } + + req := httptest.NewRequest("GET", "/api/executions/exec-empty-1/logs", nil) + w := httptest.NewRecorder() + logsMux(srv).ServeHTTP(w, req) + + body := w.Body.String() + if body != "event: done\ndata: {}\n\n" { + t.Errorf("want only done event, got:\n%s", body) + } +} + +// TestHandleStreamLogs_RunningState_LiveTail verifies that a RUNNING execution streams +// initial log content and emits a done event once it transitions to a terminal state. +func TestHandleStreamLogs_RunningState_LiveTail(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + logLines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Working..."}]}}`, + `{"type":"result","cost_usd":0.001}`, + }, "\n") + "\n" + if err := os.WriteFile(logPath, []byte(logLines), 0600); err != nil { + t.Fatal(err) + } + + runningExec := &storage.Execution{ + ID: "exec-running-1", + TaskID: "task-running-1", + StartTime: time.Now(), + Status: "RUNNING", + StdoutPath: logPath, + } + + // callCount tracks how many times GetExecution has been called. + // Call 1: initial fetch in handleStreamLogs → RUNNING + // Call 2+: poll in tailRunningExecution → COMPLETED + var callCount atomic.Int32 + mock := &mockLogStore{fn: func(id string) (*storage.Execution, error) { + n := callCount.Add(1) + if n <= 1 { + return runningExec, nil + } + completed := *runningExec + completed.Status = "COMPLETED" + return &completed, nil + }} + + srv := &Server{logStore: mock} + + req := httptest.NewRequest("GET", "/api/executions/exec-running-1/logs", nil) + w := httptest.NewRecorder() + logsMux(srv).ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, `"Working..."`) { + t.Errorf("expected initial text event 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) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 94095cb..315b64b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -17,20 +17,25 @@ import ( // Server provides the REST API and WebSocket endpoint for Claudomator. type Server struct { - store *storage.DB - pool *executor.Pool - hub *Hub - logger *slog.Logger - mux *http.ServeMux + store *storage.DB + logStore logStore // 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 } -func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger) *Server { +func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath string) *Server { s := &Server{ - store: store, - pool: pool, - hub: NewHub(), - logger: logger, - mux: http.NewServeMux(), + store: store, + logStore: store, + pool: pool, + hub: NewHub(), + logger: logger, + mux: http.NewServeMux(), + claudeBinPath: claudeBinPath, } s.routes() return s @@ -46,12 +51,20 @@ func (s *Server) StartHub() { } func (s *Server) routes() { + s.mux.HandleFunc("POST /api/tasks/elaborate", s.handleElaborateTask) 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) s.mux.HandleFunc("POST /api/tasks/{id}/run", s.handleRunTask) + 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/{id}", s.handleGetExecution) + 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) + s.mux.HandleFunc("GET /api/templates/{id}", s.handleGetTemplate) + s.mux.HandleFunc("PUT /api/templates/{id}", s.handleUpdateTemplate) + s.mux.HandleFunc("DELETE /api/templates/{id}", s.handleDeleteTemplate) s.mux.HandleFunc("GET /api/ws", s.handleWebSocket) s.mux.HandleFunc("GET /api/health", s.handleHealth) s.mux.Handle("GET /", http.FileServerFS(webui.Files)) @@ -80,12 +93,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { var input struct { - Name string `json:"name"` - Description string `json:"description"` - Claude task.ClaudeConfig `json:"claude"` - Timeout string `json:"timeout"` - Priority string `json:"priority"` - Tags []string `json:"tags"` + Name string `json:"name"` + Description string `json:"description"` + Claude task.ClaudeConfig `json:"claude"` + Timeout string `json:"timeout"` + Priority string `json:"priority"` + Tags []string `json:"tags"` + ParentTaskID string `json:"parent_task_id"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) @@ -94,17 +108,18 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { now := time.Now().UTC() t := &task.Task{ - ID: uuid.New().String(), - Name: input.Name, - Description: input.Description, - Claude: input.Claude, - Priority: task.Priority(input.Priority), - Tags: input.Tags, - DependsOn: []string{}, - Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, - State: task.StatePending, - CreatedAt: now, - UpdatedAt: now, + ID: uuid.New().String(), + Name: input.Name, + Description: input.Description, + Claude: input.Claude, + Priority: task.Priority(input.Priority), + Tags: input.Tags, + DependsOn: []string{}, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + ParentTaskID: input.ParentTaskID, } if t.Priority == "" { t.Priority = task.PriorityNormal @@ -167,6 +182,13 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { return } + if !task.ValidTransition(t.State, task.StateQueued) { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": fmt.Sprintf("task cannot be queued from state %s", t.State), + }) + return + } + if err := s.store.UpdateTaskState(id, task.StateQueued); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return @@ -184,6 +206,19 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleListSubtasks(w http.ResponseWriter, r *http.Request) { + parentID := r.PathValue("id") + tasks, err := s.store.ListSubtasks(parentID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if tasks == nil { + tasks = []*task.Task{} + } + writeJSON(w, http.StatusOK, tasks) +} + func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) { taskID := r.PathValue("id") execs, err := s.store.ListExecutions(taskID) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 1628636..68f3657 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -30,7 +30,7 @@ func testServer(t *testing.T) (*Server, *storage.DB) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) runner := &mockRunner{} pool := executor.NewPool(2, runner, store, logger) - srv := NewServer(store, pool, logger) + srv := NewServer(store, pool, logger, "claude") return srv, store } @@ -170,6 +170,88 @@ func TestListTasks_WithTasks(t *testing.T) { } } +func createTaskWithState(t *testing.T, store *storage.DB, id string, state task.State) *task.Task { + t.Helper() + tk := &task.Task{ + ID: id, + Name: "test-task-" + id, + Claude: task.ClaudeConfig{Instructions: "do something"}, + Priority: task.PriorityNormal, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, + Tags: []string{}, DependsOn: []string{}, State: task.StatePending, + } + if err := store.CreateTask(tk); err != nil { + t.Fatalf("createTaskWithState: CreateTask: %v", err) + } + if state != task.StatePending { + if err := store.UpdateTaskState(id, state); err != nil { + t.Fatalf("createTaskWithState: UpdateTaskState(%s): %v", state, err) + } + } + tk.State = state + return tk +} + +func TestRunTask_PendingTask_Returns202(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "run-pending", task.StatePending) + + req := httptest.NewRequest("POST", "/api/tasks/run-pending/run", 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()) + } +} + +func TestRunTask_FailedTask_Returns202(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "run-failed", task.StateFailed) + + req := httptest.NewRequest("POST", "/api/tasks/run-failed/run", 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()) + } +} + +func TestRunTask_TimedOutTask_Returns202(t *testing.T) { + srv, store := testServer(t) + // TIMED_OUT → QUEUED is a valid transition (retry path). + // We need to get the task into TIMED_OUT state; storage allows direct state writes. + createTaskWithState(t, store, "run-timedout", task.StateTimedOut) + + req := httptest.NewRequest("POST", "/api/tasks/run-timedout/run", 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()) + } +} + +func TestRunTask_CompletedTask_Returns409(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "run-completed", task.StateCompleted) + + req := httptest.NewRequest("POST", "/api/tasks/run-completed/run", 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()) + } + var body map[string]string + json.NewDecoder(w.Body).Decode(&body) + wantMsg := "task cannot be queued from state COMPLETED" + if body["error"] != wantMsg { + t.Errorf("error body: want %q, got %q", wantMsg, body["error"]) + } +} + func TestCORS_Headers(t *testing.T) { srv, _ := testServer(t) diff --git a/internal/api/templates.go b/internal/api/templates.go new file mode 100644 index 0000000..0139895 --- /dev/null +++ b/internal/api/templates.go @@ -0,0 +1,144 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) { + templates, err := s.store.ListTemplates() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if templates == nil { + templates = []*storage.Template{} + } + writeJSON(w, http.StatusOK, templates) +} + +func (s *Server) handleCreateTemplate(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + Description string `json:"description"` + Claude task.ClaudeConfig `json:"claude"` + Timeout string `json:"timeout"` + Priority string `json:"priority"` + Tags []string `json:"tags"` + } + 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 + } + + now := time.Now().UTC() + tmpl := &storage.Template{ + ID: uuid.New().String(), + Name: input.Name, + Description: input.Description, + Claude: input.Claude, + Timeout: input.Timeout, + Priority: input.Priority, + Tags: input.Tags, + CreatedAt: now, + UpdatedAt: now, + } + if tmpl.Priority == "" { + tmpl.Priority = "normal" + } + if tmpl.Tags == nil { + tmpl.Tags = []string{} + } + + if err := s.store.CreateTemplate(tmpl); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, tmpl) +} + +func (s *Server) handleGetTemplate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + tmpl, err := s.store.GetTemplate(id) + if err != nil { + if errors.Is(err, storage.ErrTemplateNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "template not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, tmpl) +} + +// handleUpdateTemplate fully replaces all fields of the template identified by {id}. +// All fields from the request body overwrite existing values; name is required. +func (s *Server) handleUpdateTemplate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + existing, err := s.store.GetTemplate(id) + if err != nil { + if errors.Is(err, storage.ErrTemplateNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "template not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + var input struct { + Name string `json:"name"` + Description string `json:"description"` + Claude task.ClaudeConfig `json:"claude"` + Timeout string `json:"timeout"` + Priority string `json:"priority"` + Tags []string `json:"tags"` + } + 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 + } + + existing.Name = input.Name + existing.Description = input.Description + existing.Claude = input.Claude + existing.Timeout = input.Timeout + existing.Priority = input.Priority + if input.Tags != nil { + existing.Tags = input.Tags + } + existing.UpdatedAt = time.Now().UTC() + + if err := s.store.UpdateTemplate(existing); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, existing) +} + +func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + err := s.store.DeleteTemplate(id) + if err != nil { + if errors.Is(err, storage.ErrTemplateNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "template not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/templates_test.go b/internal/api/templates_test.go new file mode 100644 index 0000000..bbcfc87 --- /dev/null +++ b/internal/api/templates_test.go @@ -0,0 +1,183 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/thepeterstone/claudomator/internal/storage" +) + +func TestListTemplates_Empty(t *testing.T) { + srv, _ := testServer(t) + + req := httptest.NewRequest("GET", "/api/templates", 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 templates []storage.Template + json.NewDecoder(w.Body).Decode(&templates) + if len(templates) != 0 { + t.Errorf("want 0 templates, got %d", len(templates)) + } +} + +func TestCreateTemplate_Success(t *testing.T) { + srv, _ := testServer(t) + + payload := `{ + "name": "Go: Run Tests", + "description": "Run the full test suite with race detector", + "claude": { + "model": "sonnet", + "instructions": "Run go test -race ./...", + "max_budget_usd": 0.50, + "allowed_tools": ["Bash"] + }, + "timeout": "10m", + "priority": "normal", + "tags": ["go", "testing"] + }` + req := httptest.NewRequest("POST", "/api/templates", 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("status: want 201, got %d; body: %s", w.Code, w.Body.String()) + } + var created storage.Template + json.NewDecoder(w.Body).Decode(&created) + if created.Name != "Go: Run Tests" { + t.Errorf("name: want 'Go: Run Tests', got %q", created.Name) + } + if created.ID == "" { + t.Error("expected auto-generated ID") + } +} + +func TestGetTemplate_AfterCreate(t *testing.T) { + srv, _ := testServer(t) + + payload := `{"name": "Fetch Me", "claude": {"instructions": "do thing", "model": "haiku"}}` + req := httptest.NewRequest("POST", "/api/templates", bytes.NewBufferString(payload)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: want 201, got %d", w.Code) + } + var created storage.Template + json.NewDecoder(w.Body).Decode(&created) + + req2 := httptest.NewRequest("GET", fmt.Sprintf("/api/templates/%s", created.ID), nil) + w2 := httptest.NewRecorder() + srv.Handler().ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("get: want 200, got %d; body: %s", w2.Code, w2.Body.String()) + } + var fetched storage.Template + json.NewDecoder(w2.Body).Decode(&fetched) + if fetched.ID != created.ID { + t.Errorf("id: want %q, got %q", created.ID, fetched.ID) + } + if fetched.Name != "Fetch Me" { + t.Errorf("name: want 'Fetch Me', got %q", fetched.Name) + } +} + +func TestGetTemplate_NotFound(t *testing.T) { + srv, _ := testServer(t) + + req := httptest.NewRequest("GET", "/api/templates/nonexistent", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: want 404, got %d", w.Code) + } +} + +func TestUpdateTemplate(t *testing.T) { + srv, _ := testServer(t) + + payload := `{"name": "Original Name", "claude": {"instructions": "original"}}` + req := httptest.NewRequest("POST", "/api/templates", bytes.NewBufferString(payload)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + var created storage.Template + json.NewDecoder(w.Body).Decode(&created) + + update := `{"name": "Updated Name", "claude": {"instructions": "updated"}}` + req2 := httptest.NewRequest("PUT", fmt.Sprintf("/api/templates/%s", created.ID), bytes.NewBufferString(update)) + w2 := httptest.NewRecorder() + srv.Handler().ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("update: want 200, got %d; body: %s", w2.Code, w2.Body.String()) + } + var updated storage.Template + json.NewDecoder(w2.Body).Decode(&updated) + if updated.Name != "Updated Name" { + t.Errorf("name: want 'Updated Name', got %q", updated.Name) + } +} + +func TestUpdateTemplate_NotFound(t *testing.T) { + srv, _ := testServer(t) + + update := `{"name": "Ghost", "claude": {"instructions": "x"}}` + req := httptest.NewRequest("PUT", "/api/templates/nonexistent", bytes.NewBufferString(update)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: want 404, got %d", w.Code) + } +} + +func TestDeleteTemplate(t *testing.T) { + srv, _ := testServer(t) + + payload := `{"name": "To Delete", "claude": {"instructions": "bye"}}` + req := httptest.NewRequest("POST", "/api/templates", bytes.NewBufferString(payload)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + var created storage.Template + json.NewDecoder(w.Body).Decode(&created) + + req2 := httptest.NewRequest("DELETE", fmt.Sprintf("/api/templates/%s", created.ID), nil) + w2 := httptest.NewRecorder() + srv.Handler().ServeHTTP(w2, req2) + + if w2.Code != http.StatusNoContent { + t.Fatalf("delete: want 204, got %d; body: %s", w2.Code, w2.Body.String()) + } + + // Subsequent GET returns 404. + req3 := httptest.NewRequest("GET", fmt.Sprintf("/api/templates/%s", created.ID), nil) + w3 := httptest.NewRecorder() + srv.Handler().ServeHTTP(w3, req3) + + if w3.Code != http.StatusNotFound { + t.Fatalf("get after delete: want 404, got %d", w3.Code) + } +} + +func TestDeleteTemplate_NotFound(t *testing.T) { + srv, _ := testServer(t) + + req := httptest.NewRequest("DELETE", "/api/templates/nonexistent", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: want 404, got %d", w.Code) + } +} |
