rdev/internal/worker/sdlc_executor.go
jordan d69da6d627 feat: add structured logging infrastructure and SDLC extensions
Major changes:
- Add internal/logging package with field constants, context propagation,
  sensitive data auto-redaction, and per-component log levels
- Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.)
- Extend SDLC with callback handlers, generate endpoints, and executor
- Add new cookbook trees for aeries and slackpath progression
- Add skeleton templates for queue, realtime, and microservices
- Add worker component template with async job processing
- Refactor services and handlers to use new logging infrastructure
- Split component.go into component_infra.go and component_listing.go

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:56:04 -07:00

274 lines
7.3 KiB
Go

package worker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"strings"
"time"
"github.com/orchard9/rdev/internal/domain"
)
// 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
logger *slog.Logger
}
// 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
Logger *slog.Logger
}
// NewSDLCTaskExecutor creates a new SDLC task executor.
func NewSDLCTaskExecutor(cfg SDLCTaskExecutorConfig) *SDLCTaskExecutor {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &SDLCTaskExecutor{
podGitOps: cfg.PodGitOps,
namespace: cfg.Namespace,
logger: logger.With("component", "sdlc-task-executor"),
}
}
// 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()
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"
e.logger.Info("executing SDLC task",
"task_id", task.ID,
"project_id", task.ProjectID,
"command", spec.Command,
"pod", 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(),
}
}
// 2. Ensure .sdlc/ is initialized (auto-init for skeleton projects)
if err := e.ensureSDLCInit(ctx, podName, workDir); err != nil {
e.logger.Warn("sdlc init check failed, continuing anyway",
"task_id", task.ID,
"error", 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
e.logger.Info("SDLC changes committed",
"task_id", task.ID,
"commit", gitResult.CommitSHA,
"files", len(gitResult.FilesChanged),
"pushed", gitResult.Pushed,
)
}
}
e.logger.Info("SDLC task completed",
"task_id", task.ID,
"command", spec.Command,
"duration_ms", result.DurationMs,
)
return result
}
// 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 {
// 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
e.logger.Info("initializing .sdlc directory", "pod", 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())
}
e.logger.Info("sdlc initialized", "pod", 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
sdlcArgs := []string{command}
sdlcArgs = append(sdlcArgs, args...)
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"`
}