From 952b7623ee9dceec15099043086622aa2aab4741 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 09:45:16 +0000 Subject: feat(executor,api): wire agent MCP into ContainerRunner + mount /mcp (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContainerRunner now mints a per-task MCP token (from an injected Registry), writes a claude mcp-config into the workspace pointing at the host agent MCP server over host.docker.internal with that bearer, and adds --mcp-config to the in-container claude invocation. The token is revoked when the run ends. After the run, a buffered ask_user (PendingQuestion on the channel) is converted into a BlockedError — the MCP path to BLOCKED — with the file-based question.json kept as a fallback for in-flight tasks started on the old wire. The API server mounts the StreamableHTTP MCP handler at POST/GET/DELETE /mcp when a registry is provided; serve.go constructs one Registry shared by the runners (mint) and the server (resolve). Minting is skipped for gemini agents. Tests: writeMCPConfig output shape, buildInnerCmd flag presence/absence, and an api-level end-to-end MCP tool call through NewServer/Handler proving the route is mounted (and absent without a registry). https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/cli/serve.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'internal/cli/serve.go') diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 459c35b..b23304b 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -79,6 +79,9 @@ func serve(addr string) error { claudeConfigDir := cfg.ClaudeConfigDir repoDir, _ := os.Getwd() + // Shared per-task agent MCP token registry: runners mint tokens; the API + // server mounts /mcp and resolves them. + agentRegistry := executor.NewRegistry() runners := map[string]executor.Runner{ // ContainerRunner: binaries are resolved via PATH inside the container image, // so ClaudeBinary/GeminiBinary are left empty (host paths would not exist inside). @@ -92,6 +95,7 @@ func serve(addr string) error { ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, + Registry: agentRegistry, }, "gemini": &executor.ContainerRunner{ Image: cfg.GeminiImage, @@ -103,6 +107,7 @@ func serve(addr string) error { ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, + Registry: agentRegistry, }, "container": &executor.ContainerRunner{ Image: "claudomator-agent:latest", @@ -114,6 +119,7 @@ func serve(addr string) error { ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, + Registry: agentRegistry, }, } @@ -146,7 +152,7 @@ func serve(addr string) error { pool.RecoverStaleQueued(context.Background()) pool.RecoverStaleBlocked() - srv := api.NewServer(store, pool, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath) + srv := api.NewServer(store, pool, agentRegistry, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath) // Configure notifiers: combine webhook (if set) with web push. notifiers := []notify.Notifier{} -- cgit v1.2.3 From e766b4c3ef13181ec6adf90ec4abe9db0129fab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 04:57:37 +0000 Subject: 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 --- internal/api/chatbotmcp.go | 218 ++++++++++++++++++++++++++++++++++ internal/api/chatbotmcp_test.go | 233 ++++++++++++++++++++++++++++++++++++ internal/api/server.go | 125 +++----------------- internal/api/taskops.go | 253 ++++++++++++++++++++++++++++++++++++++++ internal/cli/serve.go | 5 + internal/config/config.go | 1 + 6 files changed, 725 insertions(+), 110 deletions(-) create mode 100644 internal/api/chatbotmcp.go create mode 100644 internal/api/chatbotmcp_test.go create mode 100644 internal/api/taskops.go (limited to 'internal/cli/serve.go') 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 +} diff --git a/internal/cli/serve.go b/internal/cli/serve.go index b23304b..e545763 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -182,6 +182,11 @@ func serve(addr string) error { } srv.SetGitHubWebhookConfig(cfg.WebhookSecret, cfg.Projects) + if cfg.APIToken != "" { + srv.SetAPIToken(cfg.APIToken) + logger.Info("API token configured; chatbot MCP endpoint enabled at /chatbot/mcp") + } + // Register scripts. wd, _ := os.Getwd() srv.SetScripts(api.ScriptRegistry{ diff --git a/internal/config/config.go b/internal/config/config.go index 25187cf..94f3ec7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,6 +62,7 @@ type Config struct { WebhookURL string `toml:"webhook_url"` WorkspaceRoot string `toml:"workspace_root"` WebhookSecret string `toml:"webhook_secret"` + APIToken string `toml:"api_token"` // shared bearer for web UI + chatbot MCP; empty disables both auth and the chatbot MCP endpoint Projects []Project `toml:"projects"` VAPIDPublicKey string `toml:"vapid_public_key"` VAPIDPrivateKey string `toml:"vapid_private_key"` -- cgit v1.2.3 From 32715355fe2eed321df4f7083dfe580d35f8a62a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 20:31:24 +0000 Subject: feat(executor): budget-gate the dispatcher — reroute to local or block (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool now consults a BudgetGate before taking an agent slot. If running on the chosen paid provider could breach its rolling window (estimated by the task's max_budget_usd), it reroutes to the free local runner when one is registered; otherwise it stops before running and marks the task BUDGET_EXCEEDED. serve.go builds a budget.Accountant from [budget] config and wires it into the pool (only when limits are configured). Allows the QUEUED → BUDGET_EXCEEDED transition, since the gate blocks a queued task before it ever reaches RUNNING. Covered by pool tests for both the block and reroute paths against a fake gate. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/cli/serve.go | 18 ++++++++++++ internal/executor/executor.go | 46 ++++++++++++++++++++++++++++++ internal/executor/executor_test.go | 57 ++++++++++++++++++++++++++++++++++++++ internal/task/task.go | 2 +- 4 files changed, 122 insertions(+), 1 deletion(-) (limited to 'internal/cli/serve.go') diff --git a/internal/cli/serve.go b/internal/cli/serve.go index e545763..55fdaf5 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -11,6 +11,7 @@ import ( "time" "github.com/thepeterstone/claudomator/internal/api" + "github.com/thepeterstone/claudomator/internal/budget" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/notify" "github.com/thepeterstone/claudomator/internal/storage" @@ -144,6 +145,23 @@ func serve(addr string) error { pool.LLM = localClient } + // Budget accountant: gate paid providers against rolling per-provider caps. + // With no limits configured this is created but allows everything. + var accountant *budget.Accountant + if len(cfg.Budget.Provider5hUSD) > 0 { + window := budget.DefaultWindow + if cfg.Budget.Window != "" { + if d, derr := time.ParseDuration(cfg.Budget.Window); derr == nil { + window = d + } else { + logger.Warn("invalid budget.window; using default", "value", cfg.Budget.Window, "default", window) + } + } + accountant = budget.New(store, budget.Limits{Window: window, PerProvider: cfg.Budget.Provider5hUSD}) + pool.Budget = accountant + logger.Info("budget gating enabled", "window", window, "limits", cfg.Budget.Provider5hUSD) + } + if err := store.SeedProjects(); err != nil { logger.Error("failed to seed projects", "error", err) } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 4333f51..8614481 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -93,6 +93,14 @@ type Pool struct { dispatchDone chan struct{} // closed when the dispatch goroutine exits Classifier *Classifier LLM *llm.Client + // Budget gates paid providers against a rolling window; nil disables gating. + Budget BudgetGate +} + +// BudgetGate reports whether a task estimated at estCost may run on a provider +// without breaching its rolling spend window. Satisfied by *budget.Accountant. +type BudgetGate interface { + Allow(provider string, estCost float64) (bool, error) } // Result is emitted when a task execution completes. @@ -937,6 +945,44 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { agentType = "claude" } + // Budget gating: if running on this provider could breach its rolling window + // (estimated by the task's max_budget_usd), reroute to the free local runner + // when one is registered, otherwise stop and mark the task BUDGET_EXCEEDED. + if p.Budget != nil { + if ok, berr := p.Budget.Allow(agentType, t.Agent.MaxBudgetUSD); berr != nil { + p.logger.Warn("budget check failed; proceeding", "error", berr, "taskID", t.ID) + } else if !ok { + if _, hasLocal := p.runners["local"]; hasLocal && agentType != "local" { + p.logger.Info("budget window would be breached; rerouting to local", "taskID", t.ID, "from", agentType) + t.Agent.Type = "local" + t.Agent.Model = "" + agentType = "local" + if uerr := p.store.UpdateTaskAgent(t.ID, t.Agent); uerr != nil { + p.logger.Error("failed to persist rerouted agent", "error", uerr, "taskID", t.ID) + } + } else { + now := time.Now().UTC() + exec := &storage.Execution{ + ID: uuid.New().String(), + TaskID: t.ID, + StartTime: now, + EndTime: now, + Status: "BUDGET_EXCEEDED", + Agent: agentType, + ErrorMsg: fmt.Sprintf("rolling budget window cap reached for provider %q", agentType), + } + if createErr := p.store.CreateExecution(exec); createErr != nil { + p.logger.Error("failed to create execution record", "error", createErr) + } + if err := p.store.UpdateTaskState(t.ID, task.StateBudgetExceeded); err != nil { + p.logger.Error("failed to update task state", "taskID", t.ID, "state", task.StateBudgetExceeded, "error", err) + } + p.resultCh <- &Result{TaskID: t.ID, Execution: exec, Err: fmt.Errorf("budget cap reached for %s", agentType)} + return + } + } + } + // Check dependencies before taking the per-agent slot to avoid deadlock: // if a dependent task holds the slot while waiting for its dependency to run, // the dependency can never start (maxPerAgent=1). diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index d98efbf..b737e22 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -217,6 +217,63 @@ func TestPool_Submit_Subtask_GoesToCompleted(t *testing.T) { } } +type fakeGate struct{ deny map[string]bool } + +func (g fakeGate) Allow(provider string, _ float64) (bool, error) { return !g.deny[provider], nil } + +func TestPool_BudgetGate_BlocksWhenNoLocalFallback(t *testing.T) { + store := testStore(t) + runner := &mockRunner{} + pool := NewPool(2, map[string]Runner{"claude": runner}, store, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + pool.Budget = fakeGate{deny: map[string]bool{"claude": true}} + + tk := makeTask("bg-1") + store.CreateTask(tk) + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("submit: %v", err) + } + + result := <-pool.Results() + if result.Execution.Status != "BUDGET_EXCEEDED" { + t.Errorf("status: want BUDGET_EXCEEDED, got %q", result.Execution.Status) + } + if runner.callCount() != 0 { + t.Errorf("claude runner should not have run, got %d calls", runner.callCount()) + } + got, _ := store.GetTask("bg-1") + if got.State != task.StateBudgetExceeded { + t.Errorf("task state: want BUDGET_EXCEEDED, got %v", got.State) + } +} + +func TestPool_BudgetGate_ReroutesToLocal(t *testing.T) { + store := testStore(t) + claudeRunner := &mockRunner{} + localRunner := &mockRunner{} + pool := NewPool(2, map[string]Runner{"claude": claudeRunner, "local": localRunner}, store, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + pool.Budget = fakeGate{deny: map[string]bool{"claude": true}} // local is allowed + + tk := makeTask("bg-2") + store.CreateTask(tk) + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("submit: %v", err) + } + + result := <-pool.Results() + if result.Err != nil { + t.Errorf("expected success after reroute, got %v", result.Err) + } + if localRunner.callCount() != 1 { + t.Errorf("local runner should have run once, got %d", localRunner.callCount()) + } + if claudeRunner.callCount() != 0 { + t.Errorf("claude runner should not have run, got %d", claudeRunner.callCount()) + } + if result.Execution.Agent != "local" { + t.Errorf("execution agent: want local, got %q", result.Execution.Agent) + } +} + func TestPool_Submit_Failure(t *testing.T) { store := testStore(t) runner := &mockRunner{err: fmt.Errorf("boom"), exitCode: 1} diff --git a/internal/task/task.go b/internal/task/task.go index 935a238..eeac49e 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -138,7 +138,7 @@ type BatchFile struct { // BLOCKED may advance to READY when all subtasks complete, or back to QUEUED on user answer. var validTransitions = map[State]map[State]bool{ StatePending: {StateQueued: true, StateCancelled: true}, - StateQueued: {StateRunning: true, StateCancelled: true, StateReady: true}, // READY: parent task completed via subtask delegation + StateQueued: {StateRunning: true, StateCancelled: true, StateReady: true, StateBudgetExceeded: true}, // READY: subtask delegation; BUDGET_EXCEEDED: dispatcher budget gate blocks before running StateRunning: {StateReady: true, StateCompleted: true, StateFailed: true, StateTimedOut: true, StateCancelled: true, StateBudgetExceeded: true, StateBlocked: true}, StateReady: {StateCompleted: true, StatePending: true}, StateFailed: {StateQueued: true}, // retry -- cgit v1.2.3 From ab4b364954af08fa602388495ca425eaef0abf74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 20:34:09 +0000 Subject: feat(api,web): budget headroom endpoint + UI chips (Phase 6) Adds GET /api/budget returning per-provider rolling-window headroom (empty when budget gating is unconfigured), wired from serve.go via SetBudget. The web UI polls it and renders a chip per limited provider in the header, flagging any under 20% remaining. New pure JS helpers formatBudgetHeadroom/ renderBudgetHeadroom are unit-tested; the endpoint is covered by Go handler tests (empty/reports/500). UI render not browser-verified in this environment. Completes Phase 6: spend accounting + dispatcher gating + observability surface. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/api/budget.go | 36 ++++++++++++++++++++++++ internal/api/budget_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++ internal/api/server.go | 2 ++ internal/cli/serve.go | 3 ++ web/app.js | 48 ++++++++++++++++++++++++++++++++ web/index.html | 1 + web/style.css | 21 ++++++++++++++ web/test/budget.test.mjs | 58 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 237 insertions(+) create mode 100644 internal/api/budget.go create mode 100644 internal/api/budget_test.go create mode 100644 web/test/budget.test.mjs (limited to 'internal/cli/serve.go') 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/server.go b/internal/api/server.go index a4b7ea1..ffa8cd4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -61,6 +61,7 @@ type Server struct { pushStore pushSubscriptionStore dropsDir string llm *llm.Client + budget budgetReporter // optional; per-provider spend headroom for GET /api/budget } // SetAPIToken configures a bearer token that must be supplied to access the API. @@ -147,6 +148,7 @@ func (s *Server) routes() { 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) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 55fdaf5..7afa678 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -171,6 +171,9 @@ func serve(addr string) error { pool.RecoverStaleBlocked() srv := api.NewServer(store, pool, agentRegistry, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath) + if accountant != nil { + srv.SetBudget(accountant) + } // Configure notifiers: combine webhook (if set) with web push. notifiers := []notify.Notifier{} diff --git a/web/app.js b/web/app.js index 8a2662f..7f96e09 100644 --- a/web/app.js +++ b/web/app.js @@ -198,6 +198,53 @@ export async function fetchTaskEvents(taskId, fetchImpl = (typeof fetch !== 'und } } +// ── Budget headroom ───────────────────────────────────────────────────────── +// Renders the per-provider rolling-window spend headroom from GET /api/budget. + +// formatBudgetHeadroom turns one provider's headroom into a short label. +// Returns '' for unlimited providers (nothing to show). +export function formatBudgetHeadroom(h) { + if (!h || !h.limited) return ''; + const pct = Math.round((h.fraction_remaining || 0) * 100); + const remaining = (h.remaining_usd || 0).toFixed(2); + const limit = (h.limit_usd || 0).toFixed(2); + const name = h.provider ? h.provider.charAt(0).toUpperCase() + h.provider.slice(1) : '?'; + return `${name}: ${pct}% left ($${remaining} of $${limit})`; +} + +// renderBudgetHeadroom builds a chip per limited provider, flagging +// providers under 20% remaining with a --low modifier. Returns the container. +export function renderBudgetHeadroom(headrooms, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const wrap = doc.createElement('div'); + wrap.className = 'budget-bar'; + for (const h of headrooms || []) { + if (!h || !h.limited) continue; + const chip = doc.createElement('span'); + chip.className = 'budget-chip' + ((h.fraction_remaining || 0) < 0.2 ? ' budget-chip--low' : ''); + chip.textContent = formatBudgetHeadroom(h); + wrap.appendChild(chip); + } + return wrap; +} + +// loadBudget fetches headroom and injects chips into #budget-bar. Best-effort. +async function loadBudget(fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) { + if (!fetchImpl || typeof document === 'undefined') return; + const slot = document.getElementById('budget-bar'); + if (!slot) return; + try { + const resp = await fetchImpl(`${BASE_PATH}/api/budget`); + if (!resp.ok) return; + const headrooms = await resp.json(); + const rendered = renderBudgetHeadroom(headrooms); + slot.innerHTML = ''; + if (rendered) for (const c of rendered.children) slot.appendChild(c); + } catch { + // budget display is non-critical; ignore. + } +} + function truncateToWordBoundary(text, maxLen = 120) { if (!text || text.length <= maxLen) return text; const cut = text.lastIndexOf(' ', maxLen); @@ -1297,6 +1344,7 @@ function renderActiveTab(allTasks) { async function poll() { try { + loadBudget(); // fire-and-forget; budget can change independently of tasks const health = await fetchHealth(); const serverUpdate = health.last_updated; diff --git a/web/index.html b/web/index.html index 8a705cc..5aa7b44 100644 --- a/web/index.html +++ b/web/index.html @@ -37,6 +37,7 @@ +