package worker import ( "context" "fmt" "log/slog" "strings" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // BuildExecutor handles WorkTaskTypeBuild tasks. // It translates BuildSpec fields from the work task's Spec map into an // AgentRequest, executes via a CodeAgent, and returns a BuildResult. type BuildExecutor struct { agentRegistry port.CodeAgentRegistry gitOps *GitOperations logger *slog.Logger } // NewBuildExecutor creates a new build executor. func NewBuildExecutor( agentRegistry port.CodeAgentRegistry, gitOps *GitOperations, logger *slog.Logger, ) *BuildExecutor { if logger == nil { logger = slog.Default() } return &BuildExecutor{ agentRegistry: agentRegistry, gitOps: gitOps, logger: logger.With("component", "build-executor"), } } // Execute runs a build task by translating its spec into an agent call. func (b *BuildExecutor) Execute(ctx context.Context, task *domain.WorkTask) *domain.BuildResult { start := time.Now() spec, err := b.parseSpec(task.Spec) if err != nil { return &domain.BuildResult{ Success: false, Error: fmt.Sprintf("invalid build spec: %v", err), DurationMs: time.Since(start).Milliseconds(), } } // Determine working directory workDir := "/workspace" // Clone repo if git URL is provided in the spec gitURL, _ := task.Spec["git_url"].(string) if gitURL != "" && b.gitOps != nil { cloneDir, cleanup, err := b.gitOps.CloneToTemp(ctx, gitURL) if err != nil { return &domain.BuildResult{ Success: false, Error: fmt.Sprintf("git clone failed: %v", err), DurationMs: time.Since(start).Milliseconds(), } } defer cleanup() workDir = cloneDir } // Get a code agent agent := b.agentRegistry.Default() if agent == nil { return &domain.BuildResult{ Success: false, Error: "no code agent available", DurationMs: time.Since(start).Milliseconds(), } } // Build the agent request agentReq := &domain.AgentRequest{ Prompt: spec.Prompt, ProjectID: domain.ProjectID(task.ProjectID), WorkingDir: workDir, Timeout: 10 * time.Minute, } // Collect output with a size cap to prevent OOM on verbose builds. const maxOutputSize = 1 << 20 // 1MB var outputBuilder strings.Builder b.logger.Info("executing build via agent", "task_id", task.ID, "project_id", task.ProjectID, "agent", agent.Name(), "work_dir", workDir, ) // Execute the agent agentResult, err := agent.Execute(ctx, agentReq, func(event domain.AgentEvent) { if event.Type == domain.AgentEventOutput || event.Type == domain.AgentEventError { if outputBuilder.Len() >= maxOutputSize { return // Output cap reached, discard further output } if outputBuilder.Len() > 0 { outputBuilder.WriteString("\n") } remaining := maxOutputSize - outputBuilder.Len() if len(event.Content) > remaining { outputBuilder.WriteString(event.Content[:remaining]) outputBuilder.WriteString("\n... [output truncated at 1MB]") } else { outputBuilder.WriteString(event.Content) } } }) if err != nil { return &domain.BuildResult{ Success: false, Error: fmt.Sprintf("agent execution failed: %v", err), Output: outputBuilder.String(), DurationMs: time.Since(start).Milliseconds(), } } result := &domain.BuildResult{ Success: agentResult.Success(), Output: outputBuilder.String(), DurationMs: time.Since(start).Milliseconds(), } if !agentResult.Success() { errMsg := "agent returned non-zero exit code" if agentResult.Error != nil { errMsg = agentResult.Error.Error() } result.Error = errMsg } // Handle git commit/push if requested if result.Success && b.gitOps != nil && gitURL != "" { if spec.AutoCommit { commitMsg := fmt.Sprintf("build: %s", truncate(spec.Prompt, 72)) sha, filesChanged, err := b.gitOps.CommitAndPush(ctx, workDir, commitMsg, spec.AutoPush) if err != nil { b.logger.Warn("git commit/push failed", "task_id", task.ID, "error", err, ) result.Success = false result.Error = fmt.Sprintf("build succeeded but git operations failed: %v", err) } else { result.CommitSHA = sha result.FilesChanged = filesChanged } } } return result } // parsedBuildSpec holds typed fields extracted from the task spec map. type parsedBuildSpec struct { Prompt string AutoCommit bool AutoPush bool } // parseSpec extracts typed BuildSpec fields from the generic map[string]any. func (b *BuildExecutor) parseSpec(spec map[string]any) (*parsedBuildSpec, error) { prompt, _ := spec["prompt"].(string) if prompt == "" { return nil, fmt.Errorf("prompt is required") } autoCommit, _ := spec["auto_commit"].(bool) autoPush, _ := spec["auto_push"].(bool) return &parsedBuildSpec{ Prompt: prompt, AutoCommit: autoCommit, AutoPush: autoPush, }, nil } // truncate shortens a string to maxLen, adding "..." if truncated. func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s } if maxLen <= 3 { return s[:maxLen] } return s[:maxLen-3] + "..." }