From 74cc740398cf2d90804ab19db728c844c2e056b7 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 3 Mar 2026 21:15:50 +0000 Subject: 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 --- internal/api/server.go | 91 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 28 deletions(-) (limited to 'internal/api/server.go') 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) -- cgit v1.2.3