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 // Use streaming execution execErr := e.client.ExecuteStream(ctx, &claudeboxclient.ExecuteRequest{ Prompt: spec.Prompt, WorkingDir: e.workDir, Timeout: 600, // 10 minutes }, 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 }