package runners import ( "context" "log/slog" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" ) // WorktreeRow projects the worktree fields needed for prune. OwnerID and // MainPath come from a JOIN performed in the production adapter (workspace // + project), not from the worktrees table directly. type WorktreeRow struct { ID uuid.UUID WorkspaceID uuid.UUID OwnerID uuid.UUID Branch string MainPath string Path string } // WorktreePruneRepo is the minimal repository surface used by this runner. type WorktreePruneRepo interface { ListWorktreesNeedingPruneWarning(ctx context.Context, lastUsedBefore time.Time) ([]WorktreeRow, error) ListWorktreesNeedingPrune(ctx context.Context, lastUsedBefore time.Time) ([]WorktreeRow, error) MarkPruneWarning(ctx context.Context, id uuid.UUID) error TryStartPrune(ctx context.Context, id uuid.UUID) (WorktreeRow, error) FinishPruneSuccess(ctx context.Context, id uuid.UUID) error FinishPruneRollback(ctx context.Context, id uuid.UUID) error } // WorktreePruner abstracts the git CLI surface needed. type WorktreePruner interface { WorktreeRemove(ctx context.Context, mainPath, worktreePath string) error } // WorktreePruneConfig matches jobs.WorktreePruneConfig minus enabled/interval // (managed by scheduler). type WorktreePruneConfig struct { IdleThreshold time.Duration WarningLead time.Duration } // WorktreePrune is the B runner. type WorktreePrune struct { repo WorktreePruneRepo gitr WorktreePruner disp Dispatcher cfg WorktreePruneConfig log *slog.Logger audit audit.Recorder } // NewWorktreePrune constructs the runner. rec is optional; when non-nil the // runner emits a worktree.pruned audit row after each successful prune. func NewWorktreePrune(repo WorktreePruneRepo, gitr WorktreePruner, disp Dispatcher, cfg WorktreePruneConfig, log *slog.Logger, rec audit.Recorder) *WorktreePrune { if log == nil { log = slog.Default() } return &WorktreePrune{repo: repo, gitr: gitr, disp: disp, cfg: cfg, log: log, audit: rec} } // Run executes warning phase then execution phase. func (r *WorktreePrune) Run(ctx context.Context) { r.warningPhase(ctx) r.executionPhase(ctx) } func (r *WorktreePrune) warningPhase(ctx context.Context) { cutoff := time.Now().Add(-(r.cfg.IdleThreshold - r.cfg.WarningLead)) rows, err := r.repo.ListWorktreesNeedingPruneWarning(ctx, cutoff) if err != nil { r.log.Error("worktree_prune.list_warning", "err", err.Error()) return } for _, w := range rows { if err := r.repo.MarkPruneWarning(ctx, w.ID); err != nil { r.log.Error("worktree_prune.mark_warning", "worktree_id", w.ID, "err", err.Error()) continue } if derr := r.disp.Dispatch(ctx, notify.Message{ UserID: w.OwnerID, Topic: "worktree.prune_pending", Severity: notify.SeverityWarning, Title: "Worktree will be pruned soon", Body: "Branch " + w.Branch + " has been idle and will be removed in 3 days unless used.", Metadata: map[string]any{ "worktree_id": w.ID.String(), "branch": w.Branch, }, CreatedAt: time.Now().UTC(), }); derr != nil { r.log.Error("worktree_prune.dispatch", "worktree_id", w.ID, "err", derr.Error()) } } } func (r *WorktreePrune) executionPhase(ctx context.Context) { cutoff := time.Now().Add(-r.cfg.IdleThreshold) rows, err := r.repo.ListWorktreesNeedingPrune(ctx, cutoff) if err != nil { r.log.Error("worktree_prune.list_prune", "err", err.Error()) return } for _, w := range rows { started, err := r.repo.TryStartPrune(ctx, w.ID) if err != nil { // CAS lost (idle → other status), skip continue } if rmErr := r.gitr.WorktreeRemove(ctx, started.MainPath, started.Path); rmErr != nil { r.log.Error("worktree_prune.remove_failed", "worktree_id", w.ID, "err", rmErr.Error()) if rbErr := r.repo.FinishPruneRollback(ctx, w.ID); rbErr != nil { r.log.Error("worktree_prune.rollback", "worktree_id", w.ID, "err", rbErr.Error()) } continue } if err := r.repo.FinishPruneSuccess(ctx, w.ID); err != nil { r.log.Error("worktree_prune.finish_success", "worktree_id", w.ID, "err", err.Error()) continue } if r.audit != nil { if rerr := r.audit.Record(ctx, audit.Entry{ Action: "worktree.pruned", TargetType: "worktree", TargetID: w.ID.String(), Metadata: map[string]any{ "workspace_id": w.WorkspaceID.String(), "branch": w.Branch, "path": w.Path, }, }); rerr != nil { r.log.Warn("worktree_prune.audit_failed", "worktree_id", w.ID, "err", rerr.Error()) } } } }