summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-25 04:57:37 +0000
committerClaude <noreply@anthropic.com>2026-05-25 04:57:37 +0000
commite766b4c3ef13181ec6adf90ec4abe9db0129fab1 (patch)
tree760b26fdd0342d1d41371d25a0950e83610ced13 /internal/api
parent1ea57a7dbf4d34045a361e9bba9191de06cd1749 (diff)
feat(api): chatbot-facing MCP server for task orchestration (Phase 3)
Adds a chatbot-facing MCP server mounted at /chatbot/mcp, guarded by the shared API bearer token (new config api_token, wired through SetAPIToken). Tools: submit_task, list_tasks, get_task, get_events, answer_question, accept_task, reject_task, cancel_task. submit_task creates and immediately queues a task, resolving the repository URL from a named project when not given explicitly. To keep the REST API and the MCP surface from drifting, the accept/reject/ cancel/answer operations are extracted into a shared service layer (taskops.go) with typed errors mapped to REST status codes; the existing HTTP handlers now delegate to it. The endpoint is only served when a token is configured and presented as a bearer. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/chatbotmcp.go218
-rw-r--r--internal/api/chatbotmcp_test.go233
-rw-r--r--internal/api/server.go125
-rw-r--r--internal/api/taskops.go253
4 files changed, 719 insertions, 110 deletions
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/server.go b/internal/api/server.go
index 1522b72..c9fadb9 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -191,6 +191,12 @@ func (s *Server) routes() {
s.mux.Handle("GET /mcp", mcpHandler)
s.mux.Handle("DELETE /mcp", mcpHandler)
}
+ // Chatbot-facing MCP server. Always mounted; the handler refuses to serve
+ // unless a shared API token is configured and presented as a bearer.
+ 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.Handle("GET /", http.FileServerFS(webui.Files))
}
@@ -282,41 +288,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.UpdateTaskStateBy(taskID, task.StateCancelled, event.ActorUser); 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"`
}
@@ -324,61 +305,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"})
- return
- }
- if err := s.questionStore.UpdateTaskState(taskID, task.StateQueued); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to queue task"})
+ if err := s.answerTaskQuestion(s.ctx, taskID, input.Answer); err != nil {
+ writeOpError(w, err)
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})
}
@@ -704,40 +634,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.UpdateTaskStateBy(id, task.StateCompleted, event.ActorUser); 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"`
}
@@ -745,8 +650,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/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
+}