Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
The HTTP claudebox client's ExecuteStream method used a bare bufio.NewScanner with the default 64KB max token size. When Claude Code produces tool results > 64KB (e.g., reading large files), the SSE event exceeds the scanner limit and fails with "token too long". Every other scanner in the codebase (claudecode adapter, claudebox executor, kubernetes executor) already uses scanner.Buffer(buf, 1MB). This was the only one missed. Fixes: "agent execution failed: read stream: bufio.Scanner: token too long" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
465 lines
14 KiB
Go
465 lines
14 KiB
Go
// Package claudebox provides an HTTP client for the claudebox sidecar.
|
|
// This client is used by standalone workers to communicate with the local
|
|
// claudebox sidecar instead of using kubectl exec.
|
|
package claudebox
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client is an HTTP client for the claudebox sidecar.
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// ClientConfig holds configuration for the claudebox client.
|
|
type ClientConfig struct {
|
|
// BaseURL is the base URL of the claudebox sidecar (e.g., "http://localhost:8080").
|
|
BaseURL string
|
|
|
|
// Timeout is the default request timeout.
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// NewClient creates a new claudebox client.
|
|
func NewClient(cfg ClientConfig) *Client {
|
|
if cfg.Timeout == 0 {
|
|
cfg.Timeout = 10 * time.Minute
|
|
}
|
|
return &Client{
|
|
baseURL: strings.TrimSuffix(cfg.BaseURL, "/"),
|
|
httpClient: &http.Client{
|
|
Timeout: cfg.Timeout,
|
|
},
|
|
}
|
|
}
|
|
|
|
// HealthResponse is the health check response.
|
|
type HealthResponse struct {
|
|
Status string `json:"status"`
|
|
Timestamp string `json:"timestamp"`
|
|
WorkDir string `json:"work_dir"`
|
|
}
|
|
|
|
// Health checks if the claudebox sidecar is healthy.
|
|
func (c *Client) Health(ctx context.Context) (*HealthResponse, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("health check: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("health check returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var result HealthResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// ExecuteRequest is the request to execute Claude Code.
|
|
type ExecuteRequest struct {
|
|
Prompt string `json:"prompt"`
|
|
AllowedTools []string `json:"allowed_tools,omitempty"`
|
|
WorkingDir string `json:"working_dir,omitempty"`
|
|
Timeout int `json:"timeout_seconds,omitempty"` // seconds
|
|
Metadata map[string]string `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// ExecuteResponse is the response from executing Claude Code.
|
|
type ExecuteResponse struct {
|
|
Success bool `json:"success"`
|
|
Output string `json:"output"`
|
|
ExitCode int `json:"exit_code"`
|
|
DurationMs int64 `json:"duration_ms"`
|
|
Error string `json:"error,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
FinalOutput string `json:"final_output,omitempty"`
|
|
Artifacts map[string]string `json:"artifacts,omitempty"`
|
|
}
|
|
|
|
// Execute runs Claude Code and returns the complete result.
|
|
func (c *Client) Execute(ctx context.Context, req *ExecuteRequest) (*ExecuteResponse, error) {
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/execute", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("execute: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("execute returned status %d (failed to read body: %w)", resp.StatusCode, readErr)
|
|
}
|
|
return nil, fmt.Errorf("execute returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var result ExecuteResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// StreamEvent is an SSE event from streaming execution.
|
|
type StreamEvent struct {
|
|
Type string `json:"type"`
|
|
Content string `json:"content,omitempty"`
|
|
Stream string `json:"stream,omitempty"`
|
|
ToolName string `json:"tool_name,omitempty"`
|
|
Data map[string]any `json:"data,omitempty"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
|
|
// StreamEventHandler is called for each event during streaming execution.
|
|
type StreamEventHandler func(StreamEvent)
|
|
|
|
// ExecuteStream runs Claude Code and streams events to the handler.
|
|
func (c *Client) ExecuteStream(ctx context.Context, req *ExecuteRequest, handler StreamEventHandler) error {
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/execute/stream", bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Accept", "text/event-stream")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return fmt.Errorf("execute stream: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return fmt.Errorf("execute stream returned status %d (failed to read body: %w)", resp.StatusCode, readErr)
|
|
}
|
|
return fmt.Errorf("execute stream returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
// Parse SSE events
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
buf := make([]byte, 0, 64*1024)
|
|
scanner.Buffer(buf, 1024*1024) // 1MB max to match other streaming paths
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
continue
|
|
}
|
|
|
|
data := strings.TrimPrefix(line, "data: ")
|
|
if data == "" {
|
|
continue
|
|
}
|
|
|
|
var event StreamEvent
|
|
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
|
continue // Skip malformed events
|
|
}
|
|
|
|
handler(event)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return fmt.Errorf("read stream: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GitCloneRequest is the request to clone a repository.
|
|
type GitCloneRequest struct {
|
|
CloneURL string `json:"clone_url"`
|
|
WorkDir string `json:"work_dir,omitempty"`
|
|
}
|
|
|
|
// GitCloneResponse is the response from cloning.
|
|
type GitCloneResponse struct {
|
|
Success bool `json:"success"`
|
|
Cloned bool `json:"cloned"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// GitClone clones or updates a git repository.
|
|
func (c *Client) GitClone(ctx context.Context, cloneURL, workDir string) (*GitCloneResponse, error) {
|
|
req := GitCloneRequest{
|
|
CloneURL: cloneURL,
|
|
WorkDir: workDir,
|
|
}
|
|
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/git/clone", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("git clone: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("git clone returned status %d (failed to read body: %w)", resp.StatusCode, readErr)
|
|
}
|
|
return nil, fmt.Errorf("git clone returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var result GitCloneResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// GitCommitAndPushRequest is the request to commit and push changes.
|
|
type GitCommitAndPushRequest struct {
|
|
Message string `json:"message"`
|
|
Push bool `json:"push"`
|
|
WorkDir string `json:"work_dir,omitempty"`
|
|
}
|
|
|
|
// GitCommitAndPushResponse is the response from commit and push.
|
|
type GitCommitAndPushResponse struct {
|
|
Success bool `json:"success"`
|
|
HasChanges bool `json:"has_changes"`
|
|
CommitSHA string `json:"commit_sha,omitempty"`
|
|
FilesChanged []string `json:"files_changed,omitempty"`
|
|
Pushed bool `json:"pushed"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// GitCommitAndPush commits and optionally pushes changes.
|
|
func (c *Client) GitCommitAndPush(ctx context.Context, message string, push bool, workDir string) (*GitCommitAndPushResponse, error) {
|
|
req := GitCommitAndPushRequest{
|
|
Message: message,
|
|
Push: push,
|
|
WorkDir: workDir,
|
|
}
|
|
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/git/commit-and-push", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("git commit: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("git commit returned status %d (failed to read body: %w)", resp.StatusCode, readErr)
|
|
}
|
|
return nil, fmt.Errorf("git commit returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var result GitCommitAndPushResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// GitResetToMainRequest is the request to reset the workspace to main.
|
|
type GitResetToMainRequest struct {
|
|
WorkDir string `json:"work_dir,omitempty"`
|
|
}
|
|
|
|
// GitResetToMainResponse is the response from resetting to main.
|
|
type GitResetToMainResponse struct {
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// GitResetToMain resets the workspace to the main branch with a clean state.
|
|
func (c *Client) GitResetToMain(ctx context.Context, workDir string) (*GitResetToMainResponse, error) {
|
|
req := GitResetToMainRequest{
|
|
WorkDir: workDir,
|
|
}
|
|
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/git/reset-to-main", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("git reset to main: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("git reset returned status %d (failed to read body: %w)", resp.StatusCode, readErr)
|
|
}
|
|
return nil, fmt.Errorf("git reset returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var result GitResetToMainResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// GitStatusResponse is the response from git status.
|
|
type GitStatusResponse struct {
|
|
IsRepo bool `json:"is_repo"`
|
|
HasChanges bool `json:"has_changes"`
|
|
ChangedFiles []string `json:"changed_files,omitempty"`
|
|
Branch string `json:"branch,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// GitStatus returns the git status of the workspace.
|
|
func (c *Client) GitStatus(ctx context.Context, workDir string) (*GitStatusResponse, error) {
|
|
reqURL := c.baseURL + "/git/status"
|
|
if workDir != "" {
|
|
reqURL += "?work_dir=" + url.QueryEscape(workDir)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("git status: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("git status returned status %d (failed to read body: %w)", resp.StatusCode, readErr)
|
|
}
|
|
return nil, fmt.Errorf("git status returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var result GitStatusResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// SDLCRequest is the request to run an SDLC command.
|
|
type SDLCRequest struct {
|
|
Command string `json:"command"`
|
|
Args []string `json:"args,omitempty"`
|
|
WorkDir string `json:"work_dir,omitempty"`
|
|
}
|
|
|
|
// SDLCResponse is the response from running an SDLC command.
|
|
type SDLCResponse struct {
|
|
Success bool `json:"success"`
|
|
Output string `json:"output"`
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// RunSDLC executes an SDLC CLI command.
|
|
func (c *Client) RunSDLC(ctx context.Context, command string, args []string, workDir string) (*SDLCResponse, error) {
|
|
req := SDLCRequest{
|
|
Command: command,
|
|
Args: args,
|
|
WorkDir: workDir,
|
|
}
|
|
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sdlc", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sdlc: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("sdlc returned status %d (failed to read body: %w)", resp.StatusCode, readErr)
|
|
}
|
|
return nil, fmt.Errorf("sdlc returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var result SDLCResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|