summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
commit68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch)
tree14b73f21570a2f42ae729ca7e9676de6628d6819 /internal/api
parentb28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff)
parent7388337d3be5cc7f65ef547e30b2e39884dd165b (diff)
merge: integrate oss branch — budget gating, MCP back-channel, events, loopback-only bind
Key features from OSS branch: - Budget gating: rolling per-provider spend caps with BUDGET_EXCEEDED task state - Agent MCP back-channel: runners mint tokens; /mcp endpoint resolves them - Chatbot MCP server at /chatbot/mcp (requires api_token in config) - Event timeline: unified event stream replacing ad-hoc question/summary flows - Loopback-only default bind (127.0.0.1:8484); external_bind_allowed=true to expose - repository_url required on task creation (enforces traceability) - Auto-checker: spawns verification task after each execution Conflict resolutions: - serve.go: keep cfg.Runners.XEnabled() conditional registration from main + agentRegistry from oss - config.go: keep RunnersConfig from main + BudgetConfig/ExternalBindAllowed from oss - server.go: handleStaticFiles (base-path rewrite) kept; deduplicated duplicate from both sides - executor/claude.go: accepted oss deletion (ClaudeRunner replaced by ContainerRunner) - container_test.go: added noopChannel + AgentChannel parameter to Run call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/agentmcp_endpoint_test.go104
-rw-r--r--internal/api/budget.go36
-rw-r--r--internal/api/budget_test.go68
-rw-r--r--internal/api/chatbotmcp.go218
-rw-r--r--internal/api/chatbotmcp_test.go233
-rw-r--r--internal/api/elaborate.go337
-rw-r--r--internal/api/elaborate_local_test.go214
-rw-r--r--internal/api/elaborate_test.go527
-rw-r--r--internal/api/events.go38
-rw-r--r--internal/api/events_test.go94
-rw-r--r--internal/api/server.go141
-rw-r--r--internal/api/server_test.go34
-rw-r--r--internal/api/taskops.go253
-rw-r--r--internal/api/validate_test.go21
14 files changed, 1096 insertions, 1222 deletions
diff --git a/internal/api/agentmcp_endpoint_test.go b/internal/api/agentmcp_endpoint_test.go
new file mode 100644
index 0000000..b2f77d3
--- /dev/null
+++ b/internal/api/agentmcp_endpoint_test.go
@@ -0,0 +1,104 @@
+package api
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/thepeterstone/claudomator/internal/executor"
+ "github.com/thepeterstone/claudomator/internal/storage"
+)
+
+type fakeAgentChannel struct{ progress []string }
+
+func (f *fakeAgentChannel) AskUser(context.Context, string) (string, error) {
+ return "", executor.ErrAgentBlocked
+}
+func (f *fakeAgentChannel) ReportSummary(context.Context, string) error { return nil }
+func (f *fakeAgentChannel) SpawnSubtask(context.Context, executor.SubtaskSpec) (string, error) {
+ return "sub", nil
+}
+func (f *fakeAgentChannel) RecordProgress(_ context.Context, m string) error {
+ f.progress = append(f.progress, m)
+ return nil
+}
+
+type tokenRT struct {
+ token string
+ base http.RoundTripper
+}
+
+func (b tokenRT) RoundTrip(r *http.Request) (*http.Response, error) {
+ r = r.Clone(r.Context())
+ r.Header.Set("Authorization", "Bearer "+b.token)
+ return b.base.RoundTrip(r)
+}
+
+func TestServer_MountsAgentMCPEndpoint(t *testing.T) {
+ store, err := storage.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ pool := executor.NewPool(1, map[string]executor.Runner{}, store, logger)
+
+ reg := executor.NewRegistry()
+ fc := &fakeAgentChannel{}
+ token, _ := reg.Mint(fc)
+
+ srv := NewServer(store, pool, reg, logger, "claude", "gemini")
+ ts := httptest.NewServer(srv.Handler())
+ defer ts.Close()
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{
+ Endpoint: ts.URL + "/mcp",
+ HTTPClient: &http.Client{Transport: tokenRT{token: token, base: http.DefaultTransport}},
+ }, nil)
+ if err != nil {
+ t.Fatalf("connect to mounted /mcp: %v", err)
+ }
+ defer cs.Close()
+
+ if _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "record_progress",
+ Arguments: map[string]any{"message": "via api server"},
+ }); err != nil {
+ t.Fatalf("CallTool through api server: %v", err)
+ }
+ if len(fc.progress) != 1 || fc.progress[0] != "via api server" {
+ t.Errorf("tool call did not reach channel: %+v", fc.progress)
+ }
+}
+
+func TestServer_NoRegistry_NoMCPRoute(t *testing.T) {
+ store, err := storage.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ pool := executor.NewPool(1, map[string]executor.Runner{}, store, logger)
+
+ // nil registry → /mcp must not be mounted.
+ srv := NewServer(store, pool, nil, logger, "claude", "gemini")
+ ts := httptest.NewServer(srv.Handler())
+ defer ts.Close()
+
+ resp, err := http.Post(ts.URL+"/mcp", "application/json", http.NoBody)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ // Unmounted: the catch-all "GET /" route rejects POST with 405. A mounted
+ // MCP handler would instead respond (e.g. 400 for the missing token).
+ if resp.StatusCode == http.StatusBadRequest {
+ t.Errorf("expected /mcp to be absent without a registry, got 400 (handler ran)")
+ }
+}
diff --git a/internal/api/budget.go b/internal/api/budget.go
new file mode 100644
index 0000000..eee5d09
--- /dev/null
+++ b/internal/api/budget.go
@@ -0,0 +1,36 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/thepeterstone/claudomator/internal/budget"
+)
+
+// budgetReporter exposes per-provider spend headroom. Satisfied by
+// *budget.Accountant; an interface keeps the handler testable.
+type budgetReporter interface {
+ All() ([]budget.Headroom, error)
+}
+
+// SetBudget wires the budget accountant so GET /api/budget can report headroom.
+func (s *Server) SetBudget(b budgetReporter) {
+ s.budget = b
+}
+
+// handleGetBudget returns per-provider rolling-window spend headroom. When no
+// budget is configured it returns an empty list so the UI simply shows nothing.
+func (s *Server) handleGetBudget(w http.ResponseWriter, _ *http.Request) {
+ if s.budget == nil {
+ writeJSON(w, http.StatusOK, []budget.Headroom{})
+ return
+ }
+ hs, err := s.budget.All()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if hs == nil {
+ hs = []budget.Headroom{}
+ }
+ writeJSON(w, http.StatusOK, hs)
+}
diff --git a/internal/api/budget_test.go b/internal/api/budget_test.go
new file mode 100644
index 0000000..d59836d
--- /dev/null
+++ b/internal/api/budget_test.go
@@ -0,0 +1,68 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/budget"
+)
+
+type fakeBudget struct {
+ hs []budget.Headroom
+ err error
+}
+
+func (f fakeBudget) All() ([]budget.Headroom, error) { return f.hs, f.err }
+
+func TestHandleGetBudget_NoBudgetConfigured_ReturnsEmpty(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/budget", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d", w.Code)
+ }
+ var hs []budget.Headroom
+ if err := json.Unmarshal(w.Body.Bytes(), &hs); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(hs) != 0 {
+ t.Errorf("want empty list, got %+v", hs)
+ }
+}
+
+func TestHandleGetBudget_ReportsHeadroom(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetBudget(fakeBudget{hs: []budget.Headroom{
+ {Provider: "claude", Limited: true, Limit: 10, Spent: 4, Remaining: 6, Fraction: 0.6},
+ }})
+ req := httptest.NewRequest("GET", "/api/budget", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d", w.Code)
+ }
+ var hs []budget.Headroom
+ if err := json.Unmarshal(w.Body.Bytes(), &hs); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(hs) != 1 || hs[0].Provider != "claude" || hs[0].Remaining != 6 {
+ t.Errorf("unexpected headroom: %+v", hs)
+ }
+}
+
+func TestHandleGetBudget_ErrorReturns500(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetBudget(fakeBudget{err: errors.New("db down")})
+ req := httptest.NewRequest("GET", "/api/budget", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusInternalServerError {
+ t.Errorf("want 500, got %d", w.Code)
+ }
+}
diff --git a/internal/api/chatbotmcp.go b/internal/api/chatbotmcp.go
new file mode 100644
index 0000000..e14b522
--- /dev/null
+++ b/internal/api/chatbotmcp.go
@@ -0,0 +1,218 @@
+package api
+
+import (
+ "context"
+ "crypto/subtle"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// The chatbot-facing MCP server lets a chatbot drive task creation and
+// orchestration. Unlike the per-task agent server, it spans all tasks and is
+// guarded by the single shared API bearer token (config api_token / SetAPIToken).
+
+type submitTaskMCPInput struct {
+ Instructions string `json:"instructions" jsonschema:"complete instructions for the agent that will run this task"`
+ Name string `json:"name,omitempty" jsonschema:"short human-readable task name; defaults to the first line of instructions"`
+ Project string `json:"project,omitempty" jsonschema:"name of a configured project; used to resolve the repository when repository_url is omitted"`
+ RepositoryURL string `json:"repository_url,omitempty" jsonschema:"git repository URL to work in; required unless a project resolves to one"`
+ Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"`
+ MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional spend cap in USD"`
+ Timeout string `json:"timeout,omitempty" jsonschema:"optional duration like 15m or 1h"`
+ Tags []string `json:"tags,omitempty" jsonschema:"optional tags"`
+}
+
+type listTasksMCPInput struct {
+ State string `json:"state,omitempty" jsonschema:"optional state filter, e.g. RUNNING, READY, BLOCKED, COMPLETED, FAILED"`
+ Limit int `json:"limit,omitempty" jsonschema:"optional max number of tasks to return"`
+}
+
+type taskIDInput struct {
+ TaskID string `json:"task_id" jsonschema:"the task ID"`
+}
+
+type getEventsMCPInput struct {
+ TaskID string `json:"task_id" jsonschema:"the task ID"`
+ SinceSeq int64 `json:"since_seq,omitempty" jsonschema:"return only events with seq greater than this value"`
+}
+
+type answerQuestionMCPInput struct {
+ TaskID string `json:"task_id" jsonschema:"the task ID of the BLOCKED task"`
+ Answer string `json:"answer" jsonschema:"the answer to the agent's question"`
+}
+
+type rejectTaskMCPInput struct {
+ TaskID string `json:"task_id" jsonschema:"the task ID"`
+ Comment string `json:"comment,omitempty" jsonschema:"optional reason the work was rejected, fed back to the agent on retry"`
+}
+
+type compactTask struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ State task.State `json:"state"`
+ Project string `json:"project,omitempty"`
+}
+
+func mcpText(text string) *mcp.CallToolResult {
+ return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}
+}
+
+func mcpJSON(v any) (*mcp.CallToolResult, any, error) {
+ b, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return nil, nil, err
+ }
+ return mcpText(string(b)), nil, nil
+}
+
+// newChatbotServer builds the chatbot-facing MCP server bound to this Server's
+// store and pool.
+func (s *Server) newChatbotServer() *mcp.Server {
+ srv := mcp.NewServer(&mcp.Implementation{Name: "claudomator-chatbot", Version: "1"}, nil)
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "submit_task",
+ Description: "Create a task and immediately queue it for execution. Returns the new task ID and state.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in submitTaskMCPInput) (*mcp.CallToolResult, any, error) {
+ spec := submitTaskSpec{
+ Name: in.Name,
+ Instructions: in.Instructions,
+ Project: in.Project,
+ RepositoryURL: in.RepositoryURL,
+ Model: in.Model,
+ MaxBudgetUSD: in.MaxBudgetUSD,
+ Tags: in.Tags,
+ }
+ if strings.TrimSpace(in.Timeout) != "" {
+ d, err := time.ParseDuration(in.Timeout)
+ if err != nil {
+ return nil, nil, badRequestf("invalid timeout %q: %v", in.Timeout, err)
+ }
+ spec.Timeout = d
+ }
+ t, err := s.submitTask(ctx, spec)
+ if err != nil {
+ return nil, nil, err
+ }
+ return mcpJSON(compactTask{ID: t.ID, Name: t.Name, State: t.State, Project: t.Project})
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "list_tasks",
+ Description: "List tasks, optionally filtered by state. Returns compact records; use get_task for full detail.",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in listTasksMCPInput) (*mcp.CallToolResult, any, error) {
+ filter := storage.TaskFilter{Limit: in.Limit}
+ if in.State != "" {
+ st := task.State(in.State)
+ if !validTaskStates[st] {
+ return nil, nil, badRequestf("invalid state %q", in.State)
+ }
+ filter.State = st
+ }
+ tasks, err := s.store.ListTasks(filter)
+ if err != nil {
+ return nil, nil, err
+ }
+ out := make([]compactTask, 0, len(tasks))
+ for _, tk := range tasks {
+ out = append(out, compactTask{ID: tk.ID, Name: tk.Name, State: tk.State, Project: tk.Project})
+ }
+ return mcpJSON(out)
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "get_task",
+ Description: "Get one task with enriched detail (changestats, deployment status, error message).",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) {
+ tk, err := s.store.GetTask(in.TaskID)
+ if err != nil {
+ return nil, nil, errTaskNotFound
+ }
+ return mcpJSON(s.enrichTask(tk))
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "get_events",
+ Description: "Get the observability event stream for a task in seq order. Pass since_seq to page incrementally.",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in getEventsMCPInput) (*mcp.CallToolResult, any, error) {
+ if _, err := s.store.GetTask(in.TaskID); err != nil {
+ return nil, nil, errTaskNotFound
+ }
+ events, err := s.store.ListEvents(in.TaskID, in.SinceSeq)
+ if err != nil {
+ return nil, nil, err
+ }
+ if events == nil {
+ events = []*event.Event{}
+ }
+ return mcpJSON(events)
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "answer_question",
+ Description: "Answer a BLOCKED task's clarification question. The task resumes from where the agent left off.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in answerQuestionMCPInput) (*mcp.CallToolResult, any, error) {
+ if err := s.answerTaskQuestion(ctx, in.TaskID, in.Answer); err != nil {
+ return nil, nil, err
+ }
+ return mcpText("Answer recorded; task queued for resume."), nil, nil
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "accept_task",
+ Description: "Accept a READY task, marking it COMPLETED.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) {
+ if err := s.acceptTask(ctx, in.TaskID, event.ActorChatbot); err != nil {
+ return nil, nil, err
+ }
+ return mcpText("Task accepted."), nil, nil
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "reject_task",
+ Description: "Reject a READY task, sending it back to PENDING with an optional comment for the next attempt.",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in rejectTaskMCPInput) (*mcp.CallToolResult, any, error) {
+ if err := s.rejectTask(in.TaskID, in.Comment); err != nil {
+ return nil, nil, err
+ }
+ return mcpText("Task rejected."), nil, nil
+ })
+
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "cancel_task",
+ Description: "Cancel a RUNNING or QUEUED task.",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) {
+ msg, err := s.cancelTask(in.TaskID, event.ActorChatbot)
+ if err != nil {
+ return nil, nil, err
+ }
+ return mcpText(msg), nil, nil
+ })
+
+ return srv
+}
+
+// chatbotMCPHandler returns the HTTP handler for the chatbot MCP server. It
+// serves only when an API token is configured and the request presents it as a
+// bearer credential; otherwise the underlying handler receives a nil server and
+// rejects the request.
+func (s *Server) chatbotMCPHandler() http.Handler {
+ srv := s.newChatbotServer()
+ return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
+ if s.apiToken == "" {
+ return nil
+ }
+ tok := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
+ if subtle.ConstantTimeCompare([]byte(tok), []byte(s.apiToken)) != 1 {
+ return nil
+ }
+ return srv
+ }, nil)
+}
diff --git a/internal/api/chatbotmcp_test.go b/internal/api/chatbotmcp_test.go
new file mode 100644
index 0000000..d13b0d7
--- /dev/null
+++ b/internal/api/chatbotmcp_test.go
@@ -0,0 +1,233 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// chatbotClient starts the api server with the given token and returns a
+// connected MCP client session pointed at /chatbot/mcp.
+func chatbotClient(t *testing.T, srv *Server, token string) *mcp.ClientSession {
+ t.Helper()
+ ts := httptest.NewServer(srv.Handler())
+ t.Cleanup(ts.Close)
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{
+ Endpoint: ts.URL + "/chatbot/mcp",
+ HTTPClient: &http.Client{Transport: tokenRT{token: token, base: http.DefaultTransport}},
+ }, nil)
+ if err != nil {
+ t.Fatalf("connect to /chatbot/mcp: %v", err)
+ }
+ t.Cleanup(func() { cs.Close() })
+ return cs
+}
+
+func callText(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) string {
+ t.Helper()
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
+ if err != nil {
+ t.Fatalf("CallTool %s transport error: %v", name, err)
+ }
+ if res.IsError {
+ t.Fatalf("CallTool %s returned tool error: %s", name, toolText(res))
+ }
+ return toolText(res)
+}
+
+func toolText(res *mcp.CallToolResult) string {
+ if len(res.Content) == 0 {
+ return ""
+ }
+ if tc, ok := res.Content[0].(*mcp.TextContent); ok {
+ return tc.Text
+ }
+ return ""
+}
+
+func TestChatbotMCP_RequiresToken(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetAPIToken("s3cret")
+ ts := httptest.NewServer(srv.Handler())
+ defer ts.Close()
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ _, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{
+ Endpoint: ts.URL + "/chatbot/mcp",
+ HTTPClient: &http.Client{Transport: tokenRT{token: "wrong", base: http.DefaultTransport}},
+ }, nil)
+ if err == nil {
+ t.Fatal("expected connect with wrong token to be rejected")
+ }
+}
+
+func TestChatbotMCP_SubmitTask_CreatesAndQueues(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ cs := chatbotClient(t, srv, "s3cret")
+
+ out := callText(t, cs, "submit_task", map[string]any{
+ "instructions": "fix the flaky test in package foo",
+ "repository_url": "https://github.com/user/repo",
+ })
+ var ct compactTask
+ if err := json.Unmarshal([]byte(out), &ct); err != nil {
+ t.Fatalf("unmarshal submit_task result %q: %v", out, err)
+ }
+ if ct.ID == "" {
+ t.Fatal("submit_task returned empty task ID")
+ }
+ if ct.Name != "fix the flaky test in package foo" {
+ t.Errorf("name defaulted wrong: %q", ct.Name)
+ }
+ if _, err := store.GetTask(ct.ID); err != nil {
+ t.Fatalf("submitted task not persisted: %v", err)
+ }
+}
+
+func TestChatbotMCP_SubmitTask_RequiresRepo(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetAPIToken("s3cret")
+ cs := chatbotClient(t, srv, "s3cret")
+
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "submit_task",
+ Arguments: map[string]any{"instructions": "do a thing"},
+ })
+ if err != nil {
+ t.Fatalf("transport error: %v", err)
+ }
+ if !res.IsError {
+ t.Fatal("expected submit_task without repository_url to be a tool error")
+ }
+}
+
+func TestChatbotMCP_GetAndListTasks(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-ready", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ got := callText(t, cs, "get_task", map[string]any{"task_id": "ct-ready"})
+ var view map[string]any
+ if err := json.Unmarshal([]byte(got), &view); err != nil {
+ t.Fatalf("unmarshal get_task: %v", err)
+ }
+ if view["id"] != "ct-ready" {
+ t.Errorf("get_task id: got %v", view["id"])
+ }
+
+ listed := callText(t, cs, "list_tasks", map[string]any{"state": "READY"})
+ var tasks []compactTask
+ if err := json.Unmarshal([]byte(listed), &tasks); err != nil {
+ t.Fatalf("unmarshal list_tasks: %v", err)
+ }
+ if len(tasks) != 1 || tasks[0].ID != "ct-ready" {
+ t.Errorf("list_tasks returned %+v", tasks)
+ }
+}
+
+func TestChatbotMCP_GetTask_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetAPIToken("s3cret")
+ cs := chatbotClient(t, srv, "s3cret")
+
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "get_task",
+ Arguments: map[string]any{"task_id": "nope"},
+ })
+ if err != nil {
+ t.Fatalf("transport error: %v", err)
+ }
+ if !res.IsError {
+ t.Fatal("expected get_task on missing task to be a tool error")
+ }
+}
+
+func TestChatbotMCP_GetEvents(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-events", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ out := callText(t, cs, "get_events", map[string]any{"task_id": "ct-events"})
+ var events []map[string]any
+ if err := json.Unmarshal([]byte(out), &events); err != nil {
+ t.Fatalf("unmarshal get_events %q: %v", out, err)
+ }
+ // State walk to READY emits state_change events.
+ if len(events) == 0 {
+ t.Error("expected at least one event for a task that walked to READY")
+ }
+}
+
+func TestChatbotMCP_AcceptTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-accept", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "accept_task", map[string]any{"task_id": "ct-accept"})
+ got, _ := store.GetTask("ct-accept")
+ if got.State != task.StateCompleted {
+ t.Errorf("after accept_task: want COMPLETED, got %s", got.State)
+ }
+}
+
+func TestChatbotMCP_RejectTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-reject", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "reject_task", map[string]any{"task_id": "ct-reject", "comment": "needs work"})
+ got, _ := store.GetTask("ct-reject")
+ if got.State != task.StatePending {
+ t.Errorf("after reject_task: want PENDING, got %s", got.State)
+ }
+ if got.RejectionComment != "needs work" {
+ t.Errorf("rejection comment: got %q", got.RejectionComment)
+ }
+}
+
+func TestChatbotMCP_CancelTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-cancel", task.StateQueued)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "cancel_task", map[string]any{"task_id": "ct-cancel"})
+ got, _ := store.GetTask("ct-cancel")
+ if got.State != task.StateCancelled {
+ t.Errorf("after cancel_task: want CANCELLED, got %s", got.State)
+ }
+}
+
+func TestChatbotMCP_AnswerQuestion(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-answer", task.StateBlocked)
+ if err := store.CreateExecution(&storage.Execution{
+ ID: "ct-answer-exec",
+ TaskID: "ct-answer",
+ SessionID: "550e8400-e29b-41d4-a716-446655440099",
+ Status: "BLOCKED",
+ }); err != nil {
+ t.Fatalf("create execution: %v", err)
+ }
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "answer_question", map[string]any{"task_id": "ct-answer", "answer": "use main"})
+ got, _ := store.GetTask("ct-answer")
+ if got.State != task.StateQueued && got.State != task.StateRunning && got.State != task.StateReady {
+ t.Errorf("after answer_question: want QUEUED/RUNNING/READY, got %s", got.State)
+ }
+}
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go
index 8676b36..e8930a6 100644
--- a/internal/api/elaborate.go
+++ b/internal/api/elaborate.go
@@ -6,128 +6,13 @@ import (
"encoding/json"
"fmt"
"net/http"
- "os"
"os/exec"
- "path/filepath"
- "sort"
"strings"
"time"
-
- "github.com/thepeterstone/claudomator/internal/llm"
)
const elaborateTimeout = 30 * time.Second
-func buildElaboratePrompt(workDir string) string {
- workDirLine := ` "project_dir": string — leave empty unless you have a specific reason to set it,`
- 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 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.
-
-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,
- "agent": {
- "type": "claude" | "gemini",
- "model": string — "sonnet" for claude, "gemini-2.5-flash-lite" for gemini,
- "instructions": string — detailed, step-by-step instructions for the agent. Must end with a "## Acceptance Criteria" section listing measurable conditions that define success. For coding tasks, include TDD requirements (write failing tests first, then implement),
-` + workDirLine + `
- "max_budget_usd": number — conservative estimate (0.25–5.00),
- "allowed_tools": array — every tool the task genuinely needs. Include "Write" if creating files, "Edit" if modifying files, "Read" if reading files, "Bash" for shell/git/test commands, "Grep"/"Glob" for searching.
- },
- "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"`
- Agent elaboratedAgent `json:"agent"`
- Timeout string `json:"timeout"`
- Priority string `json:"priority"`
- Tags []string `json:"tags"`
-}
-
-type elaboratedAgent struct {
- Type string `json:"type"`
- Model string `json:"model"`
- Instructions string `json:"instructions"`
- ProjectDir string `json:"project_dir"`
- MaxBudgetUSD float64 `json:"max_budget_usd"`
- AllowedTools []string `json:"allowed_tools"`
-}
-
-// sanitizeElaboratedTask enforces tool completeness and dev practice compliance.
-// It modifies t in place, inferring missing tools from instruction keywords and
-// appending required sections when they are absent.
-func sanitizeElaboratedTask(t *elaboratedTask) {
- lower := strings.ToLower(t.Agent.Instructions)
-
- // Build current tool set.
- toolSet := make(map[string]bool, len(t.Agent.AllowedTools))
- for _, tool := range t.Agent.AllowedTools {
- toolSet[tool] = true
- }
-
- // Infer missing tools from instruction keywords.
- type rule struct {
- tool string
- keywords []string
- }
- rules := []rule{
- {"Write", []string{"create file", "write file", "new file", "write to", "save to", "output to", "generate file", "creates a file", "create a new file"}},
- {"Edit", []string{"edit", "modify", "refactor", "replace", "patch"}},
- {"Read", []string{"read", "inspect", "examine", "look at the file"}},
- {"Bash", []string{"run", "execute", "bash", "shell", "command", "build", "compile", "git", "install", "make"}},
- {"Grep", []string{"search for", "grep", "find in", "locate in"}},
- {"Glob", []string{"find file", "list file", "search file"}},
- }
- for _, r := range rules {
- if toolSet[r.tool] {
- continue
- }
- for _, kw := range r.keywords {
- if strings.Contains(lower, kw) {
- toolSet[r.tool] = true
- break
- }
- }
- }
- // Edit without Read is almost always wrong.
- if toolSet["Edit"] && !toolSet["Read"] {
- toolSet["Read"] = true
- }
- // Rebuild the list only when tools were added.
- if len(toolSet) > len(t.Agent.AllowedTools) {
- tools := make([]string, 0, len(toolSet))
- for tool := range toolSet {
- tools = append(tools, tool)
- }
- sort.Strings(tools)
- t.Agent.AllowedTools = tools
- }
-
- // Append an acceptance criteria section when none is present.
- if !strings.Contains(lower, "acceptance") &&
- !strings.Contains(lower, "done when") &&
- !strings.Contains(lower, "success criteria") {
- t.Agent.Instructions += "\n\n## Acceptance Criteria\nBefore finishing, verify all stated goals are met, tests pass (if applicable), and no unintended side effects were introduced."
- }
-
- // Append a TDD reminder for coding tasks that do not already mention tests.
- if (toolSet["Edit"] || toolSet["Write"]) && !strings.Contains(lower, "test") {
- t.Agent.Instructions += "\n\n## Dev Practices\nFollow TDD: write a failing test first, then implement the minimum code to make it pass. Commit all changes before finishing."
- }
-}
-
// claudeJSONResult is the top-level object returned by `claude --output-format json`.
type claudeJSONResult struct {
Result string `json:"result"`
@@ -167,142 +52,6 @@ func (s *Server) geminiBinaryPath() string {
return "gemini"
}
-func readProjectContext(workDir string) string {
- if workDir == "" {
- return ""
- }
- var sb strings.Builder
- for _, filename := range []string{"CLAUDE.md", ".agent/worklog.md"} {
- path := filepath.Join(workDir, filename)
- if data, err := os.ReadFile(path); err == nil {
- if sb.Len() > 0 {
- sb.WriteString("\n\n")
- }
- sb.WriteString(fmt.Sprintf("--- %s ---\n%s", filename, string(data)))
- }
- }
- return sb.String()
-}
-
-func (s *Server) appendRawNarrative(workDir, prompt string) {
- if workDir == "" {
- return
- }
- docsDir := filepath.Join(workDir, "docs")
- if err := os.MkdirAll(docsDir, 0755); err != nil {
- s.logger.Error("elaborate: failed to create docs directory", "error", err, "path", docsDir)
- return
- }
- path := filepath.Join(docsDir, "RAW_NARRATIVE.md")
- f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err != nil {
- s.logger.Error("elaborate: failed to open RAW_NARRATIVE.md", "error", err, "path", path)
- return
- }
- defer f.Close()
-
- entry := fmt.Sprintf("\n--- %s ---\n%s\n", time.Now().Format(time.RFC3339), prompt)
- if _, err := f.WriteString(entry); err != nil {
- s.logger.Error("elaborate: failed to write to RAW_NARRATIVE.md", "error", err, "path", path)
- }
-}
-
-func (s *Server) elaborateWithClaude(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) {
- cmd := exec.CommandContext(ctx, s.claudeBinaryPath(),
- "-p", fullPrompt,
- "--system-prompt", buildElaboratePrompt(workDir),
- "--output-format", "json",
- "--model", "haiku",
- )
-
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
-
- // Claude returns exit 1 if rate limited but still outputs JSON with is_error: true
- err := cmd.Run()
-
- output := stdout.Bytes()
- if len(output) == 0 {
- if err != nil {
- return nil, fmt.Errorf("claude failed: %w (stderr: %s)", err, stderr.String())
- }
- return nil, fmt.Errorf("claude returned no output")
- }
-
- var wrapper claudeJSONResult
- if jerr := json.Unmarshal(output, &wrapper); jerr != nil {
- return nil, fmt.Errorf("failed to parse claude JSON wrapper: %w (output: %s)", jerr, string(output))
- }
-
- if wrapper.IsError {
- return nil, fmt.Errorf("claude error: %s", wrapper.Result)
- }
-
- var result elaboratedTask
- if jerr := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); jerr != nil {
- return nil, fmt.Errorf("failed to parse elaborated task JSON: %w (result: %s)", jerr, wrapper.Result)
- }
-
- return &result, nil
-}
-
-// elaborateWithLocal runs elaboration through an OpenAI-compatible local LLM.
-// It uses the same prompt template as the Claude/Gemini paths and requests
-// json_object response format so we can decode directly without the
-// markdown-fence cleanup needed for the CLI paths.
-func elaborateWithLocal(ctx context.Context, c *llm.Client, workDir, fullPrompt string) (*elaboratedTask, error) {
- if c == nil {
- return nil, fmt.Errorf("local llm: no client configured")
- }
- systemPrompt := buildElaboratePrompt(workDir)
- resp, err := c.Chat(ctx, llm.ChatRequest{
- Messages: []llm.Message{
- {Role: "system", Content: systemPrompt},
- {Role: "user", Content: fullPrompt},
- },
- ResponseJSON: true,
- })
- if err != nil {
- return nil, fmt.Errorf("local llm: %w", err)
- }
- body := strings.TrimSpace(resp.Content)
- var result elaboratedTask
- if jerr := json.Unmarshal([]byte(extractJSON(body)), &result); jerr != nil {
- return nil, fmt.Errorf("local llm: parse JSON: %w (response: %s)", jerr, body)
- }
- return &result, nil
-}
-
-func (s *Server) elaborateWithGemini(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) {
- combinedPrompt := fmt.Sprintf("%s\n\n%s", buildElaboratePrompt(workDir), fullPrompt)
- cmd := exec.CommandContext(ctx, s.geminiBinaryPath(),
- "-p", combinedPrompt,
- "--output-format", "json",
- "--model", "gemini-2.5-flash-lite",
- )
-
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
-
- if err := cmd.Run(); err != nil {
- return nil, fmt.Errorf("gemini failed: %w (stderr: %s)", err, stderr.String())
- }
-
- var wrapper geminiJSONResult
- if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil {
- return nil, fmt.Errorf("failed to parse gemini JSON wrapper: %w (output: %s)", err, stdout.String())
- }
-
- var result elaboratedTask
- if err := json.Unmarshal([]byte(extractJSON(wrapper.Response)), &result); err != nil {
- return nil, fmt.Errorf("failed to parse elaborated task JSON: %w (response: %s)", err, wrapper.Response)
- }
-
- return &result, nil
-}
-
// elaboratedStorySubtask is a leaf unit within a story task.
type elaboratedStorySubtask struct {
Name string `json:"name"`
@@ -493,89 +242,3 @@ func (s *Server) handleElaborateStory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result)
}
-
-func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
- if s.elaborateLimiter != nil && !s.elaborateLimiter.allow(realIP(r)) {
- writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"})
- return
- }
-
- var input struct {
- Prompt string `json:"prompt"`
- ProjectID string `json:"project_id"`
- // project_dir kept for backward compat; project_id takes precedence
- ProjectDir string `json:"project_dir"`
- }
- 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
- }
-
- workDir := s.workDir
- if input.ProjectID != "" {
- if proj, err := s.store.GetProject(input.ProjectID); err == nil {
- workDir = proj.LocalPath
- }
- } else if input.ProjectDir != "" {
- workDir = input.ProjectDir
- }
-
- if workDir != s.workDir {
- go s.appendRawNarrative(workDir, input.Prompt)
- }
-
- projectContext := readProjectContext(workDir)
- fullPrompt := input.Prompt
- if projectContext != "" {
- fullPrompt = fmt.Sprintf("Project context from %s:\n%s\n\nUser request: %s", workDir, projectContext, input.Prompt)
- }
-
- ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout)
- defer cancel()
-
- var result *elaboratedTask
- var err error
-
- // Try local LLM first when configured. Falls back to Claude → Gemini on
- // hard failure of each prior attempt.
- if s.llm != nil {
- result, err = elaborateWithLocal(ctx, s.llm, workDir, fullPrompt)
- if err != nil {
- s.logger.Warn("elaborate: local llm failed, falling back to claude", "error", err)
- result = nil
- }
- }
- if result == nil {
- result, err = s.elaborateWithClaude(ctx, workDir, fullPrompt)
- if err != nil {
- s.logger.Warn("elaborate: claude failed, falling back to gemini", "error", err)
- result, err = s.elaborateWithGemini(ctx, workDir, fullPrompt)
- if err != nil {
- s.logger.Error("elaborate: gemini also failed", "error", err)
- writeJSON(w, http.StatusBadGateway, map[string]string{
- "error": fmt.Sprintf("elaboration failed: %v", err),
- })
- return
- }
- }
- }
-
- 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"
- }
-
- sanitizeElaboratedTask(result)
-
- writeJSON(w, http.StatusOK, result)
-}
diff --git a/internal/api/elaborate_local_test.go b/internal/api/elaborate_local_test.go
deleted file mode 100644
index 09a8f9e..0000000
--- a/internal/api/elaborate_local_test.go
+++ /dev/null
@@ -1,214 +0,0 @@
-package api
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
-
- "github.com/thepeterstone/claudomator/internal/llm"
-)
-
-// fakeChatCompletionsServer returns an httptest server that responds to a
-// /chat/completions POST with the given assistant content (which should be a
-// JSON-encoded elaboratedTask). Returns the server and a counter of calls
-// received so tests can assert dispatch ordering.
-func fakeChatCompletionsServer(t *testing.T, assistantContent string) (*httptest.Server, *int32) {
- t.Helper()
- var calls int32
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&calls, 1)
- w.Header().Set("Content-Type", "application/json")
- // The assistant content has to be JSON-encoded inside the wire format.
- escaped, _ := json.Marshal(assistantContent)
- fmt.Fprintf(w, `{
- "model":"local",
- "choices":[{"message":{"role":"assistant","content":%s},"finish_reason":"stop"}],
- "usage":{"prompt_tokens":10,"completion_tokens":50}
- }`, string(escaped))
- }))
- t.Cleanup(srv.Close)
- return srv, &calls
-}
-
-func TestElaborateWithLocal_ParsesValidResponse(t *testing.T) {
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Test elaborated task",
- Description: "From local llm",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Run go build.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- Tags: []string{"build"},
- })
- srv, calls := fakeChatCompletionsServer(t, string(taskBody))
-
- c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
- result, err := elaborateWithLocal(context.Background(), c, "/some/dir", "build the project")
- if err != nil {
- t.Fatalf("elaborateWithLocal: %v", err)
- }
- if result.Name != "Test elaborated task" {
- t.Errorf("Name: %q", result.Name)
- }
- if result.Agent.Instructions != "Run go build." {
- t.Errorf("Instructions: %q", result.Agent.Instructions)
- }
- if got := atomic.LoadInt32(calls); got != 1 {
- t.Errorf("expected 1 call, got %d", got)
- }
-}
-
-func TestElaborateWithLocal_NilClient(t *testing.T) {
- _, err := elaborateWithLocal(context.Background(), nil, "", "p")
- if err == nil || !strings.Contains(err.Error(), "no client") {
- t.Errorf("expected nil-client error, got %v", err)
- }
-}
-
-func TestElaborateWithLocal_BadJSON(t *testing.T) {
- srv, _ := fakeChatCompletionsServer(t, "this is not JSON at all")
- c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
- _, err := elaborateWithLocal(context.Background(), c, "", "p")
- if err == nil || !strings.Contains(err.Error(), "parse JSON") {
- t.Errorf("expected parse error, got %v", err)
- }
-}
-
-// TestElaborateTask_LocalLLMPreferred verifies the dispatcher uses local LLM
-// when SetLLM is configured, and does not invoke claude.
-func TestElaborateTask_LocalLLMPreferred(t *testing.T) {
- srv, _ := testServer(t)
-
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Local-elaborated",
- Description: "From local",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Do work. Tests pass when complete.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- })
- llmSrv, _ := fakeChatCompletionsServer(t, string(taskBody))
- srv.SetLLM(&llm.Client{Endpoint: llmSrv.URL + "/v1", Model: "fake"})
- // Point Claude binary at a path that would fail if called.
- srv.elaborateCmdPath = "/nonexistent/claude-should-not-run"
-
- body := `{"prompt":"do work"}`
- 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 got elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if got.Name != "Local-elaborated" {
- t.Errorf("Name: want Local-elaborated got %q", got.Name)
- }
-}
-
-// TestElaborateTask_LocalFails_FallsBackToClaude verifies the dispatcher
-// falls back to the Claude path when the local LLM returns an error.
-func TestElaborateTask_LocalFails_FallsBackToClaude(t *testing.T) {
- srv, _ := testServer(t)
-
- // Local LLM server that always 500s.
- failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, "boom", http.StatusInternalServerError)
- }))
- t.Cleanup(failSrv.Close)
- srv.SetLLM(&llm.Client{Endpoint: failSrv.URL + "/v1", Model: "fake"})
-
- // Configure a working fake Claude binary.
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Claude-fallback",
- Description: "From claude after local failed",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Run tests.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- })
- wrapper, _ := json.Marshal(map[string]string{"result": string(taskBody)})
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapper), 0)
-
- body := `{"prompt":"run tests"}`
- 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 got elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if got.Name != "Claude-fallback" {
- t.Errorf("Name: want Claude-fallback (fallback path) got %q", got.Name)
- }
-}
-
-// TestElaborateTask_NoLocalLLM_UsesClaude verifies that when SetLLM is not
-// called, behavior is unchanged (Claude path still primary).
-func TestElaborateTask_NoLocalLLM_UsesClaude(t *testing.T) {
- srv, _ := testServer(t)
-
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Claude-only",
- Description: "no local llm configured",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Do work.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- })
- wrapper, _ := json.Marshal(map[string]string{"result": string(taskBody)})
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapper), 0)
-
- body := `{"prompt":"do work"}`
- 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 got elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if got.Name != "Claude-only" {
- t.Errorf("Name: %q", got.Name)
- }
-}
-
diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go
deleted file mode 100644
index 32cec3c..0000000
--- a/internal/api/elaborate_test.go
+++ /dev/null
@@ -1,527 +0,0 @@
-package api
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-// 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
-}
-
-// hasTool is a test helper that reports whether name is in the tools slice.
-func hasTool(tools []string, name string) bool {
- for _, t := range tools {
- if t == name {
- return true
- }
- }
- return false
-}
-
-// --- sanitizeElaboratedTask unit tests ---
-
-func TestSanitize_AddsWriteWhenInstructionsMentionFileCreation(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Create a new file called output.txt with the results.",
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- if !hasTool(task.Agent.AllowedTools, "Write") {
- t.Errorf("expected Write in allowed_tools, got %v", task.Agent.AllowedTools)
- }
-}
-
-func TestSanitize_AddsReadWhenEditIsPresent(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Modify the configuration file.",
- AllowedTools: []string{"Edit"},
- },
- }
- sanitizeElaboratedTask(task)
- if !hasTool(task.Agent.AllowedTools, "Read") {
- t.Errorf("expected Read added alongside Edit, got %v", task.Agent.AllowedTools)
- }
-}
-
-func TestSanitize_NoDuplicateTools(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Run go test ./...",
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- count := 0
- for _, tool := range task.Agent.AllowedTools {
- if tool == "Bash" {
- count++
- }
- }
- if count != 1 {
- t.Errorf("Bash duplicated in allowed_tools: %v", task.Agent.AllowedTools)
- }
-}
-
-func TestSanitize_AddsAcceptanceCriteriaWhenMissing(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Do something useful with the codebase.",
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- lower := strings.ToLower(task.Agent.Instructions)
- if !strings.Contains(lower, "acceptance") && !strings.Contains(lower, "done when") {
- t.Error("expected acceptance criteria section appended to instructions")
- }
-}
-
-func TestSanitize_NoopWhenAcceptanceCriteriaAlreadyPresent(t *testing.T) {
- original := "Do something.\n\n## Acceptance Criteria\n- All tests pass."
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: original,
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- if task.Agent.Instructions != original {
- t.Errorf("instructions were modified when acceptance criteria were already present")
- }
-}
-
-func TestSanitize_AddsTDDReminderForCodingTaskWithoutTestMention(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "## Acceptance Criteria\nFix the bug.\n\nModify the handler to return 404 instead of 500.",
- AllowedTools: []string{"Edit", "Read"},
- },
- }
- sanitizeElaboratedTask(task)
- lower := strings.ToLower(task.Agent.Instructions)
- if !strings.Contains(lower, "tdd") && !strings.Contains(lower, "test") {
- t.Error("expected TDD reminder for coding task without test mention")
- }
-}
-
-func TestSanitize_NoTDDReminderWhenTestsAlreadyMentioned(t *testing.T) {
- original := "## Acceptance Criteria\nAll tests pass.\n\nEdit the file and run go test ./... to verify."
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: original,
- AllowedTools: []string{"Edit", "Read", "Bash"},
- },
- }
- before := task.Agent.Instructions
- sanitizeElaboratedTask(task)
- // Should NOT add a second TDD block since tests are already mentioned.
- // Count occurrences of "tdd" / "test" — just verify no double-append.
- if strings.Count(strings.ToLower(task.Agent.Instructions), "tdd") > 1 {
- t.Errorf("TDD block added twice; instructions:\n%s", task.Agent.Instructions)
- }
- _ = before
-}
-
-func TestElaboratePrompt_RequiresAcceptanceCriteria(t *testing.T) {
- prompt := buildElaboratePrompt("")
- lower := strings.ToLower(prompt)
- if !strings.Contains(lower, "acceptance criteria") {
- t.Error("elaborate prompt should instruct the model to include acceptance criteria")
- }
-}
-
-func TestElaboratePrompt_RequiresAllRelevantTools(t *testing.T) {
- prompt := buildElaboratePrompt("")
- // Prompt must remind the model to include file-creating tools when needed.
- if !strings.Contains(prompt, "Write") {
- t.Error("elaborate prompt should mention the Write tool so models know to include it")
- }
-}
-
-func TestElaborateTask_SanitizationAppliedToResponse(t *testing.T) {
- srv, _ := testServer(t)
-
- // Elaborator returns a task that needs Write (instructions say "create file")
- // but does NOT include it in allowed_tools.
- task := elaboratedTask{
- Name: "Generate report",
- Description: "Creates a report file.",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Create a new file called report.md with the analysis results.\n\n## Acceptance Criteria\n- report.md exists.",
- MaxBudgetUSD: 0.5,
- AllowedTools: []string{"Bash"}, // Write intentionally missing
- },
- Timeout: "15m",
- Priority: "normal",
- Tags: []string{"report"},
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
-
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- body := `{"prompt":"generate a report"}`
- 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 !hasTool(result.Agent.AllowedTools, "Write") {
- t.Errorf("expected Write in sanitized allowed_tools, got %v", result.Agent.AllowedTools)
- }
-}
-
-func TestElaboratePrompt_ContainsWorkDir(t *testing.T) {
- prompt := buildElaboratePrompt("/some/custom/path")
- if !strings.Contains(prompt, "/some/custom/path") {
- t.Error("prompt should contain the provided workDir")
- }
- if strings.Contains(prompt, "/root/workspace/claudomator") {
- t.Error("prompt should not hardcode /root/workspace/claudomator")
- }
-}
-
-func TestElaboratePrompt_EmptyWorkDir(t *testing.T) {
- prompt := buildElaboratePrompt("")
- if strings.Contains(prompt, "/root") {
- t.Error("prompt should not reference /root when workDir is empty")
- }
-}
-
-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.",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Run go test -race ./... and report results.",
- ProjectDir: "",
- 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.Agent.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.",
- Agent: elaboratedAgent{
- Type: "claude",
- 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)
- // Ensure Gemini fallback also fails so we get the expected 502.
- srv.geminiBinPath = "/nonexistent/gemini"
-
- 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")
- }
-}
-
-func createFakeClaudeCapturingArgs(t *testing.T, output string, exitCode int, argsFile string) 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")
- // Use printf to handle arguments safely
- content := fmt.Sprintf("#!/bin/sh\nprintf \"%%s\\n\" \"$@\" > %q\ncat %q\nexit %d\n", argsFile, outputFile, exitCode)
- if err := os.WriteFile(script, []byte(content), 0755); err != nil {
- t.Fatal(err)
- }
- return script
-}
-
-func TestElaborateTask_WithProjectContext(t *testing.T) {
- srv, _ := testServer(t)
-
- // Create a temporary workspace with CLAUDE.md and .agent/worklog.md
- workDir := t.TempDir()
- claudeContent := "Claude context info"
- sessionContent := "Session state info"
- if err := os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(claudeContent), 0600); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(workDir, ".agent"), 0700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(workDir, ".agent", "worklog.md"), []byte(sessionContent), 0600); err != nil {
- t.Fatal(err)
- }
-
- // Capture arguments passed to claude
- argsFile := filepath.Join(t.TempDir(), "args.txt")
-
- task := elaboratedTask{
- Name: "Task with context",
- Agent: elaboratedAgent{
- Instructions: "Instructions",
- },
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
-
- // Modified createFakeClaude to capture arguments
- srv.elaborateCmdPath = createFakeClaudeCapturingArgs(t, string(wrapperJSON), 0, argsFile)
-
- body := fmt.Sprintf(`{"prompt":"do something", "project_dir":"%s"}`, workDir)
- 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())
- }
-
- // Check if captured arguments contain the context
- capturedArgs, err := os.ReadFile(argsFile)
- if err != nil {
- t.Fatal(err)
- }
- argsStr := string(capturedArgs)
- if !strings.Contains(argsStr, claudeContent) {
- t.Errorf("expected arguments to contain CLAUDE.md content, got %s", argsStr)
- }
- if !strings.Contains(argsStr, sessionContent) {
- t.Errorf("expected arguments to contain .agent/worklog.md content, got %s", argsStr)
- }
-}
-
-func TestElaborateTask_NoRawNarrativeWithoutExplicitProjectDir(t *testing.T) {
- srv, _ := testServer(t)
- // Point workDir at a temp dir so any accidental write is detectable.
- srv.workDir = t.TempDir()
-
- task := elaboratedTask{
- Name: "Task",
- Agent: elaboratedAgent{Instructions: "Instructions"},
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- // No project_dir in request body.
- 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", w.Code)
- }
-
- time.Sleep(30 * time.Millisecond) // let goroutine run if it was incorrectly triggered
- narrativePath := filepath.Join(srv.workDir, "docs", "RAW_NARRATIVE.md")
- if _, err := os.Stat(narrativePath); err == nil {
- t.Errorf("RAW_NARRATIVE.md should NOT be written when project_dir is not provided by the user")
- }
-}
-
-func TestElaborateTask_AppendsRawNarrative(t *testing.T) {
- srv, _ := testServer(t)
-
- workDir := t.TempDir()
- prompt := "this is my raw request"
-
- task := elaboratedTask{
- Name: "Task",
- Agent: elaboratedAgent{
- Instructions: "Instructions",
- },
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
-
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- body := fmt.Sprintf(`{"prompt":"%s", "project_dir":"%s"}`, prompt, workDir)
- 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())
- }
-
- // It runs in a goroutine, so wait a bit
- path := filepath.Join(workDir, "docs", "RAW_NARRATIVE.md")
- var data []byte
- var err error
- for i := 0; i < 10; i++ {
- data, err = os.ReadFile(path)
- if err == nil {
- break
- }
- time.Sleep(10 * time.Millisecond)
- }
-
- if err != nil {
- t.Fatalf("failed to read RAW_NARRATIVE.md: %v", err)
- }
- if !strings.Contains(string(data), prompt) {
- t.Errorf("expected RAW_NARRATIVE.md to contain prompt, got %s", string(data))
- }
-}
diff --git a/internal/api/events.go b/internal/api/events.go
new file mode 100644
index 0000000..eefe064
--- /dev/null
+++ b/internal/api/events.go
@@ -0,0 +1,38 @@
+package api
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/thepeterstone/claudomator/internal/event"
+)
+
+// handleListTaskEvents returns a task's observability event stream in seq order.
+// Pass ?since_seq=N to fetch only events newer than seq N (incremental polling).
+func (s *Server) handleListTaskEvents(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if _, err := s.store.GetTask(id); err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
+ return
+ }
+
+ var sinceSeq int64
+ if v := r.URL.Query().Get("since_seq"); v != "" {
+ n, err := strconv.ParseInt(v, 10, 64)
+ if err != nil || n < 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid since_seq"})
+ return
+ }
+ sinceSeq = n
+ }
+
+ events, err := s.store.ListEvents(id, sinceSeq)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if events == nil {
+ events = []*event.Event{}
+ }
+ writeJSON(w, http.StatusOK, events)
+}
diff --git a/internal/api/events_test.go b/internal/api/events_test.go
new file mode 100644
index 0000000..456b6d8
--- /dev/null
+++ b/internal/api/events_test.go
@@ -0,0 +1,94 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+func TestListTaskEvents_ReturnsStreamInSeqOrder(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "ev-task", task.StateReady)
+
+ req := httptest.NewRequest("GET", "/api/tasks/ev-task/events", 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 events []*event.Event
+ if err := json.Unmarshal(w.Body.Bytes(), &events); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(events) == 0 {
+ t.Fatal("expected state-change events from the walk to READY")
+ }
+ for i := 1; i < len(events); i++ {
+ if events[i].Seq <= events[i-1].Seq {
+ t.Errorf("events not in ascending seq order: %d then %d", events[i-1].Seq, events[i].Seq)
+ }
+ }
+}
+
+func TestListTaskEvents_SinceSeqFilters(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "ev-since", task.StateReady)
+
+ all := getEvents(t, srv, "ev-since", "")
+ if len(all) < 2 {
+ t.Skipf("need at least 2 events to test since_seq, got %d", len(all))
+ }
+ firstSeq := all[0].Seq
+ rest := getEvents(t, srv, "ev-since", "?since_seq="+itoa(firstSeq))
+ for _, e := range rest {
+ if e.Seq <= firstSeq {
+ t.Errorf("since_seq=%d returned event with seq %d", firstSeq, e.Seq)
+ }
+ }
+}
+
+func TestListTaskEvents_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/tasks/missing/events", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Errorf("want 404, got %d", w.Code)
+ }
+}
+
+func TestListTaskEvents_InvalidSinceSeq(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "ev-bad", task.StateReady)
+ req := httptest.NewRequest("GET", "/api/tasks/ev-bad/events?since_seq=abc", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("want 400, got %d", w.Code)
+ }
+}
+
+func getEvents(t *testing.T, srv *Server, taskID, query string) []*event.Event {
+ t.Helper()
+ req := httptest.NewRequest("GET", "/api/tasks/"+taskID+"/events"+query, nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("getEvents %s%s: status %d", taskID, query, w.Code)
+ }
+ var events []*event.Event
+ if err := json.Unmarshal(w.Body.Bytes(), &events); err != nil {
+ t.Fatalf("getEvents unmarshal: %v", err)
+ }
+ return events
+}
+
+func itoa(n int64) string {
+ return strconv.FormatInt(n, 10)
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index afb80ad..4af7325 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -13,6 +13,7 @@ import (
"time"
"github.com/thepeterstone/claudomator/internal/config"
+ "github.com/thepeterstone/claudomator/internal/event"
"github.com/thepeterstone/claudomator/internal/executor"
"github.com/thepeterstone/claudomator/internal/llm"
"github.com/thepeterstone/claudomator/internal/notify"
@@ -40,6 +41,7 @@ type Server struct {
taskLogStore taskLogStore // injectable for tests; defaults to store
questionStore questionStore // injectable for tests; defaults to store
pool *executor.Pool
+ registry *executor.Registry // per-task agent MCP token registry; mounts /mcp when set
hub *Hub
logger *slog.Logger
mux *http.ServeMux
@@ -62,7 +64,8 @@ type Server struct {
pushStore pushSubscriptionStore
dropsDir string
llm *llm.Client
- basePath string // URL prefix the UI is mounted at, e.g. "/claudomator"
+ budget budgetReporter // optional; per-provider spend headroom for GET /api/budget
+ basePath string // URL prefix the UI is mounted at, e.g. "/claudomator"
}
// SetAPIToken configures a bearer token that must be supplied to access the API.
@@ -98,6 +101,7 @@ func (s *Server) SetBasePath(p string) {
s.basePath = p
}
+
// Pool returns the executor pool, for graceful shutdown by the caller.
func (s *Server) Pool() *executor.Pool { return s.pool }
@@ -109,7 +113,7 @@ func (s *Server) SetLLM(c *llm.Client) {
}
-func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server {
+func NewServer(store *storage.DB, pool *executor.Pool, registry *executor.Registry, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server {
wd, _ := os.Getwd()
s := &Server{
ctx: context.Background(),
@@ -118,6 +122,7 @@ func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, clau
taskLogStore: store,
questionStore: store,
pool: pool,
+ registry: registry,
hub: NewHub(),
logger: logger,
mux: http.NewServeMux(),
@@ -140,7 +145,6 @@ func (s *Server) StartHub() {
}
func (s *Server) routes() {
- s.mux.HandleFunc("POST /api/tasks/elaborate", s.handleElaborateTask)
s.mux.HandleFunc("POST /api/tasks/validate", s.handleValidateTask)
s.mux.HandleFunc("POST /api/tasks", s.handleCreateTask)
s.mux.HandleFunc("GET /api/tasks", s.handleListTasks)
@@ -152,8 +156,10 @@ func (s *Server) routes() {
s.mux.HandleFunc("DELETE /api/tasks/{id}", s.handleDeleteTask)
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/tasks/{id}/events", s.handleListTaskEvents)
s.mux.HandleFunc("GET /api/executions", s.handleListRecentExecutions)
s.mux.HandleFunc("GET /api/stats", s.handleGetDashboardStats)
+ s.mux.HandleFunc("GET /api/budget", s.handleGetBudget)
s.mux.HandleFunc("GET /api/agents/status", s.handleGetAgentStatus)
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
s.mux.HandleFunc("GET /api/executions/{id}/log", s.handleGetExecutionLog)
@@ -189,6 +195,16 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/drops", s.handleListDrops)
s.mux.HandleFunc("GET /api/drops/{filename}", s.handleGetDrop)
s.mux.HandleFunc("POST /api/drops", s.handlePostDrop)
+ if s.registry != nil {
+ mcpHandler := executor.NewAgentMCPHandler(s.registry)
+ s.mux.Handle("POST /mcp", mcpHandler)
+ s.mux.Handle("GET /mcp", mcpHandler)
+ s.mux.Handle("DELETE /mcp", mcpHandler)
+ }
+ chatbotHandler := s.chatbotMCPHandler()
+ s.mux.Handle("POST /chatbot/mcp", chatbotHandler)
+ s.mux.Handle("GET /chatbot/mcp", chatbotHandler)
+ s.mux.Handle("DELETE /chatbot/mcp", chatbotHandler)
s.mux.HandleFunc("GET /", s.handleStaticFiles)
}
@@ -280,41 +296,16 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCancelTask(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
- tk, err := s.store.GetTask(taskID)
+ msg, err := s.cancelTask(taskID, event.ActorUser)
if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- // If the task is actively running in the pool, cancel it there.
- if s.pool.Cancel(taskID) {
- writeJSON(w, http.StatusOK, map[string]string{"message": "task cancellation requested", "task_id": taskID})
- return
- }
- // For non-running tasks (PENDING, QUEUED), transition directly to CANCELLED.
- if !task.ValidTransition(tk.State, task.StateCancelled) {
- writeJSON(w, http.StatusConflict, map[string]string{"error": "task cannot be cancelled from state " + string(tk.State)})
- return
- }
- if err := s.store.UpdateTaskState(taskID, task.StateCancelled); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to cancel task"})
+ writeOpError(w, err)
return
}
- writeJSON(w, http.StatusOK, map[string]string{"message": "task cancelled", "task_id": taskID})
+ writeJSON(w, http.StatusOK, map[string]string{"message": msg, "task_id": taskID})
}
func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
-
- tk, err := s.questionStore.GetTask(taskID)
- if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- if tk.State != task.StateBlocked {
- writeJSON(w, http.StatusConflict, map[string]string{"error": "task is not blocked"})
- return
- }
-
var input struct {
Answer string `json:"answer"`
}
@@ -322,61 +313,10 @@ func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
- if input.Answer == "" {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "answer is required"})
- return
- }
-
- // Look up the session ID from the most recent execution.
- latest, err := s.questionStore.GetLatestExecution(taskID)
- if err != nil || latest.SessionID == "" {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no resumable session found"})
- return
- }
-
- // Record the Q&A interaction before clearing the question.
- if tk.QuestionJSON != "" {
- var qData struct {
- Text string `json:"text"`
- Options []string `json:"options"`
- }
- if jsonErr := json.Unmarshal([]byte(tk.QuestionJSON), &qData); jsonErr == nil {
- interaction := task.Interaction{
- QuestionText: qData.Text,
- Options: qData.Options,
- Answer: input.Answer,
- AskedAt: tk.UpdatedAt,
- }
- if appendErr := s.questionStore.AppendTaskInteraction(taskID, interaction); appendErr != nil {
- s.logger.Error("failed to append interaction", "taskID", taskID, "error", appendErr)
- }
- }
- }
-
- // Clear the question and transition to QUEUED.
- if err := s.questionStore.UpdateTaskQuestion(taskID, ""); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to clear question"})
+ if err := s.answerTaskQuestion(s.ctx, taskID, input.Answer); err != nil {
+ writeOpError(w, err)
return
}
- if err := s.questionStore.UpdateTaskState(taskID, task.StateQueued); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to queue task"})
- return
- }
-
- // Submit a resume execution. Carry the sandbox path so the runner uses
- // the same working directory where Claude stored its session files.
- resumeExec := &storage.Execution{
- ID: uuid.New().String(),
- TaskID: taskID,
- ResumeSessionID: latest.SessionID,
- ResumeAnswer: input.Answer,
- SandboxDir: latest.SandboxDir,
- }
- if err := s.pool.SubmitResume(s.ctx, tk, resumeExec); err != nil {
- writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
- return
- }
-
writeJSON(w, http.StatusOK, map[string]string{"message": "task queued for resume", "task_id": taskID})
}
@@ -702,40 +642,15 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAcceptTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
- t, err := s.store.GetTask(id)
- if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- if !task.ValidTransition(t.State, task.StateCompleted) {
- writeJSON(w, http.StatusConflict, map[string]string{
- "error": fmt.Sprintf("task cannot be accepted from state %s", t.State),
- })
- return
- }
- if err := s.store.UpdateTaskState(id, task.StateCompleted); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ if err := s.acceptTask(r.Context(), id, event.ActorUser); err != nil {
+ writeOpError(w, err)
return
}
- if t.StoryID != "" {
- go s.pool.CheckStoryCompletion(r.Context(), t.StoryID)
- }
writeJSON(w, http.StatusOK, map[string]string{"message": "task accepted", "task_id": id})
}
func (s *Server) handleRejectTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
- t, err := s.store.GetTask(id)
- if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- if !task.ValidTransition(t.State, task.StatePending) {
- writeJSON(w, http.StatusConflict, map[string]string{
- "error": fmt.Sprintf("task cannot be rejected from state %s", t.State),
- })
- return
- }
var input struct {
Comment string `json:"comment"`
}
@@ -743,8 +658,8 @@ func (s *Server) handleRejectTask(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
- if err := s.store.RejectTask(id, input.Comment); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ if err := s.rejectTask(id, input.Comment); err != nil {
+ writeOpError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "task rejected", "task_id": id})
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 2530d55..43d91b0 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -98,7 +98,7 @@ func testServerWithRunner(t *testing.T, runner executor.Runner) (*Server, *stora
"gemini": runner,
}
pool := executor.NewPool(2, runners, store, logger)
- srv := NewServer(store, pool, logger, "claude", "gemini")
+ srv := NewServer(store, pool, nil, logger, "claude", "gemini")
return srv, store
}
@@ -116,7 +116,7 @@ func (m *mockRunner) ExecLogDir(execID string) string {
return filepath.Join(m.logDir, execID)
}
-func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
+func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, _ executor.AgentChannel) error {
if e.ID == "" {
e.ID = uuid.New().String()
}
@@ -194,7 +194,7 @@ func testServerWithGeminiMockRunner(t *testing.T) (*Server, *storage.DB) {
"gemini": mr,
}
pool := executor.NewPool(2, runners, store, logger)
- srv := NewServer(store, pool, logger, "claude", "gemini")
+ srv := NewServer(store, pool, nil, logger, "claude", "gemini")
return srv, store
}
@@ -1228,34 +1228,6 @@ func TestServer_AnswerQuestion_UpdateStateFails_Returns500(t *testing.T) {
}
}
-func TestRateLimit_ElaborateRejectsExcess(t *testing.T) {
- srv, _ := testServer(t)
- // Use burst-1 and rate-0 so the second request from the same IP is rejected.
- srv.elaborateLimiter = newIPRateLimiter(0, 1)
-
- makeReq := func(remoteAddr string) int {
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(`{"description":"x"}`))
- req.Header.Set("Content-Type", "application/json")
- req.RemoteAddr = remoteAddr
- w := httptest.NewRecorder()
- srv.Handler().ServeHTTP(w, req)
- return w.Code
- }
-
- // First request from IP A: limiter allows it (non-429).
- if code := makeReq("192.0.2.1:1234"); code == http.StatusTooManyRequests {
- t.Errorf("first request should not be rate limited, got 429")
- }
- // Second request from IP A: bucket exhausted, must be 429.
- if code := makeReq("192.0.2.1:1234"); code != http.StatusTooManyRequests {
- t.Errorf("second request from same IP should be 429, got %d", code)
- }
- // First request from IP B: separate bucket, not limited.
- if code := makeReq("192.0.2.2:1234"); code == http.StatusTooManyRequests {
- t.Errorf("first request from different IP should not be rate limited, got 429")
- }
-}
-
func TestListWorkspaces_RequiresAuth(t *testing.T) {
srv, _ := testServer(t)
srv.SetAPIToken("secret-token")
diff --git a/internal/api/taskops.go b/internal/api/taskops.go
new file mode 100644
index 0000000..881babe
--- /dev/null
+++ b/internal/api/taskops.go
@@ -0,0 +1,253 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// Shared task-operation service layer. Both the REST handlers and the
+// chatbot-facing MCP tools call these methods so the two surfaces cannot drift.
+// Errors are typed so callers can map them to the right transport status.
+
+// errTaskNotFound is returned when a task ID does not resolve. REST maps it to
+// 404; MCP surfaces it as a tool error.
+var errTaskNotFound = errors.New("task not found")
+
+// conflictError signals an operation that is invalid for the task's current
+// state (REST 409).
+type conflictError struct{ msg string }
+
+func (e *conflictError) Error() string { return e.msg }
+
+func conflictf(format string, a ...any) error { return &conflictError{msg: fmt.Sprintf(format, a...)} }
+
+// badRequestError signals malformed or missing input (REST 400).
+type badRequestError struct{ msg string }
+
+func (e *badRequestError) Error() string { return e.msg }
+
+func badRequestf(format string, a ...any) error { return &badRequestError{msg: fmt.Sprintf(format, a...)} }
+
+// submitTaskSpec describes a task a chatbot wants created and immediately run.
+type submitTaskSpec struct {
+ Name string
+ Instructions string
+ Project string
+ RepositoryURL string
+ Model string
+ MaxBudgetUSD float64
+ Timeout time.Duration
+ Tags []string
+}
+
+// submitTask creates a task and queues it for execution in one step. The
+// repository URL is resolved from the named project when not given explicitly,
+// because task.Validate requires it before the executor's lazy resolution runs.
+func (s *Server) submitTask(ctx context.Context, spec submitTaskSpec) (*task.Task, error) {
+ if strings.TrimSpace(spec.Instructions) == "" {
+ return nil, badRequestf("instructions are required")
+ }
+
+ repoURL := spec.RepositoryURL
+ if repoURL == "" && spec.Project != "" {
+ if proj, err := s.store.GetProject(spec.Project); err == nil {
+ repoURL = proj.RemoteURL
+ }
+ }
+ if repoURL == "" {
+ return nil, badRequestf("repository_url is required (pass it directly or a project that resolves to one)")
+ }
+
+ name := strings.TrimSpace(spec.Name)
+ if name == "" {
+ name = firstLine(spec.Instructions, 72)
+ }
+
+ now := time.Now().UTC()
+ t := &task.Task{
+ ID: uuid.New().String(),
+ Name: name,
+ Project: spec.Project,
+ RepositoryURL: repoURL,
+ Agent: task.AgentConfig{
+ Type: "claude",
+ Model: spec.Model,
+ Instructions: spec.Instructions,
+ MaxBudgetUSD: spec.MaxBudgetUSD,
+ },
+ Priority: task.PriorityNormal,
+ Tags: spec.Tags,
+ DependsOn: []string{},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if spec.Timeout > 0 {
+ t.Timeout.Duration = spec.Timeout
+ }
+ if t.Tags == nil {
+ t.Tags = []string{}
+ }
+
+ if err := task.Validate(t); err != nil {
+ return nil, badRequestf("%s", err.Error())
+ }
+ if err := s.store.CreateTask(t); err != nil {
+ return nil, err
+ }
+ if err := s.store.UpdateTaskStateBy(t.ID, task.StateQueued, event.ActorChatbot); err != nil {
+ return nil, err
+ }
+ t.State = task.StateQueued
+ if err := s.pool.Submit(ctx, t); err != nil {
+ return nil, err
+ }
+ return t, nil
+}
+
+// acceptTask transitions a READY task to COMPLETED.
+func (s *Server) acceptTask(ctx context.Context, id string, actor event.Actor) error {
+ t, err := s.store.GetTask(id)
+ if err != nil {
+ return errTaskNotFound
+ }
+ if !task.ValidTransition(t.State, task.StateCompleted) {
+ return conflictf("task cannot be accepted from state %s", t.State)
+ }
+ if err := s.store.UpdateTaskStateBy(id, task.StateCompleted, actor); err != nil {
+ return err
+ }
+ if t.StoryID != "" {
+ go s.pool.CheckStoryCompletion(ctx, t.StoryID)
+ }
+ return nil
+}
+
+// rejectTask transitions a READY task back to PENDING with an optional comment.
+func (s *Server) rejectTask(id, comment string) error {
+ t, err := s.store.GetTask(id)
+ if err != nil {
+ return errTaskNotFound
+ }
+ if !task.ValidTransition(t.State, task.StatePending) {
+ return conflictf("task cannot be rejected from state %s", t.State)
+ }
+ return s.store.RejectTask(id, comment)
+}
+
+// cancelTask cancels a running/queued task, returning a human-readable result.
+func (s *Server) cancelTask(id string, actor event.Actor) (string, error) {
+ tk, err := s.store.GetTask(id)
+ if err != nil {
+ return "", errTaskNotFound
+ }
+ if s.pool.Cancel(id) {
+ return "task cancellation requested", nil
+ }
+ if !task.ValidTransition(tk.State, task.StateCancelled) {
+ return "", conflictf("task cannot be cancelled from state %s", tk.State)
+ }
+ if err := s.store.UpdateTaskStateBy(id, task.StateCancelled, actor); err != nil {
+ return "", err
+ }
+ return "task cancelled", nil
+}
+
+// answerTaskQuestion answers a BLOCKED task's clarification and resumes it.
+func (s *Server) answerTaskQuestion(ctx context.Context, id, answer string) error {
+ tk, err := s.questionStore.GetTask(id)
+ if err != nil {
+ return errTaskNotFound
+ }
+ if tk.State != task.StateBlocked {
+ return conflictf("task is not blocked")
+ }
+ if strings.TrimSpace(answer) == "" {
+ return badRequestf("answer is required")
+ }
+
+ latest, err := s.questionStore.GetLatestExecution(id)
+ if err != nil || latest.SessionID == "" {
+ return fmt.Errorf("no resumable session found")
+ }
+
+ if tk.QuestionJSON != "" {
+ var qData struct {
+ Text string `json:"text"`
+ Options []string `json:"options"`
+ }
+ if json.Unmarshal([]byte(tk.QuestionJSON), &qData) == nil {
+ if appendErr := s.questionStore.AppendTaskInteraction(id, task.Interaction{
+ QuestionText: qData.Text,
+ Options: qData.Options,
+ Answer: answer,
+ AskedAt: tk.UpdatedAt,
+ }); appendErr != nil {
+ s.logger.Error("failed to append interaction", "taskID", id, "error", appendErr)
+ }
+ }
+ }
+
+ if err := s.questionStore.UpdateTaskQuestion(id, ""); err != nil {
+ return err
+ }
+ if err := s.questionStore.UpdateTaskState(id, task.StateQueued); err != nil {
+ return err
+ }
+
+ resumeExec := &storage.Execution{
+ ID: uuid.New().String(),
+ TaskID: id,
+ ResumeSessionID: latest.SessionID,
+ ResumeAnswer: answer,
+ SandboxDir: latest.SandboxDir,
+ }
+ return s.pool.SubmitResume(ctx, tk, resumeExec)
+}
+
+// writeOpError maps a service-layer error to the appropriate REST status.
+func writeOpError(w http.ResponseWriter, err error) {
+ switch {
+ case errors.Is(err, errTaskNotFound):
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
+ return
+ }
+ var ce *conflictError
+ if errors.As(err, &ce) {
+ writeJSON(w, http.StatusConflict, map[string]string{"error": ce.Error()})
+ return
+ }
+ var be *badRequestError
+ if errors.As(err, &be) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": be.Error()})
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+}
+
+// firstLine returns the first non-empty line of s, truncated to max runes.
+func firstLine(s string, max int) string {
+ line := s
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ line = s[:i]
+ }
+ line = strings.TrimSpace(line)
+ if r := []rune(line); len(r) > max {
+ return strings.TrimSpace(string(r[:max]))
+ }
+ if line == "" {
+ return "task"
+ }
+ return line
+}
diff --git a/internal/api/validate_test.go b/internal/api/validate_test.go
index c3d7b1f..60fec14 100644
--- a/internal/api/validate_test.go
+++ b/internal/api/validate_test.go
@@ -3,11 +3,32 @@ package api
import (
"bytes"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"testing"
)
+// createFakeClaude writes a stub `claude` script that prints the given output
+// and exits with the given code, returning its path for use as a binary
+// override in 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 TestValidateTask_Success(t *testing.T) {
srv, _ := testServer(t)