rdev/internal/adapter/claudebox/client.go
jordan 3b35900a2d feat: enterprise worker pool with HTTP sidecar pattern
Implements horizontally-scalable worker pool architecture:
- claudebox-sidecar: HTTP server for Claude Code, git, and SDLC ops
- rdev-worker: standalone worker binary polling rdev-api for tasks
- HTTP client adapter for sidecar communication
- HPA with custom Prometheus metrics for autoscaling
- ServiceMonitor for metrics scraping

Code review fixes applied:
- URL-encode query parameters in GitStatus (Critical #1)
- Remove unused shellQuote function (Critical #2)
- Use stdlib strings.Split/TrimSpace (Critical #3)
- Add version injection via ldflags (Warning #4)
- Add debug logging for swallowed git/sdlc errors (Warning #5, #6)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:21:11 -07:00

395 lines
12 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, _ := io.ReadAll(resp.Body)
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, _ := io.ReadAll(resp.Body)
return fmt.Errorf("execute stream returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
// Parse SSE events
scanner := bufio.NewScanner(resp.Body)
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, _ := io.ReadAll(resp.Body)
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, _ := io.ReadAll(resp.Body)
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
}
// 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, _ := io.ReadAll(resp.Body)
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, _ := io.ReadAll(resp.Body)
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
}