- 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>
224 lines
5.9 KiB
Go
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] + "..."
|
|
}
|