rdev/internal/worker/sdlc_executor.go
jordan 6ec2a4fea3
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(sdlc): persist branch metadata on main before feature branch creation
The `sdlc merge` command reads the Branch field from the feature manifest
on main, but `sdlc branch create` was only committing that state to the
feature branch (via the executor's CommitAndPush). This caused merge to
fail with "feature has no branch".

Two changes:
1. cmd/sdlc/cmd_branch.go: commit .sdlc/ state to main before
   `git checkout -b`, ensuring Branch metadata is on main where merge
   reads it.
2. internal/worker/sdlc_executor.go: reset workspace to main
   (`git fetch && git checkout main && git reset --hard origin/main`)
   before each SDLC task, preventing cross-task branch contamination
   from commands that switch branches.

Also updates foundary cookbook with architect fallback pattern and
on_error: continue for steps that may fail during early lifecycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 08:36:10 -07:00

321 lines
9.2 KiB
Go

package worker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
)
// SDLCTaskExecutor handles WorkTaskTypeSDLC tasks by executing sdlc CLI commands
// inside worker pods via kubectl exec. This enables SDLC operations on skeleton
// projects that don't have dedicated pods.
type SDLCTaskExecutor struct {
podGitOps *PodGitOperations
namespace string
}
// SDLCTaskExecutorConfig holds configuration for the SDLC task executor.
type SDLCTaskExecutorConfig struct {
// Namespace is the Kubernetes namespace for kubectl exec.
Namespace string
// PodGitOps provides git clone/commit/push operations.
PodGitOps *PodGitOperations
}
// NewSDLCTaskExecutor creates a new SDLC task executor.
func NewSDLCTaskExecutor(cfg SDLCTaskExecutorConfig) *SDLCTaskExecutor {
return &SDLCTaskExecutor{
podGitOps: cfg.PodGitOps,
namespace: cfg.Namespace,
}
}
// Execute runs an SDLC task by cloning the repo, executing the sdlc CLI command,
// and optionally committing/pushing changes.
func (e *SDLCTaskExecutor) Execute(ctx context.Context, task *domain.WorkTask) *domain.BuildResult {
start := time.Now()
log := logging.FromContext(ctx).WithWorker("sdlc-task-executor")
spec, err := e.parseSpec(task.Spec)
if err != nil {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("invalid SDLC spec: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
// Use the first available claudebox worker pod
podName := "claudebox-0"
// Working directory in the pod
workDir := "/workspace"
log.Info("executing SDLC task",
"task_id", task.ID,
logging.FieldProjectID, task.ProjectID,
"command", spec.Command,
logging.FieldPodName, podName,
)
// 1. Clone repo to worker pod
if e.podGitOps == nil {
return &domain.BuildResult{
Success: false,
Error: "pod git operations not configured",
DurationMs: time.Since(start).Milliseconds(),
}
}
cloneResult := e.podGitOps.CloneRepo(ctx, podName, workDir, spec.GitCloneURL)
if cloneResult.Error != nil {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("git clone failed: %v", cloneResult.Error),
DurationMs: time.Since(start).Milliseconds(),
}
}
// 1b. Reset workspace to main to ensure a known-good starting state.
// Worker pods are reused across tasks and may be left on a feature branch
// from a previous command (e.g., `sdlc branch create` switches branches).
if err := e.resetToMain(ctx, podName, workDir); err != nil {
log.Warn("failed to reset workspace to main, continuing",
"task_id", task.ID,
logging.FieldError, err,
)
}
// 2. Ensure .sdlc/ is initialized (auto-init for skeleton projects)
if err := e.ensureSDLCInit(ctx, podName, workDir); err != nil {
log.Warn("sdlc init check failed, continuing anyway",
"task_id", task.ID,
logging.FieldError, err,
)
}
// 3. Run SDLC CLI command
output, err := e.runSDLCCommand(ctx, podName, workDir, spec.Command, spec.Args)
if err != nil {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("sdlc command failed: %v", err),
Output: output,
DurationMs: time.Since(start).Milliseconds(),
}
}
result := &domain.BuildResult{
Success: true,
Output: output,
DurationMs: time.Since(start).Milliseconds(),
}
// 4. Commit and push if enabled
if spec.AutoCommit {
commitMsg := fmt.Sprintf("sdlc: %s", spec.Command)
gitResult := e.podGitOps.CommitAndPush(ctx, podName, workDir, commitMsg, spec.AutoPush)
if gitResult.Error != nil {
result.Success = false
result.Error = fmt.Sprintf("git operations failed: %v", gitResult.Error)
return result
}
if gitResult.HasChanges {
result.CommitSHA = gitResult.CommitSHA
result.FilesChanged = gitResult.FilesChanged
log.Info("SDLC changes committed",
"task_id", task.ID,
"commit", gitResult.CommitSHA,
"files", len(gitResult.FilesChanged),
"pushed", gitResult.Pushed,
)
}
}
log.Info("SDLC task completed",
"task_id", task.ID,
"command", spec.Command,
logging.FieldDuration, result.DurationMs,
)
return result
}
// resetToMain resets the workspace to the main branch with a clean state.
// This ensures each SDLC task starts from a known-good state regardless of
// what previous tasks may have done (e.g., switched to a feature branch).
func (e *SDLCTaskExecutor) resetToMain(ctx context.Context, podName, workDir string) error {
resetScript := fmt.Sprintf(
"cd %s && git fetch origin && git checkout main && git reset --hard origin/main",
workDir,
)
args := []string{
"exec", "-n", e.namespace, podName, "--",
"sh", "-c", resetScript,
}
cmd := exec.CommandContext(ctx, "kubectl", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("reset to main: %s: %w", stderr.String(), err)
}
return nil
}
// ensureSDLCInit checks if .sdlc/ exists and runs `sdlc init` if it doesn't.
// This enables SDLC operations on skeleton projects that don't have .sdlc/ pre-initialized.
func (e *SDLCTaskExecutor) ensureSDLCInit(ctx context.Context, podName, workDir string) error {
log := logging.FromContext(ctx).WithWorker("sdlc-task-executor")
// Check if .sdlc/ directory exists
checkArgs := []string{
"exec", "-n", e.namespace, podName, "--",
"sh", "-c",
fmt.Sprintf("test -d %s/.sdlc && echo exists || echo missing", workDir),
}
cmd := exec.CommandContext(ctx, "kubectl", checkArgs...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return fmt.Errorf("check .sdlc failed: %w", err)
}
if strings.TrimSpace(stdout.String()) == "exists" {
return nil // Already initialized
}
// Run sdlc init
log.Info("initializing .sdlc directory", logging.FieldPodName, podName, "workDir", workDir)
initArgs := []string{
"exec", "-n", e.namespace, podName, "--",
"sh", "-c",
fmt.Sprintf("cd %s && sdlc init --json", workDir),
}
initCmd := exec.CommandContext(ctx, "kubectl", initArgs...)
var initStdout, initStderr bytes.Buffer
initCmd.Stdout = &initStdout
initCmd.Stderr = &initStderr
if err := initCmd.Run(); err != nil {
return fmt.Errorf("sdlc init failed: %w: %s", err, initStderr.String())
}
log.Info("sdlc initialized", logging.FieldPodName, podName, "output", initStdout.String())
return nil
}
// runSDLCCommand executes the sdlc CLI command in the worker pod.
func (e *SDLCTaskExecutor) runSDLCCommand(ctx context.Context, podName, workDir, command string, args []string) (string, error) {
// Build the full command: sdlc {command} {args...} --json
// Each argument is quoted to handle values with spaces (e.g., --title "My Feature")
sdlcArgs := []string{shellQuote(command)}
for _, arg := range args {
sdlcArgs = append(sdlcArgs, shellQuote(arg))
}
sdlcArgs = append(sdlcArgs, "--json")
// Build kubectl exec command
kubectlArgs := []string{
"exec", "-n", e.namespace, podName, "--",
"sh", "-c",
fmt.Sprintf("cd %s && sdlc %s", workDir, strings.Join(sdlcArgs, " ")),
}
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return stdout.String(), fmt.Errorf("%s: %s", err, stderr.String())
}
return stdout.String(), nil
}
// parsedSDLCSpec holds typed fields extracted from the task spec map.
type parsedSDLCSpec struct {
Command string
Args []string
GitCloneURL string
AutoCommit bool
AutoPush bool
}
// parseSpec extracts typed SDLCTaskSpec fields from the generic map[string]any.
func (e *SDLCTaskExecutor) parseSpec(spec map[string]any) (*parsedSDLCSpec, error) {
command, _ := spec["command"].(string)
if command == "" {
return nil, fmt.Errorf("command is required")
}
gitCloneURL, _ := spec["git_clone_url"].(string)
if gitCloneURL == "" {
return nil, fmt.Errorf("git_clone_url is required")
}
autoCommit, _ := spec["auto_commit"].(bool)
autoPush, _ := spec["auto_push"].(bool)
// Parse args (can be []string or []any from JSON)
var args []string
if argsRaw, ok := spec["args"]; ok {
switch v := argsRaw.(type) {
case []string:
args = v
case []any:
for _, a := range v {
if s, ok := a.(string); ok {
args = append(args, s)
}
}
}
}
return &parsedSDLCSpec{
Command: command,
Args: args,
GitCloneURL: gitCloneURL,
AutoCommit: autoCommit,
AutoPush: autoPush,
}, nil
}
// SDLCResult represents the parsed JSON output from an SDLC command.
// Used by WorkerSDLCExecutor to parse results.
type SDLCResult struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// shellQuote escapes a string for safe use in a shell command.
// It wraps the string in single quotes and escapes any single quotes within.
func shellQuote(s string) string {
// If the string contains no special characters, return as-is
if !strings.ContainsAny(s, " \t\n'\"\\$`!*?[]{}|&;<>()") {
return s
}
// Escape single quotes by ending the quoted section, adding an escaped quote, and restarting
// 'foo'bar' becomes 'foo'"'"'bar'
escaped := strings.ReplaceAll(s, "'", "'\"'\"'")
return "'" + escaped + "'"
}