package service import ( "sync" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // BuildProgressTracker estimates build progress based on agent activity patterns. // It tracks phases and emits progress events periodically. type BuildProgressTracker struct { streams port.StreamPublisher mu sync.RWMutex tasks map[string]*buildProgress } // buildProgress tracks the progress state for a single build. type buildProgress struct { taskID string projectID string phase domain.BuildPhase percentage float64 toolCount int outputLines int startTime time.Time lastUpdate time.Time } // NewBuildProgressTracker creates a new progress tracker. func NewBuildProgressTracker(streams port.StreamPublisher) *BuildProgressTracker { return &BuildProgressTracker{ streams: streams, tasks: make(map[string]*buildProgress), } } // Start begins tracking progress for a build. func (t *BuildProgressTracker) Start(taskID, projectID string) { t.mu.Lock() defer t.mu.Unlock() now := time.Now() t.tasks[taskID] = &buildProgress{ taskID: taskID, projectID: projectID, phase: domain.BuildPhaseStarting, percentage: 0, startTime: now, lastUpdate: now, } t.emitProgress(taskID) } // RecordToolUse updates progress when a tool is used. func (t *BuildProgressTracker) RecordToolUse(taskID, toolName string) { t.mu.Lock() defer t.mu.Unlock() progress, exists := t.tasks[taskID] if !exists { return } progress.toolCount++ progress.lastUpdate = time.Now() // Infer phase from tool usage switch toolName { case "Read", "Glob", "Grep": if progress.phase == domain.BuildPhaseStarting { progress.phase = domain.BuildPhaseReading } case "Write", "Edit": progress.phase = domain.BuildPhaseWriting case "Bash": // Could be testing or committing depending on context if progress.phase == domain.BuildPhaseWriting { progress.phase = domain.BuildPhaseTesting } } t.updatePercentage(progress) t.emitProgress(taskID) } // RecordOutput updates progress when output is received. func (t *BuildProgressTracker) RecordOutput(taskID string) { t.mu.Lock() defer t.mu.Unlock() progress, exists := t.tasks[taskID] if !exists { return } progress.outputLines++ progress.lastUpdate = time.Now() // Emit progress periodically (every 10 lines or 5 seconds) if progress.outputLines%10 == 0 || time.Since(progress.lastUpdate) > 5*time.Second { t.updatePercentage(progress) t.emitProgress(taskID) } } // Complete marks a build as complete. func (t *BuildProgressTracker) Complete(taskID string, success bool) { t.mu.Lock() defer t.mu.Unlock() progress, exists := t.tasks[taskID] if !exists { return } progress.phase = domain.BuildPhaseComplete progress.percentage = 100 progress.lastUpdate = time.Now() t.emitProgress(taskID) // Clean up delete(t.tasks, taskID) } // GetProgress returns current progress for a build. func (t *BuildProgressTracker) GetProgress(taskID string) (phase domain.BuildPhase, percentage float64, ok bool) { t.mu.RLock() defer t.mu.RUnlock() progress, exists := t.tasks[taskID] if !exists { return "", 0, false } return progress.phase, progress.percentage, true } // updatePercentage estimates completion percentage based on phase and activity. func (t *BuildProgressTracker) updatePercentage(progress *buildProgress) { // Base percentage from phase var basePercent float64 switch progress.phase { case domain.BuildPhaseStarting: basePercent = 5 case domain.BuildPhaseReading: basePercent = 15 case domain.BuildPhaseWriting: basePercent = 50 case domain.BuildPhaseTesting: basePercent = 80 case domain.BuildPhaseCommitting: basePercent = 95 case domain.BuildPhaseComplete: basePercent = 100 } // Add progress within phase based on activity // Tool count adds ~1% per tool (max +10% within phase) toolBonus := float64(progress.toolCount) * 1.0 if toolBonus > 10 { toolBonus = 10 } // Time-based bonus: ~1% per 10 seconds (max +5% within phase) elapsed := time.Since(progress.startTime).Seconds() timeBonus := elapsed / 10.0 if timeBonus > 5 { timeBonus = 5 } // Calculate total but don't exceed next phase threshold progress.percentage = basePercent + toolBonus + timeBonus // Cap to reasonable maximum for current phase maxForPhase := map[domain.BuildPhase]float64{ domain.BuildPhaseStarting: 14, domain.BuildPhaseReading: 49, domain.BuildPhaseWriting: 79, domain.BuildPhaseTesting: 94, domain.BuildPhaseCommitting: 99, domain.BuildPhaseComplete: 100, } if max, ok := maxForPhase[progress.phase]; ok && progress.percentage > max { progress.percentage = max } } // emitProgress publishes a progress event. Must be called with lock held. func (t *BuildProgressTracker) emitProgress(taskID string) { progress, exists := t.tasks[taskID] if !exists || t.streams == nil { return } t.streams.Publish(taskID, port.StreamEvent{ Type: "build.progress", TaskID: taskID, Data: map[string]any{ "phase": string(progress.phase), "percentage": progress.percentage, "tool_count": progress.toolCount, "elapsed_ms": time.Since(progress.startTime).Milliseconds(), }, }) }