| 1 | package checkpoint |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "slices" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/diff" |
| 9 | fileenc "reasonix/internal/fileutil/encoding" |
| 10 | ) |
| 11 | |
| 12 | // TurnChangesBudget bounds retained patches and the content processed per turn. |
| 13 | const TurnChangesBudget = 2 << 20 |
| 14 | |
| 15 | // TurnChanges contains only confirmed net changes. Partial and unknown results |
| 16 | // must never be presented as an exhaustive inventory of workspace changes. |
| 17 | type TurnChanges struct { |
| 18 | ID string `json:"id,omitempty"` |
| 19 | Turn int `json:"turn"` |
| 20 | Coverage string `json:"coverage"` |
| 21 | Files []TurnFile `json:"files"` |
| 22 | Added int `json:"added"` |
| 23 | Removed int `json:"removed"` |
| 24 | Reasons []string `json:"reasons"` |
| 25 | } |
| 26 | |
| 27 | type TurnFile struct { |
| 28 | Path string `json:"path"` |
| 29 | Kind diff.Kind `json:"kind"` |
| 30 | Added int `json:"added"` |
| 31 | Removed int `json:"removed"` |
| 32 | Binary bool `json:"binary,omitempty"` |
| 33 | ModeOnly bool `json:"modeOnly,omitempty"` |
| 34 | Uncounted bool `json:"uncounted,omitempty"` |
| 35 | Unavailable string `json:"unavailable,omitempty"` |
| 36 | Patch string `json:"patch,omitempty"` |
| 37 | } |
| 38 | |
| 39 | func emptyTurnChanges(turn int) *TurnChanges { |
| 40 | return &TurnChanges{Turn: turn, Coverage: "unknown", Files: []TurnFile{}, Reasons: []string{}} |
| 41 | } |
| 42 | |
| 43 | func cloneTurnChanges(r *TurnChanges) *TurnChanges { |
| 44 | if r == nil { |
| 45 | return nil |
| 46 | } |
| 47 | out := *r |
| 48 | out.Files = append([]TurnFile{}, r.Files...) |
| 49 | out.Reasons = append([]string{}, r.Reasons...) |
| 50 | return &out |
| 51 | } |
| 52 | |
| 53 | // Summary omits patches from transport events and transcript sidecars. |
| 54 | func (r *TurnChanges) Summary() *TurnChanges { |
| 55 | out := cloneTurnChanges(r) |
| 56 | if out != nil { |
| 57 | for i := range out.Files { |
| 58 | out.Files[i].Patch = "" |
| 59 | } |
| 60 | } |
| 61 | return out |
| 62 | } |
| 63 | |
| 64 | func (r *TurnChanges) gap(reason string) { |
| 65 | r.Coverage = "partial" |
| 66 | if !slices.Contains(r.Reasons, reason) { |
| 67 | r.Reasons = append(r.Reasons, reason) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // TurnChanges returns the frozen result; reading history never touches the |
| 72 | // workspace. A returned value is detached from the store's mutable state. |
| 73 | func (s *Store) TurnChanges(turn int) *TurnChanges { |
| 74 | if s == nil { |
| 75 | return emptyTurnChanges(turn) |
| 76 | } |
| 77 | s.mu.Lock() |
| 78 | defer s.mu.Unlock() |
| 79 | for _, c := range s.all() { |
| 80 | if c.Turn == turn && c.Result != nil { |
| 81 | return cloneTurnChanges(c.Result) |
| 82 | } |
| 83 | } |
| 84 | return emptyTurnChanges(turn) |
| 85 | } |
| 86 | |
| 87 | // FreezeTurnChanges runs while controller turn admission is closed. The shared |
| 88 | // mutation barrier excludes owned writes without waiting on background jobs. |
| 89 | // Untracked/external writes are rejected by their after-image fingerprints. |
| 90 | func (s *Store) FreezeTurnChanges(turn int) *TurnChanges { |
| 91 | if s == nil { |
| 92 | return emptyTurnChanges(turn) |
| 93 | } |
| 94 | owned := s.barrier.TryEnterExclusive() |
| 95 | if owned { |
| 96 | defer s.barrier.ExitExclusive() |
| 97 | } |
| 98 | s.mu.Lock() |
| 99 | defer s.mu.Unlock() |
| 100 | var c *Checkpoint |
| 101 | for _, candidate := range s.all() { |
| 102 | if candidate.Turn == turn { |
| 103 | c = candidate |
| 104 | break |
| 105 | } |
| 106 | } |
| 107 | if c == nil { |
| 108 | return emptyTurnChanges(turn) |
| 109 | } |
| 110 | if c.Result != nil { |
| 111 | return cloneTurnChanges(c.Result) |
| 112 | } |
| 113 | r := emptyTurnChanges(turn) |
| 114 | r.ID = fmt.Sprintf("%d:%d", turn, c.Time.UnixNano()) |
| 115 | r.Coverage = "complete" |
| 116 | for _, gap := range c.CoverageGaps { |
| 117 | r.gap(string(gap.Reason)) |
| 118 | } |
| 119 | if c.Legacy || c.ExpiredFilePayload { |
| 120 | r.gap("snapshot_unavailable") |
| 121 | } |
| 122 | if !owned || len(s.activeWriters) > 0 { |
| 123 | r.gap("active_writer") |
| 124 | } else { |
| 125 | remaining := TurnChangesBudget |
| 126 | for _, f := range c.Files { |
| 127 | s.addTurnFile(r, f, &remaining) |
| 128 | } |
| 129 | } |
| 130 | c.Result = r |
| 131 | if err := s.persist(c); err != nil { |
| 132 | r.gap("result_not_saved") |
| 133 | } |
| 134 | return cloneTurnChanges(r) |
| 135 | } |
| 136 | |
| 137 | func (s *Store) addTurnFile(r *TurnChanges, f FileSnap, remaining *int) { |
| 138 | if f.PayloadExpired || (f.SHA256 != "" && f.Content == nil) { |
| 139 | r.gap("snapshot_unavailable") |
| 140 | return |
| 141 | } |
| 142 | if f.AfterExisted == nil || (*f.AfterExisted && f.AfterSHA256 == "") { |
| 143 | r.gap("ownership_unknown") |
| 144 | return |
| 145 | } |
| 146 | if f.Content != nil && (f.SHA256 == "" || Digest(v3PayloadBytes(f)) != f.SHA256) { |
| 147 | r.gap("snapshot_unavailable") |
| 148 | return |
| 149 | } |
| 150 | before := "" |
| 151 | if f.Content != nil { |
| 152 | before = *f.Content |
| 153 | } |
| 154 | if len(before) >= *remaining { |
| 155 | r.gap("size_limit") |
| 156 | return |
| 157 | } |
| 158 | after, gap, err := CapturePath(f.Path, CaptureOptions{WorkspaceRoot: s.root, ReadContent: true, MaxBytes: int64(*remaining - len(before))}) |
| 159 | if err != nil || gap != nil { |
| 160 | r.gap("file_unavailable") |
| 161 | return |
| 162 | } |
| 163 | if CompareIdentity(after, f.AfterSHA256, f.AfterExisted, f.AfterMode) != "" { |
| 164 | r.gap("external_change") |
| 165 | return |
| 166 | } |
| 167 | existed := f.Content != nil || f.SHA256 != "" |
| 168 | if existed == after.Existed && (!existed || (f.SHA256 == after.SHA256 && f.Mode == after.Mode)) { |
| 169 | return |
| 170 | } |
| 171 | kind := diff.Modify |
| 172 | if !existed { |
| 173 | kind = diff.Create |
| 174 | } else if !after.Existed { |
| 175 | kind = diff.Delete |
| 176 | } |
| 177 | enc, raw := fileenc.Detect(after.Content) |
| 178 | text := string(fileenc.Decode(raw, enc)) |
| 179 | if len(before)+len(text) > *remaining { |
| 180 | r.gap("size_limit") |
| 181 | return |
| 182 | } |
| 183 | change := diff.Build(NormalizeRelPath(s.root, f.Path), before, text, kind) |
| 184 | entry := TurnFile{Path: change.Path, Kind: kind, Binary: change.Binary, ModeOnly: existed && after.Existed && f.SHA256 == after.SHA256 && f.Mode != after.Mode} |
| 185 | if strings.HasPrefix(change.Diff, "(diff omitted:") { |
| 186 | entry.Uncounted, entry.Unavailable = true, "size_limit" |
| 187 | r.gap("size_limit") |
| 188 | } else { |
| 189 | entry.Added, entry.Removed = change.Added, change.Removed |
| 190 | entry.Patch = change.Diff |
| 191 | if len(before)+len(text)+len(entry.Patch) > *remaining { |
| 192 | entry.Patch, entry.Unavailable = "", "size_limit" |
| 193 | } |
| 194 | } |
| 195 | *remaining -= len(before) + len(text) + len(entry.Patch) |
| 196 | r.Files = append(r.Files, entry) |
| 197 | r.Added += entry.Added |
| 198 | r.Removed += entry.Removed |
| 199 | } |
| 200 |