rdev/internal/service/build_service.go
jordan 84af398d85
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
refactor: add timeout constants for agent execution tiers
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>
2026-02-11 10:48:24 -07:00

209 lines
5.4 KiB
Go

// Package service provides business logic services.
package service
import (
"context"
"fmt"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/port"
)
// BuildService orchestrates build task submission and tracking.
// It coordinates between the work queue (execution) and build audit (history).
type BuildService struct {
queue port.WorkQueue
audit port.BuildAudit
}
// NewBuildService creates a new build service.
func NewBuildService(
queue port.WorkQueue,
audit port.BuildAudit,
) *BuildService {
return &BuildService{
queue: queue,
audit: audit,
}
}
// StartBuild enqueues a build task and creates an audit entry.
// Returns the task ID for status tracking.
func (s *BuildService) StartBuild(ctx context.Context, projectID string, spec domain.BuildSpec) (string, error) {
if err := spec.Validate(); err != nil {
return "", err
}
if projectID == "" {
return "", fmt.Errorf("project_id is required")
}
// Build work task spec from build spec
taskSpec := map[string]any{
"prompt": spec.Prompt,
"auto_commit": spec.AutoCommit,
"auto_push": spec.AutoPush,
}
if spec.Template != "" {
taskSpec["template"] = spec.Template
}
if len(spec.Variables) > 0 {
taskSpec["variables"] = spec.Variables
}
if spec.GitCloneURL != "" {
taskSpec["git_clone_url"] = spec.GitCloneURL
}
if spec.TimeoutSeconds > 0 {
taskSpec["timeout_seconds"] = spec.TimeoutSeconds
}
// Create work task
task := &domain.WorkTask{
ProjectID: projectID,
Type: domain.WorkTaskTypeBuild,
Spec: taskSpec,
CallbackURL: spec.CallbackURL,
MaxRetries: 3,
}
// Enqueue to work queue
taskID, err := s.queue.Enqueue(ctx, task)
if err != nil {
return "", fmt.Errorf("enqueue build task: %w", err)
}
// Create audit entry (non-critical - don't fail the build if audit fails)
auditEntry := &domain.BuildAuditEntry{
TaskID: taskID,
ProjectID: projectID,
Spec: spec,
Status: domain.BuildStatusPending,
StartedAt: time.Now(),
}
log := logging.FromContext(ctx).WithService("build")
if err := s.audit.Record(ctx, auditEntry); err != nil {
log.Warn("failed to record audit entry",
"task_id", taskID,
logging.FieldError, err.Error(),
)
}
log.Info("build enqueued",
"task_id", taskID,
logging.FieldProjectID, projectID,
"template", spec.Template,
"auto_push", spec.AutoPush,
)
return taskID, nil
}
// GetBuildStatus returns the current status of a build.
func (s *BuildService) GetBuildStatus(ctx context.Context, taskID string) (*domain.BuildAuditEntry, error) {
return s.audit.Get(ctx, taskID)
}
// ListBuilds returns build history for a project.
func (s *BuildService) ListBuilds(ctx context.Context, projectID string, limit int) ([]*domain.BuildAuditEntry, error) {
if limit <= 0 {
limit = 50
}
return s.audit.List(ctx, port.BuildAuditFilter{
ProjectID: projectID,
Limit: limit,
})
}
// CompleteBuild updates the audit entry when a build finishes.
// Called by the work queue processor on task completion.
func (s *BuildService) CompleteBuild(ctx context.Context, taskID string, result *domain.BuildResult) error {
if err := s.audit.Update(ctx, taskID, result); err != nil {
return fmt.Errorf("update audit: %w", err)
}
log := logging.FromContext(ctx).WithService("build")
log.Info("build completed",
"task_id", taskID,
"success", result.Success,
logging.FieldDuration, result.DurationMs,
)
return nil
}
// StartBuildWithSDLCContext enqueues a build task with SDLC context for callback routing.
// The SDLC context is included in the task spec and will be passed through to the callback.
func (s *BuildService) StartBuildWithSDLCContext(ctx context.Context, projectID string, spec domain.BuildSpec, sdlcCtx map[string]any) (string, error) {
if err := spec.Validate(); err != nil {
return "", err
}
if projectID == "" {
return "", fmt.Errorf("project_id is required")
}
// Build work task spec from build spec
taskSpec := map[string]any{
"prompt": spec.Prompt,
"auto_commit": spec.AutoCommit,
"auto_push": spec.AutoPush,
}
if spec.Template != "" {
taskSpec["template"] = spec.Template
}
if len(spec.Variables) > 0 {
taskSpec["variables"] = spec.Variables
}
if spec.GitCloneURL != "" {
taskSpec["git_clone_url"] = spec.GitCloneURL
}
if spec.TimeoutSeconds > 0 {
taskSpec["timeout_seconds"] = spec.TimeoutSeconds
}
// Add SDLC context for callback routing
if sdlcCtx != nil {
taskSpec["sdlc_context"] = sdlcCtx
}
// Create work task
task := &domain.WorkTask{
ProjectID: projectID,
Type: domain.WorkTaskTypeBuild,
Spec: taskSpec,
CallbackURL: spec.CallbackURL,
MaxRetries: 3,
}
// Enqueue to work queue
taskID, err := s.queue.Enqueue(ctx, task)
if err != nil {
return "", fmt.Errorf("enqueue build task: %w", err)
}
// Create audit entry (non-critical - don't fail the build if audit fails)
auditEntry := &domain.BuildAuditEntry{
TaskID: taskID,
ProjectID: projectID,
Spec: spec,
Status: domain.BuildStatusPending,
StartedAt: time.Now(),
}
log := logging.FromContext(ctx).WithService("build")
if err := s.audit.Record(ctx, auditEntry); err != nil {
log.Warn("failed to record audit entry",
"task_id", taskID,
logging.FieldError, err.Error(),
)
}
log.Info("build enqueued with SDLC context",
"task_id", taskID,
logging.FieldProjectID, projectID,
"sdlc_context", sdlcCtx,
)
return taskID, nil
}