diff options
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/elaborate.go | 20 | ||||
| -rw-r--r-- | internal/api/elaborate_test.go | 8 | ||||
| -rw-r--r-- | internal/api/server.go | 11 | ||||
| -rw-r--r-- | internal/api/server_test.go | 25 | ||||
| -rw-r--r-- | internal/api/templates.go | 14 | ||||
| -rw-r--r-- | internal/api/templates_test.go | 13 | ||||
| -rw-r--r-- | internal/api/validate.go | 32 | ||||
| -rw-r--r-- | internal/api/validate_test.go | 8 |
8 files changed, 84 insertions, 47 deletions
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go index 8a18dee..907cb98 100644 --- a/internal/api/elaborate.go +++ b/internal/api/elaborate.go @@ -18,7 +18,7 @@ func buildElaboratePrompt(workDir string) string { if workDir != "" { workDirLine = fmt.Sprintf(` "project_dir": string — use %q for tasks that operate on this codebase, empty string otherwise,`, workDir) } - return `You are a task configuration assistant for Claudomator, an AI task runner that executes tasks by running Claude as a subprocess. + return `You are a task configuration assistant for Claudomator, an AI task runner that executes tasks by running Claude or Gemini 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. @@ -27,9 +27,10 @@ Output ONLY a valid JSON object matching this schema (no markdown fences, no pro { "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, + "agent": { + "type": "claude" | "gemini", + "model": string — "sonnet" for claude, "gemini-2.0-flash" for gemini, + "instructions": string — detailed, step-by-step instructions for the agent, ` + workDirLine + ` "max_budget_usd": number — conservative estimate (0.25–5.00), "allowed_tools": array — only tools the task genuinely needs @@ -44,13 +45,14 @@ Output ONLY a valid JSON object matching this schema (no markdown fences, no pro type elaboratedTask struct { Name string `json:"name"` Description string `json:"description"` - Claude elaboratedClaude `json:"claude"` + Agent elaboratedAgent `json:"agent"` Timeout string `json:"timeout"` Priority string `json:"priority"` Tags []string `json:"tags"` } -type elaboratedClaude struct { +type elaboratedAgent struct { + Type string `json:"type"` Model string `json:"model"` Instructions string `json:"instructions"` ProjectDir string `json:"project_dir"` @@ -149,12 +151,16 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { return } - if result.Name == "" || result.Claude.Instructions == "" { + if result.Name == "" || result.Agent.Instructions == "" { writeJSON(w, http.StatusBadGateway, map[string]string{ "error": "elaboration failed: missing required fields in response", }) return } + if result.Agent.Type == "" { + result.Agent.Type = "claude" + } + writeJSON(w, http.StatusOK, result) } diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go index 09f7fbe..b33ca11 100644 --- a/internal/api/elaborate_test.go +++ b/internal/api/elaborate_test.go @@ -53,7 +53,8 @@ func TestElaborateTask_Success(t *testing.T) { task := elaboratedTask{ Name: "Run Go tests with race detector", Description: "Runs the Go test suite with -race flag and checks coverage.", - Claude: elaboratedClaude{ + Agent: elaboratedAgent{ + Type: "claude", Model: "sonnet", Instructions: "Run go test -race ./... and report results.", ProjectDir: "", @@ -94,7 +95,7 @@ func TestElaborateTask_Success(t *testing.T) { if result.Name == "" { t.Error("expected non-empty name") } - if result.Claude.Instructions == "" { + if result.Agent.Instructions == "" { t.Error("expected non-empty instructions") } } @@ -127,7 +128,8 @@ func TestElaborateTask_MarkdownFencedJSON(t *testing.T) { task := elaboratedTask{ Name: "Test task", Description: "Does something.", - Claude: elaboratedClaude{ + Agent: elaboratedAgent{ + Type: "claude", Model: "sonnet", Instructions: "Do the thing.", MaxBudgetUSD: 0.5, diff --git a/internal/api/server.go b/internal/api/server.go index 833be8b..3d7cb1e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -37,6 +37,7 @@ type Server struct { logger *slog.Logger mux *http.ServeMux claudeBinPath string // path to claude binary; defaults to "claude" + geminiBinPath string // path to gemini binary; defaults to "gemini" elaborateCmdPath string // overrides claudeBinPath; used in tests validateCmdPath string // overrides claudeBinPath for validate; used in tests scripts ScriptRegistry // optional; maps endpoint name → script path @@ -56,7 +57,7 @@ func (s *Server) SetNotifier(n notify.Notifier) { s.notifier = n } -func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath string) *Server { +func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server { wd, _ := os.Getwd() s := &Server{ store: store, @@ -68,6 +69,7 @@ func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, clau logger: logger, mux: http.NewServeMux(), claudeBinPath: claudeBinPath, + geminiBinPath: geminiBinPath, workDir: wd, } s.routes() @@ -346,7 +348,7 @@ 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"` + Agent task.AgentConfig `json:"agent"` Timeout string `json:"timeout"` Priority string `json:"priority"` Tags []string `json:"tags"` @@ -362,7 +364,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { ID: uuid.New().String(), Name: input.Name, Description: input.Description, - Claude: input.Claude, + Agent: input.Agent, Priority: task.Priority(input.Priority), Tags: input.Tags, DependsOn: []string{}, @@ -372,6 +374,9 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { UpdatedAt: now, ParentTaskID: input.ParentTaskID, } + if t.Agent.Type == "" { + t.Agent.Type = "claude" + } if t.Priority == "" { t.Priority = task.PriorityNormal } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index c3b12ce..cd415ae 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -84,8 +84,12 @@ 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, "claude") + runners := map[string]executor.Runner{ + "claude": runner, + "gemini": runner, + } + pool := executor.NewPool(2, runners, store, logger) + srv := NewServer(store, pool, logger, "claude", "gemini") return srv, store } @@ -118,7 +122,8 @@ func TestCreateTask_Success(t *testing.T) { payload := `{ "name": "API Task", "description": "Created via API", - "claude": { + "agent": { + "type": "claude", "instructions": "do the thing", "model": "sonnet" }, @@ -160,7 +165,7 @@ func TestCreateTask_InvalidJSON(t *testing.T) { func TestCreateTask_ValidationFailure(t *testing.T) { srv, _ := testServer(t) - payload := `{"name": "", "claude": {"instructions": ""}}` + payload := `{"name": "", "agent": {"type": "claude", "instructions": ""}}` req := httptest.NewRequest("POST", "/api/tasks", bytes.NewBufferString(payload)) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -207,7 +212,7 @@ func TestListTasks_WithTasks(t *testing.T) { for i := 0; i < 3; i++ { tk := &task.Task{ ID: fmt.Sprintf("lt-%d", i), Name: fmt.Sprintf("T%d", i), - Claude: task.ClaudeConfig{Instructions: "x"}, Priority: task.PriorityNormal, + Agent: task.AgentConfig{Type: "claude", Instructions: "x"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{}, DependsOn: []string{}, State: task.StatePending, } @@ -244,7 +249,7 @@ func createTaskWithState(t *testing.T, store *storage.DB, id string, state task. tk := &task.Task{ ID: id, Name: "test-task-" + id, - Claude: task.ClaudeConfig{Instructions: "do something"}, + Agent: task.AgentConfig{Type: "claude", Instructions: "do something"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{}, DependsOn: []string{}, State: task.StatePending, @@ -606,7 +611,7 @@ func TestRunTask_RetryLimitReached_Returns409(t *testing.T) { tk := &task.Task{ ID: "retry-limit-1", Name: "Retry Limit Task", - Claude: task.ClaudeConfig{Instructions: "do something"}, + Agent: task.AgentConfig{Instructions: "do something"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{}, @@ -647,7 +652,7 @@ func TestRunTask_WithinRetryLimit_Returns202(t *testing.T) { tk := &task.Task{ ID: "retry-within-1", Name: "Retry Within Task", - Claude: task.ClaudeConfig{Instructions: "do something"}, + Agent: task.AgentConfig{Instructions: "do something"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 3, Backoff: "linear"}, Tags: []string{}, @@ -694,7 +699,7 @@ func TestDeleteTask_Success(t *testing.T) { srv, store := testServer(t) // Create a task to delete. - created := createTestTask(t, srv, `{"name":"Delete Me","claude":{"instructions":"x","model":"sonnet"}}`) + created := createTestTask(t, srv, `{"name":"Delete Me","agent":{"type":"claude","instructions":"x","model":"sonnet"}}`) req := httptest.NewRequest("DELETE", "/api/tasks/"+created.ID, nil) w := httptest.NewRecorder() @@ -729,7 +734,7 @@ func TestDeleteTask_RunningTaskRejected(t *testing.T) { tk := &task.Task{ ID: "running-task-del", Name: "Running Task", - Claude: task.ClaudeConfig{Instructions: "x", Model: "sonnet"}, + Agent: task.AgentConfig{Instructions: "x", Model: "sonnet"}, Priority: task.PriorityNormal, Tags: []string{}, DependsOn: []string{}, diff --git a/internal/api/templates.go b/internal/api/templates.go index 0139895..024a6df 100644 --- a/internal/api/templates.go +++ b/internal/api/templates.go @@ -27,7 +27,7 @@ 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"` + Agent task.AgentConfig `json:"agent"` Timeout string `json:"timeout"` Priority string `json:"priority"` Tags []string `json:"tags"` @@ -46,13 +46,16 @@ func (s *Server) handleCreateTemplate(w http.ResponseWriter, r *http.Request) { ID: uuid.New().String(), Name: input.Name, Description: input.Description, - Claude: input.Claude, + Agent: input.Agent, Timeout: input.Timeout, Priority: input.Priority, Tags: input.Tags, CreatedAt: now, UpdatedAt: now, } + if tmpl.Agent.Type == "" { + tmpl.Agent.Type = "claude" + } if tmpl.Priority == "" { tmpl.Priority = "normal" } @@ -98,7 +101,7 @@ func (s *Server) handleUpdateTemplate(w http.ResponseWriter, r *http.Request) { var input struct { Name string `json:"name"` Description string `json:"description"` - Claude task.ClaudeConfig `json:"claude"` + Agent task.AgentConfig `json:"agent"` Timeout string `json:"timeout"` Priority string `json:"priority"` Tags []string `json:"tags"` @@ -114,7 +117,10 @@ func (s *Server) handleUpdateTemplate(w http.ResponseWriter, r *http.Request) { existing.Name = input.Name existing.Description = input.Description - existing.Claude = input.Claude + existing.Agent = input.Agent + if existing.Agent.Type == "" { + existing.Agent.Type = "claude" + } existing.Timeout = input.Timeout existing.Priority = input.Priority if input.Tags != nil { diff --git a/internal/api/templates_test.go b/internal/api/templates_test.go index bbcfc87..474c5d4 100644 --- a/internal/api/templates_test.go +++ b/internal/api/templates_test.go @@ -34,7 +34,8 @@ func TestCreateTemplate_Success(t *testing.T) { payload := `{ "name": "Go: Run Tests", "description": "Run the full test suite with race detector", - "claude": { + "agent": { + "type": "claude", "model": "sonnet", "instructions": "Run go test -race ./...", "max_budget_usd": 0.50, @@ -65,7 +66,7 @@ func TestCreateTemplate_Success(t *testing.T) { func TestGetTemplate_AfterCreate(t *testing.T) { srv, _ := testServer(t) - payload := `{"name": "Fetch Me", "claude": {"instructions": "do thing", "model": "haiku"}}` + payload := `{"name": "Fetch Me", "agent": {"type": "claude", "instructions": "do thing", "model": "haiku"}}` req := httptest.NewRequest("POST", "/api/templates", bytes.NewBufferString(payload)) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -107,14 +108,14 @@ func TestGetTemplate_NotFound(t *testing.T) { func TestUpdateTemplate(t *testing.T) { srv, _ := testServer(t) - payload := `{"name": "Original Name", "claude": {"instructions": "original"}}` + payload := `{"name": "Original Name", "agent": {"type": "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"}}` + update := `{"name": "Updated Name", "agent": {"type": "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) @@ -132,7 +133,7 @@ func TestUpdateTemplate(t *testing.T) { func TestUpdateTemplate_NotFound(t *testing.T) { srv, _ := testServer(t) - update := `{"name": "Ghost", "claude": {"instructions": "x"}}` + update := `{"name": "Ghost", "agent": {"type": "claude", "instructions": "x"}}` req := httptest.NewRequest("PUT", "/api/templates/nonexistent", bytes.NewBufferString(update)) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) @@ -145,7 +146,7 @@ func TestUpdateTemplate_NotFound(t *testing.T) { func TestDeleteTemplate(t *testing.T) { srv, _ := testServer(t) - payload := `{"name": "To Delete", "claude": {"instructions": "bye"}}` + payload := `{"name": "To Delete", "agent": {"type": "claude", "instructions": "bye"}}` req := httptest.NewRequest("POST", "/api/templates", bytes.NewBufferString(payload)) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) diff --git a/internal/api/validate.go b/internal/api/validate.go index 0fcdb47..07d293c 100644 --- a/internal/api/validate.go +++ b/internal/api/validate.go @@ -12,7 +12,7 @@ import ( const validateTimeout = 20 * time.Second -const validateSystemPrompt = `You are a task instruction reviewer for Claudomator, an AI task runner that executes tasks by running Claude as a subprocess. +const validateSystemPrompt = `You are a task instruction reviewer for Claudomator, an AI task runner that executes tasks by running Claude or Gemini as a subprocess. Analyze the given task name and instructions for clarity and completeness. @@ -48,7 +48,7 @@ func (s *Server) validateBinaryPath() string { if s.validateCmdPath != "" { return s.validateCmdPath } - return s.claudeBinaryPath() + return s.claudeBinPath } func (s *Server) handleValidateTask(w http.ResponseWriter, r *http.Request) { @@ -59,11 +59,13 @@ func (s *Server) handleValidateTask(w http.ResponseWriter, r *http.Request) { var input struct { Name string `json:"name"` - Claude struct { + Agent struct { + Type string `json:"type"` Instructions string `json:"instructions"` ProjectDir string `json:"project_dir"` + WorkingDir string `json:"working_dir"` // legacy AllowedTools []string `json:"allowed_tools"` - } `json:"claude"` + } `json:"agent"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) @@ -73,17 +75,27 @@ func (s *Server) handleValidateTask(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } - if input.Claude.Instructions == "" { + if input.Agent.Instructions == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "instructions are required"}) return } - userMsg := fmt.Sprintf("Task name: %s\n\nInstructions:\n%s", input.Name, input.Claude.Instructions) - if input.Claude.ProjectDir != "" { - userMsg += fmt.Sprintf("\n\nWorking directory: %s", input.Claude.ProjectDir) + agentType := input.Agent.Type + if agentType == "" { + agentType = "claude" } - if len(input.Claude.AllowedTools) > 0 { - userMsg += fmt.Sprintf("\n\nAllowed tools: %v", input.Claude.AllowedTools) + + projectDir := input.Agent.ProjectDir + if projectDir == "" { + projectDir = input.Agent.WorkingDir + } + + userMsg := fmt.Sprintf("Task name: %s\nAgent: %s\n\nInstructions:\n%s", input.Name, agentType, input.Agent.Instructions) + if projectDir != "" { + userMsg += fmt.Sprintf("\n\nWorking directory: %s", projectDir) + } + if len(input.Agent.AllowedTools) > 0 { + userMsg += fmt.Sprintf("\n\nAllowed tools: %v", input.Agent.AllowedTools) } ctx, cancel := context.WithTimeout(r.Context(), validateTimeout) diff --git a/internal/api/validate_test.go b/internal/api/validate_test.go index 5a1246b..c3d7b1f 100644 --- a/internal/api/validate_test.go +++ b/internal/api/validate_test.go @@ -23,7 +23,7 @@ func TestValidateTask_Success(t *testing.T) { wrapperJSON, _ := json.Marshal(wrapper) srv.validateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) - body := `{"name":"Test Task","claude":{"instructions":"Run go test ./... and report results."}}` + body := `{"name":"Test Task","agent":{"instructions":"Run go test ./... and report results."}}` req := httptest.NewRequest("POST", "/api/tasks/validate", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -46,7 +46,7 @@ func TestValidateTask_Success(t *testing.T) { func TestValidateTask_MissingInstructions(t *testing.T) { srv, _ := testServer(t) - body := `{"name":"Test Task","claude":{"instructions":""}}` + body := `{"name":"Test Task","agent":{"instructions":""}}` req := httptest.NewRequest("POST", "/api/tasks/validate", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -61,7 +61,7 @@ func TestValidateTask_MissingInstructions(t *testing.T) { func TestValidateTask_MissingName(t *testing.T) { srv, _ := testServer(t) - body := `{"name":"","claude":{"instructions":"Do something useful."}}` + body := `{"name":"","agent":{"instructions":"Do something useful."}}` req := httptest.NewRequest("POST", "/api/tasks/validate", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -77,7 +77,7 @@ func TestValidateTask_BadJSONFromClaude(t *testing.T) { srv, _ := testServer(t) srv.validateCmdPath = createFakeClaude(t, "not valid json at all", 0) - body := `{"name":"Test Task","claude":{"instructions":"Do something useful."}}` + body := `{"name":"Test Task","agent":{"instructions":"Do something useful."}}` req := httptest.NewRequest("POST", "/api/tasks/validate", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() |
