Files
agentic-coding-workflow/internal/jobs/runners/worktree_prune.go
T
q792602257 a2b6d5382e feat(workspace,jobs): emit sync_aborted_on_restart audit + enrich worktree.pruned metadata
- workspace.ResetStuckSyncStatuses now returns affected IDs so app.New can record one workspace.sync_aborted_on_restart audit per reset row
- worktree_prune audit metadata gains workspace_id and path alongside existing branch
- updated fakeRepo + integration test for new ResetStuckSyncStatuses signature
- add TestWorktreePrune_AuditMetadataContainsWorkspaceIDAndPath
2026-05-07 10:14:46 +08:00

142 lines
4.6 KiB
Go

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