summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-03 21:15:50 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-03 21:15:50 +0000
commit74cc740398cf2d90804ab19db728c844c2e056b7 (patch)
treee8532d1da9273e1613beb7b762b16134da0de286
parentf527972f4d8311a09e639ede6c4da4ca669cfd5e (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>
-rw-r--r--internal/api/elaborate.go145
-rw-r--r--internal/api/elaborate_test.go171
-rw-r--r--internal/api/logs.go235
-rw-r--r--internal/api/logs_test.go172
-rw-r--r--internal/api/server.go91
-rw-r--r--internal/api/server_test.go84
-rw-r--r--internal/api/templates.go144
-rw-r--r--internal/api/templates_test.go183
-rw-r--r--internal/cli/root.go10
-rw-r--r--internal/cli/serve.go7
-rw-r--r--internal/storage/templates.go140
11 files changed, 1352 insertions, 30 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)
+ }
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 4f8dad6..43f5cb9 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -1,6 +1,8 @@
package cli
import (
+ "path/filepath"
+
"github.com/thepeterstone/claudomator/internal/config"
"github.com/spf13/cobra"
)
@@ -22,8 +24,16 @@ func NewRootCmd() *cobra.Command {
cmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default $HOME/.claudomator/config.toml)")
cmd.PersistentFlags().StringVar(&cfg.DataDir, "data-dir", cfg.DataDir, "data directory")
+ cmd.PersistentFlags().StringVar(&cfg.ClaudeBinaryPath, "claude-bin", cfg.ClaudeBinaryPath, "path to claude binary")
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
+ // Re-derive DBPath and LogDir after flags are parsed, so --data-dir takes effect.
+ cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
+ cfg.DBPath = filepath.Join(cfg.DataDir, "claudomator.db")
+ cfg.LogDir = filepath.Join(cfg.DataDir, "executions")
+ return nil
+ }
+
cmd.AddCommand(
newRunCmd(),
newServeCmd(),
diff --git a/internal/cli/serve.go b/internal/cli/serve.go
index 93a5fb3..9545f5c 100644
--- a/internal/cli/serve.go
+++ b/internal/cli/serve.go
@@ -49,14 +49,19 @@ func serve(addr string) error {
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
+ apiURL := "http://localhost" + addr
+ if len(addr) > 0 && addr[0] != ':' {
+ apiURL = "http://" + addr
+ }
runner := &executor.ClaudeRunner{
BinaryPath: cfg.ClaudeBinaryPath,
Logger: logger,
LogDir: cfg.LogDir,
+ APIURL: apiURL,
}
pool := executor.NewPool(cfg.MaxConcurrent, runner, store, logger)
- srv := api.NewServer(store, pool, logger)
+ srv := api.NewServer(store, pool, logger, cfg.ClaudeBinaryPath)
srv.StartHub()
httpSrv := &http.Server{
diff --git a/internal/storage/templates.go b/internal/storage/templates.go
new file mode 100644
index 0000000..350b4f8
--- /dev/null
+++ b/internal/storage/templates.go
@@ -0,0 +1,140 @@
+package storage
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// ErrTemplateNotFound is returned when a template ID does not exist.
+var ErrTemplateNotFound = errors.New("template not found")
+
+// Template is a reusable task configuration saved for repeated use.
+type Template struct {
+ ID string `json:"id"`
+ 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"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// CreateTemplate inserts a new template.
+func (s *DB) CreateTemplate(tmpl *Template) error {
+ configJSON, err := json.Marshal(tmpl.Claude)
+ if err != nil {
+ return fmt.Errorf("marshaling config: %w", err)
+ }
+ tagsJSON, err := json.Marshal(tmpl.Tags)
+ if err != nil {
+ return fmt.Errorf("marshaling tags: %w", err)
+ }
+ _, err = s.db.Exec(`
+ INSERT INTO templates (id, name, description, config_json, timeout, priority, tags_json, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ tmpl.ID, tmpl.Name, tmpl.Description, string(configJSON),
+ tmpl.Timeout, tmpl.Priority, string(tagsJSON),
+ tmpl.CreatedAt.UTC(), tmpl.UpdatedAt.UTC(),
+ )
+ return err
+}
+
+// GetTemplate retrieves a template by ID, returning ErrTemplateNotFound if missing.
+func (s *DB) GetTemplate(id string) (*Template, error) {
+ row := s.db.QueryRow(`SELECT id, name, description, config_json, timeout, priority, tags_json, created_at, updated_at FROM templates WHERE id = ?`, id)
+ return scanTemplate(row)
+}
+
+// ListTemplates returns all templates ordered by name.
+func (s *DB) ListTemplates() ([]*Template, error) {
+ rows, err := s.db.Query(`SELECT id, name, description, config_json, timeout, priority, tags_json, created_at, updated_at FROM templates ORDER BY name ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var templates []*Template
+ for rows.Next() {
+ tmpl, err := scanTemplate(rows)
+ if err != nil {
+ return nil, err
+ }
+ templates = append(templates, tmpl)
+ }
+ return templates, rows.Err()
+}
+
+// UpdateTemplate fully replaces a template's fields. Returns ErrTemplateNotFound if the ID is missing.
+func (s *DB) UpdateTemplate(tmpl *Template) error {
+ configJSON, err := json.Marshal(tmpl.Claude)
+ if err != nil {
+ return fmt.Errorf("marshaling config: %w", err)
+ }
+ tagsJSON, err := json.Marshal(tmpl.Tags)
+ if err != nil {
+ return fmt.Errorf("marshaling tags: %w", err)
+ }
+ result, err := s.db.Exec(`
+ UPDATE templates SET name = ?, description = ?, config_json = ?, timeout = ?, priority = ?, tags_json = ?, updated_at = ?
+ WHERE id = ?`,
+ tmpl.Name, tmpl.Description, string(configJSON), tmpl.Timeout, tmpl.Priority, string(tagsJSON),
+ tmpl.UpdatedAt.UTC(), tmpl.ID,
+ )
+ if err != nil {
+ return err
+ }
+ n, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrTemplateNotFound
+ }
+ return nil
+}
+
+// DeleteTemplate removes a template by ID. Returns ErrTemplateNotFound if the ID is missing.
+func (s *DB) DeleteTemplate(id string) error {
+ result, err := s.db.Exec(`DELETE FROM templates WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ n, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrTemplateNotFound
+ }
+ return nil
+}
+
+func scanTemplate(row scanner) (*Template, error) {
+ var (
+ tmpl Template
+ configJSON string
+ tagsJSON string
+ )
+ err := row.Scan(&tmpl.ID, &tmpl.Name, &tmpl.Description, &configJSON,
+ &tmpl.Timeout, &tmpl.Priority, &tagsJSON, &tmpl.CreatedAt, &tmpl.UpdatedAt)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrTemplateNotFound
+ }
+ return nil, err
+ }
+ if err := json.Unmarshal([]byte(configJSON), &tmpl.Claude); err != nil {
+ return nil, fmt.Errorf("unmarshaling config: %w", err)
+ }
+ if err := json.Unmarshal([]byte(tagsJSON), &tmpl.Tags); err != nil {
+ return nil, fmt.Errorf("unmarshaling tags: %w", err)
+ }
+ return &tmpl, nil
+}