rdev/internal/worker/http_sdlc_executor.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

185 lines
4.7 KiB
Go

package worker
import (
"context"
"fmt"
"time"
claudeboxclient "github.com/orchard9/rdev/internal/adapter/claudebox"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
)
// HTTPSDLCTaskExecutor handles WorkTaskTypeSDLC tasks using HTTP calls to the
// local claudebox sidecar instead of kubectl exec.
type HTTPSDLCTaskExecutor struct {
client *claudeboxclient.Client
workDir string
}
// HTTPSDLCTaskExecutorConfig holds configuration for the HTTP SDLC executor.
type HTTPSDLCTaskExecutorConfig struct {
// ClaudeboxClient is the HTTP client for the claudebox sidecar.
ClaudeboxClient *claudeboxclient.Client
// WorkDir is the default working directory in the container.
WorkDir string
}
// NewHTTPSDLCTaskExecutor creates a new HTTP-based SDLC executor.
func NewHTTPSDLCTaskExecutor(cfg HTTPSDLCTaskExecutorConfig) *HTTPSDLCTaskExecutor {
if cfg.WorkDir == "" {
cfg.WorkDir = "/workspace"
}
return &HTTPSDLCTaskExecutor{
client: cfg.ClaudeboxClient,
workDir: cfg.WorkDir,
}
}
// Execute runs an SDLC task using the claudebox sidecar HTTP API.
func (e *HTTPSDLCTaskExecutor) Execute(ctx context.Context, task *domain.WorkTask) *domain.BuildResult {
start := time.Now()
log := logging.FromContext(ctx).WithWorker("http-sdlc-executor")
// Parse SDLC spec
spec, err := e.parseSpec(task.Spec)
if err != nil {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("invalid SDLC spec: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
log.Info("executing SDLC task via HTTP",
"task_id", task.ID,
logging.FieldProjectID, task.ProjectID,
"command", spec.Command,
)
// Clone repo to workspace
cloneResp, err := e.client.GitClone(ctx, spec.GitCloneURL, e.workDir)
if err != nil {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("git clone failed: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
if !cloneResp.Success {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("git clone failed: %s", cloneResp.Error),
DurationMs: time.Since(start).Milliseconds(),
}
}
// Run SDLC command
sdlcResp, err := e.client.RunSDLC(ctx, spec.Command, spec.Args, e.workDir)
if err != nil {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("sdlc command failed: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
if !sdlcResp.Success {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("sdlc command failed: %s", sdlcResp.Error),
Output: sdlcResp.Output,
DurationMs: time.Since(start).Milliseconds(),
}
}
result := &domain.BuildResult{
Success: true,
Output: sdlcResp.Output,
DurationMs: time.Since(start).Milliseconds(),
}
// Commit and push if enabled
if spec.AutoCommit {
commitMsg := fmt.Sprintf("sdlc: %s", spec.Command)
gitResp, err := e.client.GitCommitAndPush(ctx, commitMsg, spec.AutoPush, e.workDir)
if err != nil {
result.Success = false
result.Error = fmt.Sprintf("git operations failed: %v", err)
return result
}
if !gitResp.Success {
result.Success = false
result.Error = fmt.Sprintf("git operations failed: %s", gitResp.Error)
return result
}
if gitResp.HasChanges {
result.CommitSHA = gitResp.CommitSHA
result.FilesChanged = gitResp.FilesChanged
log.Info("SDLC changes committed",
"task_id", task.ID,
"commit", gitResp.CommitSHA,
"files", len(gitResp.FilesChanged),
"pushed", gitResp.Pushed,
)
}
}
log.Info("SDLC task completed",
"task_id", task.ID,
"command", spec.Command,
logging.FieldDuration, result.DurationMs,
)
return result
}
// httpSDLCSpec holds typed fields extracted from the task spec map.
type httpSDLCSpec struct {
Command string
Args []string
GitCloneURL string
AutoCommit bool
AutoPush bool
}
// parseSpec extracts typed SDLCTaskSpec fields from the generic map.
func (e *HTTPSDLCTaskExecutor) parseSpec(spec map[string]any) (*httpSDLCSpec, error) {
command, _ := spec["command"].(string)
if command == "" {
return nil, fmt.Errorf("command is required")
}
gitCloneURL, _ := spec["git_clone_url"].(string)
if gitCloneURL == "" {
return nil, fmt.Errorf("git_clone_url is required")
}
autoCommit, _ := spec["auto_commit"].(bool)
autoPush, _ := spec["auto_push"].(bool)
// Parse args (can be []string or []any from JSON)
var args []string
if argsRaw, ok := spec["args"]; ok {
switch v := argsRaw.(type) {
case []string:
args = v
case []any:
for _, a := range v {
if s, ok := a.(string); ok {
args = append(args, s)
}
}
}
}
return &httpSDLCSpec{
Command: command,
Args: args,
GitCloneURL: gitCloneURL,
AutoCommit: autoCommit,
AutoPush: autoPush,
}, nil
}