package agent import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "strings" ) // RecoverDue discovers failure-only recording pairs after an Agent restart. // Blocked/corrupt entries remain untouched and are returned as errors while // other independent entries continue; none can produce a fabricated result. func (r *RecordingRecovery) RecoverDue(ctx context.Context, grant RecoveryGrant, report RecordingResultReporter) (processedCount int, scanErr error) { defer func() { if scanErr != nil { slog.ErrorContext(ctx, "Recording recovery scan failed", "stage", "recovery_scan_failed", "processed", processedCount, "error", scanErr) } }() slog.InfoContext(ctx, "Recording recovery scan", "stage", "recovery_started") if strings.TrimSpace(r.Root) == "" { return 0, errors.New("recording recovery root is required") } if err := ctx.Err(); err != nil { return 0, err } if err := os.MkdirAll(r.Root, 0700); err != nil { return 0, fmt.Errorf("prepare recording recovery root: %w", err) } if err := checkRecoveryDirectory(r.Root); err != nil { return 0, err } processed := 0 var blocked []error addBlocked := func(dir, stage string, cause error, identity ...any) { digest := sha256.Sum256([]byte(dir)) attrs := append([]any{"stage", stage, "recovery_digest", fmt.Sprintf("%x", digest[:6]), "error", cause}, identity...) slog.ErrorContext(ctx, "Recording recovery entry blocked", attrs...) rel, err := filepath.Rel(r.Root, dir) if err != nil { blocked = append(blocked, fmt.Errorf("recording recovery path unavailable: %w", cause)) return } sum := sha256.Sum256([]byte(filepath.ToSlash(rel))) blocked = append(blocked, fmt.Errorf("recording recovery %s: %w", hex.EncodeToString(sum[:6]), cause)) } walkErr := filepath.WalkDir(r.Root, func(path string, item fs.DirEntry, err error) error { if err != nil { return err } if err := ctx.Err(); err != nil { return err } if item.IsDir() { if path == filepath.Join(r.Root, ".results") { if err := checkRecoveryDirectory(path); err != nil { return err } return filepath.SkipDir // result-only files have their own scanner } return checkRecoveryDirectory(path) } dir := filepath.Dir(path) switch item.Name() { case "recording.wav": if _, err := os.Stat(filepath.Join(dir, "info.json")); err != nil { addBlocked(dir, "recovery_validate", fmt.Errorf("%w: original audio has no call information", ErrRecordingRecoveryIncomplete)) } return nil case "info.json": default: addBlocked(dir, "recovery_validate", fmt.Errorf("%w: unexpected recovery file", ErrRecordingRecoveryIncomplete)) return nil } rel, err := filepath.Rel(r.Root, dir) if err != nil { addBlocked(dir, "recovery_path", err) return nil } parts := strings.Split(filepath.ToSlash(rel), "/") if len(parts) < 2 { addBlocked(dir, "recovery_validate", ErrRecordingRecoveryIncomplete) return nil } bucket, objectKey := parts[0], strings.Join(parts[1:], "/") before, err := r.Load(bucket, objectKey) if err != nil { addBlocked(dir, "recovery_load", err) return nil } after, err := r.Retry(ctx, bucket, objectKey, grant, report) if errors.Is(err, ErrRecordingNotDue) { return nil } if err != nil { addBlocked(dir, "recovery_retry", err, recordingRecoveryAttrs(after)...) return nil } if before.State != "delivered" && after.State == "delivered" { processed++ } return nil }) if len(blocked) > 0 || walkErr != nil { slog.ErrorContext(ctx, "Recording recovery blocked", "stage", "recovery_blocked", "processed", processed, "blocked", len(blocked), "walk_error", walkErr) } else { slog.InfoContext(ctx, "Recording recovery scan complete", "stage", "recovery_completed", "processed", processed) } return processed, errors.Join(append(blocked, walkErr)...) } func checkRecoveryDirectory(path string) error { info, err := os.Stat(path) if err != nil { return err } if !info.IsDir() || info.Mode().Perm()&0077 != 0 { return fmt.Errorf("recording recovery directory must be private (mode=%#o dir=%t)", info.Mode().Perm(), info.IsDir()) } return nil }