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>
210 lines
5.1 KiB
Go
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(),
|
|
},
|
|
})
|
|
}
|