rdev/internal/domain/build.go
jordan cfba724f8a feat: add work task error classification and user-facing error codes
- Add WorkErrorCode type with RATE_LIMITED, AUTH_FAILED, TIMEOUT, STALE_WORKER, AGENT_ERROR, INVALID_SPEC
- Add ClassifyAgentError function to detect error patterns from stderr
- Add error_code column to work_queue table (migration 016)
- Add FailWithCode method to WorkQueue interface and implementations
- Update RequeueStaleWithIDs to mark permanently failed tasks with STALE_WORKER
- Add ErrorCode to BuildResult for API responses
- Update work executor to classify errors before failing tasks

This enables users to see actual failure reasons (e.g., "RATE_LIMITED") instead of
builds stuck in "running" state forever when Claude hits rate limits.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 00:07:34 -07:00

211 lines
6.2 KiB
Go

// Package domain contains pure domain models with no external dependencies.
package domain
import (
"fmt"
"maps"
"net/url"
"strconv"
"strings"
"time"
)
// BuildSpec defines what a build task should accomplish.
// It captures the user's intent and parameters for code generation.
type BuildSpec struct {
// Template is the project template to use (e.g., "nextjs-landing").
Template string `json:"template,omitempty"`
// Prompt is the user's instruction for the build.
Prompt string `json:"prompt"`
// Variables contains template-specific substitution values.
Variables map[string]string `json:"variables,omitempty"`
// AutoCommit controls whether changes are automatically committed.
AutoCommit bool `json:"auto_commit"`
// AutoPush controls whether commits are automatically pushed.
AutoPush bool `json:"auto_push"`
// CallbackURL is the webhook URL for completion notification.
CallbackURL string `json:"callback_url,omitempty"`
// GitCloneURL is the HTTPS URL for cloning the project repository.
// Required for builds that use AutoCommit/AutoPush on shared worker pods.
GitCloneURL string `json:"git_clone_url,omitempty"`
}
// Validate checks that the BuildSpec has required fields.
func (s *BuildSpec) Validate() error {
if s.Prompt == "" {
return ErrPromptRequired
}
if s.CallbackURL != "" {
if err := ValidateCallbackURL(s.CallbackURL); err != nil {
return err
}
}
return nil
}
// ValidateCallbackURL checks that a callback URL is safe to use.
// It rejects non-HTTPS URLs and private/internal network addresses.
func ValidateCallbackURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid callback URL: %w", err)
}
if u.Scheme != "https" {
return fmt.Errorf("callback URL must use HTTPS scheme, got %q", u.Scheme)
}
if u.Host == "" {
return fmt.Errorf("callback URL must have a host")
}
host := u.Hostname()
lower := strings.ToLower(host)
// Block localhost and loopback
if lower == "localhost" || lower == "127.0.0.1" || lower == "::1" || lower == "[::1]" {
return fmt.Errorf("callback URL must not point to localhost")
}
// Block common metadata endpoints
if lower == "metadata.google.internal" || lower == "169.254.169.254" {
return fmt.Errorf("callback URL must not point to cloud metadata service")
}
// Block private network ranges
if strings.HasPrefix(lower, "10.") || strings.HasPrefix(lower, "192.168.") {
return fmt.Errorf("callback URL must not point to private network addresses")
}
if strings.HasPrefix(lower, "172.") {
// 172.16.0.0 - 172.31.255.255
parts := strings.SplitN(lower, ".", 3)
if len(parts) >= 2 {
if octet, err := strconv.Atoi(parts[1]); err == nil && octet >= 16 && octet <= 31 {
return fmt.Errorf("callback URL must not point to private network addresses")
}
}
}
return nil
}
// BuildResult captures the outcome of a build execution.
type BuildResult struct {
// Success indicates whether the build completed successfully.
Success bool `json:"success"`
// Output is the agent's text output during the build.
Output string `json:"output,omitempty"`
// Error contains the error message if the build failed.
Error string `json:"error,omitempty"`
// ErrorCode categorizes the failure type for programmatic handling.
// Values: RATE_LIMITED, AUTH_FAILED, TIMEOUT, STALE_WORKER, AGENT_ERROR, INVALID_SPEC
ErrorCode WorkErrorCode `json:"error_code,omitempty"`
// CommitSHA is the git commit hash if auto-commit was enabled.
CommitSHA string `json:"commit_sha,omitempty"`
// FilesChanged lists files modified during the build.
FilesChanged []string `json:"files_changed,omitempty"`
// DurationMs is the total execution time in milliseconds.
DurationMs int64 `json:"duration_ms"`
// Artifacts contains named outputs from the build (e.g., deploy URLs).
Artifacts map[string]string `json:"artifacts,omitempty"`
}
// ToWorkResult converts a BuildResult to a WorkResult.
// Build-specific fields (commit_sha, files_changed, duration_ms) are
// promoted into the artifacts map, overwriting any existing keys with
// the same names.
func (r *BuildResult) ToWorkResult() *WorkResult {
if r == nil {
return &WorkResult{}
}
artifacts := make(map[string]string)
maps.Copy(artifacts, r.Artifacts)
// Promote build-specific fields into artifacts
if r.CommitSHA != "" {
artifacts["commit_sha"] = r.CommitSHA
}
if r.DurationMs > 0 {
artifacts["duration_ms"] = strconv.FormatInt(r.DurationMs, 10)
}
if len(r.FilesChanged) > 0 {
artifacts["files_changed_count"] = strconv.Itoa(len(r.FilesChanged))
}
output := r.Output
if !r.Success && r.Error != "" {
output = r.Error
}
return &WorkResult{
Output: output,
Artifacts: artifacts,
}
}
// BuildStatus represents the lifecycle state of a build.
type BuildStatus string
const (
BuildStatusPending BuildStatus = "pending"
BuildStatusRunning BuildStatus = "running"
BuildStatusCompleted BuildStatus = "completed"
BuildStatusFailed BuildStatus = "failed"
BuildStatusCancelled BuildStatus = "cancelled"
)
// IsValid returns true if the status is a known valid status.
func (s BuildStatus) IsValid() bool {
switch s {
case BuildStatusPending, BuildStatusRunning, BuildStatusCompleted,
BuildStatusFailed, BuildStatusCancelled:
return true
}
return false
}
// IsTerminal returns true if the build is in a final state.
func (s BuildStatus) IsTerminal() bool {
switch s {
case BuildStatusCompleted, BuildStatusFailed, BuildStatusCancelled:
return true
}
return false
}
// BuildAuditEntry represents a single build's audit record.
type BuildAuditEntry struct {
// TaskID is the work queue task ID.
TaskID string `json:"task_id"`
// ProjectID is the project this build belongs to.
ProjectID string `json:"project_id"`
// WorkerID is the worker that executed the build.
WorkerID string `json:"worker_id,omitempty"`
// Spec is the original build specification.
Spec BuildSpec `json:"spec"`
// Result is the build outcome (nil if not yet complete).
Result *BuildResult `json:"result,omitempty"`
// Status is the current build status.
Status BuildStatus `json:"status"`
// StartedAt is when the build was created/enqueued.
StartedAt time.Time `json:"started_at"`
// CompletedAt is when the build finished (nil if still running).
CompletedAt *time.Time `json:"completed_at,omitempty"`
}