| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "log/slog" |
| 5 | "os" |
| 6 | "time" |
| 7 | |
| 8 | "reasonix/internal/agent" |
| 9 | "reasonix/internal/sessioncatalog" |
| 10 | ) |
| 11 | |
| 12 | // startRecoveryGC is intentionally a no-op for physical moves. |
| 13 | // |
| 14 | // Catalog v4 folds recovery lineages into one ordinary list row. Automatic |
| 15 | // startup/upgrade/timer GC must not move JSONL/meta into trash — only explicit |
| 16 | // CleanRecoveryLineage (UI, with preview) and `reasonix sessions cleanup |
| 17 | // --apply` may reclaim covered copies. reclaimRecoveryBranchesIn remains for |
| 18 | // those explicit entry points and focused tests. |
| 19 | func (a *App) startRecoveryGC() {} |
| 20 | |
| 21 | func waitRecoveryGCStartup(done <-chan struct{}, elapsed <-chan time.Time) bool { |
| 22 | select { |
| 23 | case <-elapsed: |
| 24 | return true |
| 25 | case <-done: |
| 26 | return false |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time, grace time.Duration) int { |
| 31 | if grace <= 0 { |
| 32 | grace = agent.RecoveryGCGracePeriod |
| 33 | } |
| 34 | reclaimed := 0 |
| 35 | for _, dir := range dirs { |
| 36 | removed := map[string]bool{} |
| 37 | if catalog := a.sessionCatalog.Load(); catalog != nil { |
| 38 | groups, err := catalog.ListRecoveryGroups(a.bootContext(), dir) |
| 39 | if err != nil { |
| 40 | slog.Warn("desktop: list recovery lineages for GC", "dir", dir, "err", err) |
| 41 | } else { |
| 42 | for _, group := range groups { |
| 43 | for _, path := range a.reclaimAdoptedRecoveryGroup(group, now, grace) { |
| 44 | removed[sessionRuntimeKey(path)] = true |
| 45 | reclaimed++ |
| 46 | } |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | reclaimable, err := agent.ReclaimableRecoveryBranches(dir, now, grace) |
| 51 | if err != nil { |
| 52 | slog.Warn("desktop: scan reclaimable recovery branches", "dir", dir, "err", err) |
| 53 | continue |
| 54 | } |
| 55 | for _, path := range reclaimable { |
| 56 | if removed[sessionRuntimeKey(path)] { |
| 57 | continue |
| 58 | } |
| 59 | // Re-check liveness right before disposal: the scan is a snapshot, |
| 60 | // and the user may have opened the branch since. |
| 61 | if agent.SessionLeaseHeld(path) || a.sessionOpenInAnyTab(path) { |
| 62 | continue |
| 63 | } |
| 64 | // DeleteRecoveryCopy re-proves real parent coverage under removal |
| 65 | // guards. A concurrent continue-edit, missing parent, or busy lease |
| 66 | // skips without moving or permanently deleting anything. |
| 67 | if err := a.DeleteRecoveryCopy(path); err != nil { |
| 68 | slog.Warn("desktop: trash reclaimed recovery branch", "path", path, "err", err) |
| 69 | continue |
| 70 | } |
| 71 | reclaimed++ |
| 72 | } |
| 73 | } |
| 74 | if reclaimed > 0 { |
| 75 | slog.Info("desktop: moved redundant recovery branches to the session trash", |
| 76 | "count", reclaimed, "grace", grace.String()) |
| 77 | } |
| 78 | return reclaimed |
| 79 | } |
| 80 | |
| 81 | // reclaimAdoptedRecoveryGroup compacts an entire legacy recovery chain once a |
| 82 | // canonical leaf has proved it covers every member. Every candidate is still |
| 83 | // revalidated under removal guards immediately before it is moved to trash. |
| 84 | func (a *App) reclaimAdoptedRecoveryGroup(group sessioncatalog.RecoveryGroup, now time.Time, grace time.Duration) []string { |
| 85 | if group.State != "adopted" || group.CanonicalPath == "" || group.ID == "" { |
| 86 | return nil |
| 87 | } |
| 88 | candidates := []string{} |
| 89 | for _, member := range group.Members { |
| 90 | if member.Path == group.CanonicalPath || member.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy { |
| 91 | continue |
| 92 | } |
| 93 | info, err := os.Stat(member.Path) |
| 94 | if err != nil || now.Sub(info.ModTime()) < grace { |
| 95 | continue |
| 96 | } |
| 97 | candidates = append(candidates, member.Path) |
| 98 | } |
| 99 | if len(candidates) == 0 { |
| 100 | return nil |
| 101 | } |
| 102 | defer a.lockRuntimeMutation("gc-recovery-lineage")() |
| 103 | a.sessionRemovalMu.Lock() |
| 104 | defer a.sessionRemovalMu.Unlock() |
| 105 | if a.sessionOpenInAnyTab(group.CanonicalPath) || agent.SessionLeaseHeld(group.CanonicalPath) { |
| 106 | return nil |
| 107 | } |
| 108 | if err := agent.ReparentRecoveryCanonical(group.CanonicalPath, group.ID, group.Directory); err != nil { |
| 109 | return nil |
| 110 | } |
| 111 | moved := []string{} |
| 112 | for _, path := range candidates { |
| 113 | if a.sessionOpenInAnyTab(path) || agent.SessionLeaseHeld(path) { |
| 114 | continue |
| 115 | } |
| 116 | if err := agent.TrashRecoveryBranchCoveredBy(path, group.CanonicalPath, group.Directory); err != nil { |
| 117 | continue |
| 118 | } |
| 119 | moved = append(moved, path) |
| 120 | a.removeSessionCatalogPath(path, "recovery_lineage_gc") |
| 121 | } |
| 122 | return moved |
| 123 | } |
| 124 | |
| 125 | // sessionOpenInAnyTab reports whether any tab's current session is path. |
| 126 | // Lease checks cover live runtimes; this additionally covers tabs that hold a |
| 127 | // session without a lease (read-only channel views). |
| 128 | func (a *App) sessionOpenInAnyTab(path string) bool { |
| 129 | key := sessionRuntimeKey(path) |
| 130 | if key == "" { |
| 131 | return false |
| 132 | } |
| 133 | a.mu.RLock() |
| 134 | defer a.mu.RUnlock() |
| 135 | for _, tab := range a.tabs { |
| 136 | if tab == nil { |
| 137 | continue |
| 138 | } |
| 139 | if sessionRuntimeKey(tab.currentSessionPath()) == key { |
| 140 | return true |
| 141 | } |
| 142 | } |
| 143 | return false |
| 144 | } |
| 145 |