package recovery import ( "encoding/json" "fmt" "os" "path/filepath" "sync" "time" ) // Checkpoint represents a saved workflow state type Checkpoint struct { WorkflowID string `json:"workflow_id"` Timestamp time.Time `json:"timestamp"` Stage string `json:"stage"` // e.g., "clone", "plan", "implement", "judge", "merge" CompletedTasks []string `json:"completed_tasks"` PendingTasks []string `json:"pending_tasks"` FailedTasks []string `json:"failed_tasks"` CurrentTaskID string `json:"current_task_id"` CurrentActivityType string `json:"current_activity_type"` Metadata map[string]any `json:"metadata"` } // CheckpointManager manages workflow checkpoints for recovery type CheckpointManager struct { mu sync.RWMutex basePath string interval time.Duration stopChan chan struct{} wg sync.WaitGroup running bool current *Checkpoint lastSave time.Time } // NewCheckpointManager creates a new checkpoint manager func NewCheckpointManager(basePath string, interval time.Duration) *CheckpointManager { return &CheckpointManager{ basePath: basePath, interval: interval, stopChan: make(chan struct{}), current: &Checkpoint{Metadata: make(map[string]any)}, } } // Start starts periodic checkpoint saving func (cm *CheckpointManager) Start(workflowID string) error { cm.mu.Lock() defer cm.mu.Unlock() if cm.running { return fmt.Errorf("checkpoint manager already running") } cm.current.WorkflowID = workflowID cm.current.Timestamp = time.Now() cm.running = true // Start periodic checkpoint save cm.wg.Add(1) go cm.periodicCheckpoint() return nil } // Stop stops checkpoint saving and performs a final save func (cm *CheckpointManager) Stop() error { cm.mu.Lock() defer cm.mu.Unlock() if !cm.running { return nil } cm.running = false close(cm.stopChan) cm.wg.Wait() // Final checkpoint return cm.saveLocked() } // Update updates the current checkpoint func (cm *CheckpointManager) Update(checkpoint *Checkpoint) error { cm.mu.Lock() defer cm.mu.Unlock() checkpoint.Timestamp = time.Now() cm.current = checkpoint return nil } // UpdateStage updates the current stage func (cm *CheckpointManager) UpdateStage(stage string) error { cm.mu.Lock() defer cm.mu.Unlock() cm.current.Stage = stage cm.current.Timestamp = time.Now() return nil } // AddCompletedTask adds a completed task to the checkpoint func (cm *CheckpointManager) AddCompletedTask(taskID string) error { cm.mu.Lock() defer cm.mu.Unlock() cm.current.CompletedTasks = append(cm.current.CompletedTasks, taskID) cm.current.Timestamp = time.Now() // Remove from pending if it's there for i, id := range cm.current.PendingTasks { if id == taskID { cm.current.PendingTasks = append(cm.current.PendingTasks[:i], cm.current.PendingTasks[i+1:]...) break } } return nil } // AddFailedTask adds a failed task to the checkpoint func (cm *CheckpointManager) AddFailedTask(taskID string) error { cm.mu.Lock() defer cm.mu.Unlock() cm.current.FailedTasks = append(cm.current.FailedTasks, taskID) cm.current.Timestamp = time.Now() // Remove from pending if it's there for i, id := range cm.current.PendingTasks { if id == taskID { cm.current.PendingTasks = append(cm.current.PendingTasks[:i], cm.current.PendingTasks[i+1:]...) break } } return nil } // SetPendingTasks sets the list of pending tasks func (cm *CheckpointManager) SetPendingTasks(tasks []string) error { cm.mu.Lock() defer cm.mu.Unlock() cm.current.PendingTasks = tasks cm.current.Timestamp = time.Now() return nil } // GetLatest retrieves the latest checkpoint from disk func (cm *CheckpointManager) GetLatest(workflowID string) (*Checkpoint, error) { cm.mu.RLock() defer cm.mu.RUnlock() path := cm.checkpointPath(workflowID) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } var cp Checkpoint if err := json.Unmarshal(data, &cp); err != nil { return nil, err } return &cp, nil } // periodictCheckpoint periodically saves checkpoints func (cm *CheckpointManager) periodicCheckpoint() { defer cm.wg.Done() ticker := time.NewTicker(cm.interval) defer ticker.Stop() for { select { case <-cm.stopChan: return case <-ticker.C: cm.mu.Lock() if cm.running { _ = cm.saveLocked() } cm.mu.Unlock() } } } // saveLocked saves the current checkpoint to disk (must be called with lock held) func (cm *CheckpointManager) saveLocked() error { if !cm.running || cm.current == nil { return nil } path := cm.checkpointPath(cm.current.WorkflowID) // Create directory if it doesn't exist if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } data, err := json.MarshalIndent(cm.current, "", " ") if err != nil { return err } cm.lastSave = time.Now() return os.WriteFile(path, data, 0644) } // checkpointPath returns the path to a checkpoint file func (cm *CheckpointManager) checkpointPath(workflowID string) string { return filepath.Join(cm.basePath, "checkpoints", fmt.Sprintf("%s.checkpoint.json", workflowID)) } // CleanupCheckpoint removes a checkpoint (after successful completion) func (cm *CheckpointManager) CleanupCheckpoint(workflowID string) error { path := cm.checkpointPath(workflowID) if _, err := os.Stat(path); err == nil { return os.Remove(path) } return nil } // HasCheckpoint checks if a checkpoint exists func (cm *CheckpointManager) HasCheckpoint(workflowID string) (bool, error) { path := cm.checkpointPath(workflowID) _, err := os.Stat(path) if err == nil { return true, nil } if os.IsNotExist(err) { return false, nil } return false, err } // GetCurrent returns the current checkpoint in memory (non-persistent) func (cm *CheckpointManager) GetCurrent() *Checkpoint { cm.mu.RLock() defer cm.mu.RUnlock() if cm.current == nil { return nil } // Return a copy to avoid external mutations cpCopy := *cm.current return &cpCopy }