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>
395 lines
12 KiB
Go
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
|
|
}
|