rdev/internal/worker/http_build_executor.go
jordan 84af398d85
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
refactor: add timeout constants for agent execution tiers
Add TimeoutAgentExecution (22m) to handlers for synchronous SDLC
execution, and TimeoutAgent{Default,Medium,Heavy} (12/22/47m) to
workers for tiered agent task execution. Aligns with SDLC action
complexity tiers and prevents inline duration literals.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-11 10:48:24 -07:00

322 lines
9.2 KiB
Go

package worker
import (
"context"
"fmt"
"strings"
"time"
claudeboxclient "github.com/orchard9/rdev/internal/adapter/claudebox"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/port"
)
// HTTPBuildExecutor handles WorkTaskTypeBuild tasks using HTTP calls to the
// local claudebox sidecar instead of kubectl exec.
type HTTPBuildExecutor struct {
client *claudeboxclient.Client
streams port.StreamPublisher
workDir string
}
// HTTPBuildExecutorConfig holds configuration for the HTTP build executor.
type HTTPBuildExecutorConfig struct {
// ClaudeboxClient is the HTTP client for the claudebox sidecar.
ClaudeboxClient *claudeboxclient.Client
// Streams is the SSE stream publisher for real-time events.
Streams port.StreamPublisher
// WorkDir is the default working directory in the container.
WorkDir string
}
// NewHTTPBuildExecutor creates a new HTTP-based build executor.
func NewHTTPBuildExecutor(cfg HTTPBuildExecutorConfig) *HTTPBuildExecutor {
if cfg.WorkDir == "" {
cfg.WorkDir = "/workspace"
}
return &HTTPBuildExecutor{
client: cfg.ClaudeboxClient,
streams: cfg.Streams,
workDir: cfg.WorkDir,
}
}
// Execute runs a build task using the claudebox sidecar HTTP API.
func (e *HTTPBuildExecutor) Execute(ctx context.Context, task *domain.WorkTask) *domain.BuildResult {
log := logging.FromContext(ctx).WithWorker("http-build-executor")
start := time.Now()
streamID := task.ID
// Publish started event
e.publishEvent(streamID, BuildEventStarted, map[string]any{
"task_id": task.ID,
"project_id": task.ProjectID,
"started_at": start.Format(time.RFC3339),
})
// Parse build spec
spec, err := e.parseSpec(task.Spec)
if err != nil {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": fmt.Sprintf("invalid build spec: %v", err),
})
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("invalid build spec: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
// Clone or update repository if git operations are needed
if (spec.AutoCommit || spec.AutoPush) && e.client != nil {
if spec.GitCloneURL == "" {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": "git_clone_url is required when auto_commit or auto_push is enabled",
})
return &domain.BuildResult{
Success: false,
Error: "git_clone_url is required when auto_commit or auto_push is enabled",
DurationMs: time.Since(start).Milliseconds(),
}
}
log.Info("cloning repository via HTTP", "task_id", task.ID)
cloneResp, err := e.client.GitClone(ctx, spec.GitCloneURL, e.workDir)
if err != nil {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": fmt.Sprintf("git clone failed: %v", err),
})
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("git clone failed: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
if !cloneResp.Success {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": fmt.Sprintf("git clone failed: %s", cloneResp.Error),
})
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("git clone failed: %s", cloneResp.Error),
DurationMs: time.Since(start).Milliseconds(),
}
}
if cloneResp.Cloned {
e.publishEvent(streamID, BuildEventOutput, map[string]any{
"content": fmt.Sprintf("Cloned repository to %s", e.workDir),
})
}
}
// Execute Claude Code via HTTP
log.Info("executing Claude Code via HTTP", "task_id", task.ID, "prompt_len", len(spec.Prompt))
var output strings.Builder
const maxOutputSize = 1 << 20 // 1MB
// Derive timeout from spec or use default (10 minutes)
timeoutSec := 600
if ts, ok := task.Spec["timeout_seconds"].(float64); ok && ts > 0 {
timeoutSec = int(ts)
}
// Use streaming execution
execErr := e.client.ExecuteStream(ctx, &claudeboxclient.ExecuteRequest{
Prompt: spec.Prompt,
WorkingDir: e.workDir,
Timeout: timeoutSec,
}, func(evt claudeboxclient.StreamEvent) {
// Map event types
eventType := BuildEventOutput
switch evt.Type {
case "tool_use":
eventType = BuildEventToolUse
case "tool_result":
eventType = BuildEventToolResult
case "error":
eventType = BuildEventError
}
e.publishEvent(streamID, eventType, map[string]any{
"content": evt.Content,
"stream": evt.Stream,
"tool_name": evt.ToolName,
})
// Buffer output
if evt.Type == "output" || evt.Type == "error" {
if output.Len() >= maxOutputSize {
return
}
if output.Len() > 0 {
output.WriteString("\n")
}
remaining := maxOutputSize - output.Len()
if len(evt.Content) > remaining {
output.WriteString(evt.Content[:remaining])
output.WriteString("\n... [output truncated at 1MB]")
} else {
output.WriteString(evt.Content)
}
}
})
if execErr != nil {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": fmt.Sprintf("agent execution failed: %v", execErr),
"duration_ms": time.Since(start).Milliseconds(),
})
e.closeStream(ctx, streamID)
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("agent execution failed: %v", execErr),
Output: output.String(),
DurationMs: time.Since(start).Milliseconds(),
}
}
result := &domain.BuildResult{
Success: true,
Output: output.String(),
DurationMs: time.Since(start).Milliseconds(),
Artifacts: make(map[string]string),
}
// Include SDLC context in artifacts for callback routing
if spec.SDLCContext != nil {
if spec.SDLCContext.Feature != "" {
result.Artifacts["sdlc_feature"] = spec.SDLCContext.Feature
}
if spec.SDLCContext.ArtifactType != "" {
result.Artifacts["sdlc_artifact_type"] = spec.SDLCContext.ArtifactType
}
if spec.SDLCContext.TaskID != "" {
result.Artifacts["sdlc_task_id"] = spec.SDLCContext.TaskID
}
}
// Post-build git operations: commit and push changes
if result.Success && spec.AutoCommit && e.client != nil {
commitMsg := fmt.Sprintf("build: %s", truncate(spec.Prompt, 72))
gitResp, err := e.client.GitCommitAndPush(ctx, commitMsg, spec.AutoPush, e.workDir)
if err != nil {
log.Warn("post-build git operations failed", "task_id", task.ID, "error", err)
result.Success = false
result.Error = fmt.Sprintf("build succeeded but git operations failed: %v", err)
} else if !gitResp.Success {
log.Warn("post-build git operations failed", "task_id", task.ID, "error", gitResp.Error)
result.Success = false
result.Error = fmt.Sprintf("build succeeded but git operations failed: %s", gitResp.Error)
} else if gitResp.HasChanges {
result.CommitSHA = gitResp.CommitSHA
result.FilesChanged = gitResp.FilesChanged
log.Info("post-build git operations completed",
"task_id", task.ID,
"commit", gitResp.CommitSHA,
"files", len(gitResp.FilesChanged),
"pushed", gitResp.Pushed,
)
} else {
log.Info("no changes to commit after build", "task_id", task.ID)
}
}
// Publish completion event
if result.Success {
e.publishEvent(streamID, BuildEventCompleted, map[string]any{
"task_id": task.ID,
"success": true,
"commit_sha": result.CommitSHA,
"files_changed": result.FilesChanged,
"duration_ms": result.DurationMs,
})
} else {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": result.Error,
"duration_ms": result.DurationMs,
})
}
e.closeStream(ctx, streamID)
return result
}
// publishEvent publishes an event to the SSE stream.
func (e *HTTPBuildExecutor) publishEvent(streamID, eventType string, data map[string]any) {
if e.streams == nil {
return
}
e.streams.Publish(streamID, port.StreamEvent{
Type: eventType,
Data: data,
})
}
// closeStream closes the stream after a delay.
func (e *HTTPBuildExecutor) closeStream(ctx context.Context, streamID string) {
if e.streams == nil {
return
}
go func() {
select {
case <-ctx.Done():
e.streams.Close(streamID)
case <-time.After(streamCloseDelay):
e.streams.Close(streamID)
}
}()
}
// httpBuildSpec holds typed fields extracted from the task spec map.
type httpBuildSpec struct {
Prompt string
AutoCommit bool
AutoPush bool
GitCloneURL string
SDLCContext *sdlcContext
}
// parseSpec extracts typed BuildSpec fields from the generic map.
func (e *HTTPBuildExecutor) parseSpec(spec map[string]any) (*httpBuildSpec, error) {
prompt, _ := spec["prompt"].(string)
if prompt == "" {
return nil, fmt.Errorf("prompt is required")
}
autoCommit, _ := spec["auto_commit"].(bool)
autoPush, _ := spec["auto_push"].(bool)
gitCloneURL, _ := spec["git_clone_url"].(string)
parsed := &httpBuildSpec{
Prompt: prompt,
AutoCommit: autoCommit,
AutoPush: autoPush,
GitCloneURL: gitCloneURL,
}
// Extract SDLC context if present
if sdlcCtx, ok := spec["sdlc_context"].(map[string]any); ok {
parsed.SDLCContext = &sdlcContext{
Feature: stringFromMap(sdlcCtx, "feature"),
ArtifactType: stringFromMap(sdlcCtx, "artifact_type"),
TaskID: stringFromMap(sdlcCtx, "task_id"),
}
}
return parsed, nil
}