All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Add TimeoutAgentExecution (22m) to handlers for synchronous SDLC
execution, and TimeoutAgent{Default,Medium,Heavy} (12/22/47m) to
workers for tiered agent task execution. Aligns with SDLC action
complexity tiers and prevents inline duration literals.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
322 lines
9.2 KiB
Go
322 lines
9.2 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
claudeboxclient "github.com/orchard9/rdev/internal/adapter/claudebox"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/logging"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// HTTPBuildExecutor handles WorkTaskTypeBuild tasks using HTTP calls to the
|
|
// local claudebox sidecar instead of kubectl exec.
|
|
type HTTPBuildExecutor struct {
|
|
client *claudeboxclient.Client
|
|
streams port.StreamPublisher
|
|
workDir string
|
|
}
|
|
|
|
// HTTPBuildExecutorConfig holds configuration for the HTTP build executor.
|
|
type HTTPBuildExecutorConfig struct {
|
|
// ClaudeboxClient is the HTTP client for the claudebox sidecar.
|
|
ClaudeboxClient *claudeboxclient.Client
|
|
|
|
// Streams is the SSE stream publisher for real-time events.
|
|
Streams port.StreamPublisher
|
|
|
|
// WorkDir is the default working directory in the container.
|
|
WorkDir string
|
|
}
|
|
|
|
// NewHTTPBuildExecutor creates a new HTTP-based build executor.
|
|
func NewHTTPBuildExecutor(cfg HTTPBuildExecutorConfig) *HTTPBuildExecutor {
|
|
if cfg.WorkDir == "" {
|
|
cfg.WorkDir = "/workspace"
|
|
}
|
|
return &HTTPBuildExecutor{
|
|
client: cfg.ClaudeboxClient,
|
|
streams: cfg.Streams,
|
|
workDir: cfg.WorkDir,
|
|
}
|
|
}
|
|
|
|
// Execute runs a build task using the claudebox sidecar HTTP API.
|
|
func (e *HTTPBuildExecutor) Execute(ctx context.Context, task *domain.WorkTask) *domain.BuildResult {
|
|
log := logging.FromContext(ctx).WithWorker("http-build-executor")
|
|
start := time.Now()
|
|
streamID := task.ID
|
|
|
|
// Publish started event
|
|
e.publishEvent(streamID, BuildEventStarted, map[string]any{
|
|
"task_id": task.ID,
|
|
"project_id": task.ProjectID,
|
|
"started_at": start.Format(time.RFC3339),
|
|
})
|
|
|
|
// Parse build spec
|
|
spec, err := e.parseSpec(task.Spec)
|
|
if err != nil {
|
|
e.publishEvent(streamID, BuildEventFailed, map[string]any{
|
|
"task_id": task.ID,
|
|
"error": fmt.Sprintf("invalid build spec: %v", err),
|
|
})
|
|
return &domain.BuildResult{
|
|
Success: false,
|
|
Error: fmt.Sprintf("invalid build spec: %v", err),
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
}
|
|
}
|
|
|
|
// Clone or update repository if git operations are needed
|
|
if (spec.AutoCommit || spec.AutoPush) && e.client != nil {
|
|
if spec.GitCloneURL == "" {
|
|
e.publishEvent(streamID, BuildEventFailed, map[string]any{
|
|
"task_id": task.ID,
|
|
"error": "git_clone_url is required when auto_commit or auto_push is enabled",
|
|
})
|
|
return &domain.BuildResult{
|
|
Success: false,
|
|
Error: "git_clone_url is required when auto_commit or auto_push is enabled",
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
}
|
|
}
|
|
|
|
log.Info("cloning repository via HTTP", "task_id", task.ID)
|
|
|
|
cloneResp, err := e.client.GitClone(ctx, spec.GitCloneURL, e.workDir)
|
|
if err != nil {
|
|
e.publishEvent(streamID, BuildEventFailed, map[string]any{
|
|
"task_id": task.ID,
|
|
"error": fmt.Sprintf("git clone failed: %v", err),
|
|
})
|
|
return &domain.BuildResult{
|
|
Success: false,
|
|
Error: fmt.Sprintf("git clone failed: %v", err),
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
}
|
|
}
|
|
|
|
if !cloneResp.Success {
|
|
e.publishEvent(streamID, BuildEventFailed, map[string]any{
|
|
"task_id": task.ID,
|
|
"error": fmt.Sprintf("git clone failed: %s", cloneResp.Error),
|
|
})
|
|
return &domain.BuildResult{
|
|
Success: false,
|
|
Error: fmt.Sprintf("git clone failed: %s", cloneResp.Error),
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
}
|
|
}
|
|
|
|
if cloneResp.Cloned {
|
|
e.publishEvent(streamID, BuildEventOutput, map[string]any{
|
|
"content": fmt.Sprintf("Cloned repository to %s", e.workDir),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Execute Claude Code via HTTP
|
|
log.Info("executing Claude Code via HTTP", "task_id", task.ID, "prompt_len", len(spec.Prompt))
|
|
|
|
var output strings.Builder
|
|
const maxOutputSize = 1 << 20 // 1MB
|
|
|
|
// Derive timeout from spec or use default (10 minutes)
|
|
timeoutSec := 600
|
|
if ts, ok := task.Spec["timeout_seconds"].(float64); ok && ts > 0 {
|
|
timeoutSec = int(ts)
|
|
}
|
|
|
|
// Use streaming execution
|
|
execErr := e.client.ExecuteStream(ctx, &claudeboxclient.ExecuteRequest{
|
|
Prompt: spec.Prompt,
|
|
WorkingDir: e.workDir,
|
|
Timeout: timeoutSec,
|
|
}, func(evt claudeboxclient.StreamEvent) {
|
|
// Map event types
|
|
eventType := BuildEventOutput
|
|
switch evt.Type {
|
|
case "tool_use":
|
|
eventType = BuildEventToolUse
|
|
case "tool_result":
|
|
eventType = BuildEventToolResult
|
|
case "error":
|
|
eventType = BuildEventError
|
|
}
|
|
|
|
e.publishEvent(streamID, eventType, map[string]any{
|
|
"content": evt.Content,
|
|
"stream": evt.Stream,
|
|
"tool_name": evt.ToolName,
|
|
})
|
|
|
|
// Buffer output
|
|
if evt.Type == "output" || evt.Type == "error" {
|
|
if output.Len() >= maxOutputSize {
|
|
return
|
|
}
|
|
if output.Len() > 0 {
|
|
output.WriteString("\n")
|
|
}
|
|
remaining := maxOutputSize - output.Len()
|
|
if len(evt.Content) > remaining {
|
|
output.WriteString(evt.Content[:remaining])
|
|
output.WriteString("\n... [output truncated at 1MB]")
|
|
} else {
|
|
output.WriteString(evt.Content)
|
|
}
|
|
}
|
|
})
|
|
|
|
if execErr != nil {
|
|
e.publishEvent(streamID, BuildEventFailed, map[string]any{
|
|
"task_id": task.ID,
|
|
"error": fmt.Sprintf("agent execution failed: %v", execErr),
|
|
"duration_ms": time.Since(start).Milliseconds(),
|
|
})
|
|
e.closeStream(ctx, streamID)
|
|
return &domain.BuildResult{
|
|
Success: false,
|
|
Error: fmt.Sprintf("agent execution failed: %v", execErr),
|
|
Output: output.String(),
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
}
|
|
}
|
|
|
|
result := &domain.BuildResult{
|
|
Success: true,
|
|
Output: output.String(),
|
|
DurationMs: time.Since(start).Milliseconds(),
|
|
Artifacts: make(map[string]string),
|
|
}
|
|
|
|
// Include SDLC context in artifacts for callback routing
|
|
if spec.SDLCContext != nil {
|
|
if spec.SDLCContext.Feature != "" {
|
|
result.Artifacts["sdlc_feature"] = spec.SDLCContext.Feature
|
|
}
|
|
if spec.SDLCContext.ArtifactType != "" {
|
|
result.Artifacts["sdlc_artifact_type"] = spec.SDLCContext.ArtifactType
|
|
}
|
|
if spec.SDLCContext.TaskID != "" {
|
|
result.Artifacts["sdlc_task_id"] = spec.SDLCContext.TaskID
|
|
}
|
|
}
|
|
|
|
// Post-build git operations: commit and push changes
|
|
if result.Success && spec.AutoCommit && e.client != nil {
|
|
commitMsg := fmt.Sprintf("build: %s", truncate(spec.Prompt, 72))
|
|
gitResp, err := e.client.GitCommitAndPush(ctx, commitMsg, spec.AutoPush, e.workDir)
|
|
|
|
if err != nil {
|
|
log.Warn("post-build git operations failed", "task_id", task.ID, "error", err)
|
|
result.Success = false
|
|
result.Error = fmt.Sprintf("build succeeded but git operations failed: %v", err)
|
|
} else if !gitResp.Success {
|
|
log.Warn("post-build git operations failed", "task_id", task.ID, "error", gitResp.Error)
|
|
result.Success = false
|
|
result.Error = fmt.Sprintf("build succeeded but git operations failed: %s", gitResp.Error)
|
|
} else if gitResp.HasChanges {
|
|
result.CommitSHA = gitResp.CommitSHA
|
|
result.FilesChanged = gitResp.FilesChanged
|
|
log.Info("post-build git operations completed",
|
|
"task_id", task.ID,
|
|
"commit", gitResp.CommitSHA,
|
|
"files", len(gitResp.FilesChanged),
|
|
"pushed", gitResp.Pushed,
|
|
)
|
|
} else {
|
|
log.Info("no changes to commit after build", "task_id", task.ID)
|
|
}
|
|
}
|
|
|
|
// Publish completion event
|
|
if result.Success {
|
|
e.publishEvent(streamID, BuildEventCompleted, map[string]any{
|
|
"task_id": task.ID,
|
|
"success": true,
|
|
"commit_sha": result.CommitSHA,
|
|
"files_changed": result.FilesChanged,
|
|
"duration_ms": result.DurationMs,
|
|
})
|
|
} else {
|
|
e.publishEvent(streamID, BuildEventFailed, map[string]any{
|
|
"task_id": task.ID,
|
|
"error": result.Error,
|
|
"duration_ms": result.DurationMs,
|
|
})
|
|
}
|
|
e.closeStream(ctx, streamID)
|
|
|
|
return result
|
|
}
|
|
|
|
// publishEvent publishes an event to the SSE stream.
|
|
func (e *HTTPBuildExecutor) publishEvent(streamID, eventType string, data map[string]any) {
|
|
if e.streams == nil {
|
|
return
|
|
}
|
|
e.streams.Publish(streamID, port.StreamEvent{
|
|
Type: eventType,
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
// closeStream closes the stream after a delay.
|
|
func (e *HTTPBuildExecutor) closeStream(ctx context.Context, streamID string) {
|
|
if e.streams == nil {
|
|
return
|
|
}
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
e.streams.Close(streamID)
|
|
case <-time.After(streamCloseDelay):
|
|
e.streams.Close(streamID)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// httpBuildSpec holds typed fields extracted from the task spec map.
|
|
type httpBuildSpec struct {
|
|
Prompt string
|
|
AutoCommit bool
|
|
AutoPush bool
|
|
GitCloneURL string
|
|
SDLCContext *sdlcContext
|
|
}
|
|
|
|
// parseSpec extracts typed BuildSpec fields from the generic map.
|
|
func (e *HTTPBuildExecutor) parseSpec(spec map[string]any) (*httpBuildSpec, error) {
|
|
prompt, _ := spec["prompt"].(string)
|
|
if prompt == "" {
|
|
return nil, fmt.Errorf("prompt is required")
|
|
}
|
|
|
|
autoCommit, _ := spec["auto_commit"].(bool)
|
|
autoPush, _ := spec["auto_push"].(bool)
|
|
gitCloneURL, _ := spec["git_clone_url"].(string)
|
|
|
|
parsed := &httpBuildSpec{
|
|
Prompt: prompt,
|
|
AutoCommit: autoCommit,
|
|
AutoPush: autoPush,
|
|
GitCloneURL: gitCloneURL,
|
|
}
|
|
|
|
// Extract SDLC context if present
|
|
if sdlcCtx, ok := spec["sdlc_context"].(map[string]any); ok {
|
|
parsed.SDLCContext = &sdlcContext{
|
|
Feature: stringFromMap(sdlcCtx, "feature"),
|
|
ArtifactType: stringFromMap(sdlcCtx, "artifact_type"),
|
|
TaskID: stringFromMap(sdlcCtx, "task_id"),
|
|
}
|
|
}
|
|
|
|
return parsed, nil
|
|
}
|