rdev/internal/service/build_progress.go
jordan c59d348040 chore: prepare for composable monorepo template implementation
This commit captures the current state before implementing the composable
monorepo template system. Key changes included:

Infrastructure:
- Add CockroachDB provisioner adapter for database provisioning
- Add Redis provisioner adapter for cache provisioning
- Add build events system with PostgreSQL storage
- Add WebSocket endpoint for real-time build progress

Code agent improvements:
- Fix Claude Code adapter to use default allowed tools instead of dangerously-skip-permissions
- Add context-aware stream closing for cancellation support
- Improve parser tests for edge cases

Build system:
- Add build event constants and metrics
- Remove deprecated git_operations.go (replaced by pod_git_operations.go)
- Add rollback logic for multi-step provisioning operations

Documentation:
- Add composable-monorepo feature documentation
- Add DNS/Cloudflare service documentation
- Update deployment and troubleshooting guides

Cookbooks:
- Add fullstack-app cookbook
- Refactor landing-test with shared library

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:39:28 -07:00

210 lines
5.1 KiB
Go

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(),
},
})
}