rdev/internal/worker/build_executor.go
jordan 9c15976f86 feat: Complete Claude endpoint and update cookbook
- Add session_id, model, allowed_tools to Claude request handler
- Update OpenAPI spec for Claude endpoint
- Fix BuildExecutor constructor call sites
- Rewrite landing-test.sh for agent-driven flow
- Fix cookbook documentation for correct API format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:25:29 -07:00

224 lines
5.9 KiB
Go

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
defaultPodName string // Default claudebox pod for agent execution
namespace string // Kubernetes namespace for the pod
}
// BuildExecutorConfig holds configuration for the build executor.
type BuildExecutorConfig struct {
DefaultPodName string // Default pod to execute Claude Code in (e.g., "claudebox-0")
Namespace string // Kubernetes namespace (e.g., "rdev")
}
// NewBuildExecutor creates a new build executor.
func NewBuildExecutor(
agentRegistry port.CodeAgentRegistry,
gitOps *GitOperations,
logger *slog.Logger,
cfg *BuildExecutorConfig,
) *BuildExecutor {
if logger == nil {
logger = slog.Default()
}
if cfg == nil {
cfg = &BuildExecutorConfig{
DefaultPodName: "claudebox-0",
Namespace: "rdev",
}
}
return &BuildExecutor{
agentRegistry: agentRegistry,
gitOps: gitOps,
logger: logger.With("component", "build-executor"),
defaultPodName: cfg.DefaultPodName,
namespace: cfg.Namespace,
}
}
// 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(),
}
}
// Determine which pod to execute in (from task spec or default)
podName, _ := task.Spec["pod_name"].(string)
if podName == "" {
podName = b.defaultPodName
}
// Build the agent request with pod metadata for Claude Code adapter
agentReq := &domain.AgentRequest{
Prompt: spec.Prompt,
ProjectID: domain.ProjectID(task.ProjectID),
WorkingDir: workDir,
Timeout: 10 * time.Minute,
Metadata: map[string]string{
"pod_name": podName,
"namespace": b.namespace,
},
}
// 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] + "..."
}