- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
197 lines
5.0 KiB
Go
197 lines
5.0 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
|
|
}
|
|
|
|
// 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] + "..."
|
|
}
|