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/elaborate.go | |
| 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/elaborate.go')
| -rw-r--r-- | internal/api/elaborate.go | 145 |
1 files changed, 145 insertions, 0 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) +} |
