summaryrefslogtreecommitdiff
path: root/internal/api/elaborate.go
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 /internal/api/elaborate.go
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>
Diffstat (limited to 'internal/api/elaborate.go')
-rw-r--r--internal/api/elaborate.go145
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)
+}