| 1 | // Package checkpoint is reasonix's snapshot-based edit safety net. Before a writer |
| 2 | // tool changes a file, the agent records the file's pre-edit content here, keyed |
| 3 | // to the current user turn; a frontend can then rewind the workspace (and, via the |
| 4 | // controller, the conversation) to an earlier turn. |
| 5 | // |
| 6 | // It is deliberately git-free (like Claude Code's rewind): snapshots live beside |
| 7 | // the session, never touch the user's git, and work in a non-git directory. Only |
| 8 | // edit-tool changes are tracked — bash side effects are not (a shell command's |
| 9 | // targets can't be known in advance), which is why the capture hook only fires for |
| 10 | // tools that can Preview their change. |
| 11 | // |
| 12 | // Schema v2 adds blobs and verified restore; v3 stores new preimages in per-turn |
| 13 | // directories while retaining legacy blob and transaction compatibility. |
| 14 | package checkpoint |
| 15 | |
| 16 | import ( |
| 17 | "encoding/json" |
| 18 | "errors" |
| 19 | "fmt" |
| 20 | "log/slog" |
| 21 | "os" |
| 22 | "path/filepath" |
| 23 | "sort" |
| 24 | "strings" |
| 25 | "sync" |
| 26 | "time" |
| 27 | |
| 28 | "reasonix/internal/diff" |
| 29 | "reasonix/internal/fileutil" |
| 30 | fileenc "reasonix/internal/fileutil/encoding" |
| 31 | ) |
| 32 | |
| 33 | // FileSnap is one file's state at the moment it was first touched in a turn. |
| 34 | // Content == nil means the file did not exist then, so a restore deletes it. |
| 35 | // |
| 36 | // v2 fields (Mode, SHA256, BlobRef, After*, CaptureSource) are omitempty so v1 |
| 37 | // readers ignore them and old JSON still unmarshals cleanly. |
| 38 | type FileSnap struct { |
| 39 | Path string `json:"path"` |
| 40 | Content *string `json:"content"` |
| 41 | Encoding *fileenc.Kind `json:"encoding,omitempty"` |
| 42 | Mode uint32 `json:"mode,omitempty"` |
| 43 | SHA256 string `json:"sha256,omitempty"` |
| 44 | BlobRef string `json:"blobRef,omitempty"` |
| 45 | CaptureSource CaptureSource `json:"captureSource,omitempty"` |
| 46 | AfterSHA256 string `json:"afterSha256,omitempty"` |
| 47 | AfterExisted *bool `json:"afterExisted,omitempty"` |
| 48 | AfterMode uint32 `json:"afterMode,omitempty"` |
| 49 | // PayloadExpired marks that the blob was GC'd while metadata remains. |
| 50 | PayloadExpired bool `json:"payloadExpired,omitempty"` |
| 51 | rawContent []byte |
| 52 | } |
| 53 | |
| 54 | // FileState is the earliest pre-edit state recorded for a file in this |
| 55 | // session. Content == nil means the file did not exist before the session's |
| 56 | // first tracked edit. |
| 57 | type FileState struct { |
| 58 | Content *string |
| 59 | Encoding *fileenc.Kind |
| 60 | Mode uint32 |
| 61 | SHA256 string |
| 62 | BlobRef string |
| 63 | Owned bool // true when session has after-fingerprint ownership |
| 64 | } |
| 65 | |
| 66 | // Checkpoint anchors the pre-edit state of every distinct file touched during one |
| 67 | // user turn. MsgIndex is len(Session.Messages) at the turn's start — the |
| 68 | // conversation-rewind boundary — persisted so a resumed session can rewind the |
| 69 | // conversation and fork, not just the code. |
| 70 | type Checkpoint struct { |
| 71 | Recovery *RecoveryIdentity `json:"recovery,omitempty"` |
| 72 | // Result is a bounded, immutable view of this turn's confirmed net changes. |
| 73 | // Missing on older checkpoints; never reconstructed from today's workspace. |
| 74 | Result *TurnChanges `json:"result,omitempty"` |
| 75 | SchemaVersion int `json:"schemaVersion,omitempty"` |
| 76 | Turn int `json:"turn"` |
| 77 | Time time.Time `json:"time"` |
| 78 | Prompt string `json:"prompt"` |
| 79 | MsgIndex int `json:"msgIndex"` |
| 80 | SessionID string `json:"sessionId,omitempty"` |
| 81 | Files []FileSnap `json:"files"` |
| 82 | Coverage Coverage `json:"coverage,omitempty"` |
| 83 | CoverageGaps []CoverageGap `json:"coverageGaps,omitempty"` |
| 84 | ActiveWriters []ActiveWriter `json:"activeWriters,omitempty"` |
| 85 | LastMutationSeq int64 `json:"lastMutationSeq,omitempty"` |
| 86 | SessionRevision int64 `json:"sessionRevision,omitempty"` |
| 87 | Legacy bool `json:"legacy,omitempty"` |
| 88 | ExpiredFilePayload bool `json:"expiredFilePayload,omitempty"` |
| 89 | } |
| 90 | |
| 91 | // revisions returns FileRevision views of Files. |
| 92 | func (c *Checkpoint) revisions() []FileRevision { |
| 93 | if c == nil { |
| 94 | return nil |
| 95 | } |
| 96 | out := make([]FileRevision, 0, len(c.Files)) |
| 97 | for _, f := range c.Files { |
| 98 | rev := FileRevision{ |
| 99 | Path: f.Path, |
| 100 | Existed: f.Content != nil || f.BlobRef != "" || f.SHA256 != "", |
| 101 | Mode: f.Mode, |
| 102 | Encoding: f.Encoding, |
| 103 | SHA256: f.SHA256, |
| 104 | BlobRef: f.BlobRef, |
| 105 | CaptureSource: f.CaptureSource, |
| 106 | AfterSHA256: f.AfterSHA256, |
| 107 | AfterExisted: f.AfterExisted, |
| 108 | AfterMode: f.AfterMode, |
| 109 | Content: f.Content, |
| 110 | } |
| 111 | // v1 create: Content nil and no blob → did not exist. |
| 112 | if f.Content == nil && f.BlobRef == "" && f.SHA256 == "" { |
| 113 | rev.Existed = false |
| 114 | } |
| 115 | if f.Content != nil { |
| 116 | rev.Existed = true |
| 117 | if rev.SHA256 == "" { |
| 118 | rev.SHA256 = Digest([]byte(*f.Content)) |
| 119 | } |
| 120 | } |
| 121 | if f.PayloadExpired { |
| 122 | rev.BlobRef = "" |
| 123 | rev.Content = nil |
| 124 | } |
| 125 | out = append(out, rev) |
| 126 | } |
| 127 | return out |
| 128 | } |
| 129 | |
| 130 | // Meta is the picker-facing summary of a checkpoint (no file contents). |
| 131 | type Meta struct { |
| 132 | Turn int |
| 133 | Time time.Time |
| 134 | Prompt string |
| 135 | Paths []string |
| 136 | Coverage Coverage |
| 137 | CoverageGaps []CoverageGap |
| 138 | ExpiredFilePayload bool |
| 139 | ActiveWriters []ActiveWriter |
| 140 | Legacy bool |
| 141 | CanUndoFiles bool |
| 142 | DisabledReason string |
| 143 | } |
| 144 | |
| 145 | // Store holds a session's checkpoints in memory and, when dir is set, persists one |
| 146 | // JSON file per turn under it (cheap delete, corruption-isolated). All methods are |
| 147 | // safe for concurrent use — the agent snapshots from tool goroutines. |
| 148 | type Store struct { |
| 149 | dir string // <session>.ckpt/, or "" for in-memory only |
| 150 | root string // workspace root, for restore path-escape guards |
| 151 | |
| 152 | mu sync.Mutex |
| 153 | done []*Checkpoint // finalized turns |
| 154 | cur *Checkpoint // the active turn's checkpoint |
| 155 | seen map[string]bool // paths already snapshotted this turn (dedup) |
| 156 | |
| 157 | blobs *BlobStore |
| 158 | barrier *MutationBarrier |
| 159 | activeWriters []ActiveWriter |
| 160 | plans map[string]preparedPlan |
| 161 | lastUndo *TransactionManifest |
| 162 | sessionID string |
| 163 | mutationSeq int64 |
| 164 | retainN int |
| 165 | blobQuota int64 |
| 166 | // protectTurns prevents GC of these turn payloads (active tx / last undo). |
| 167 | protectTurns map[int]bool |
| 168 | } |
| 169 | |
| 170 | // New returns a store for the given checkpoint dir and workspace root, loading any |
| 171 | // checkpoints already persisted under dir. A "" dir disables persistence (the |
| 172 | // store still works in memory for the session). Options override the retention |
| 173 | // defaults. |
| 174 | func New(dir, root string, opts ...Option) *Store { |
| 175 | s := &Store{ |
| 176 | dir: dir, |
| 177 | root: root, |
| 178 | seen: map[string]bool{}, |
| 179 | barrier: NewMutationBarrier(), |
| 180 | plans: map[string]preparedPlan{}, |
| 181 | retainN: DefaultRetainCheckpoints, |
| 182 | blobQuota: DefaultBlobQuotaBytes, |
| 183 | protectTurns: map[int]bool{}, |
| 184 | } |
| 185 | // Applied before the load/GC below: that startup prune reads retainN and |
| 186 | // blobQuota (gcLocked -> pruneV3TurnsLocked), so a configured retention must |
| 187 | // already be in place or reopening a session would trim using the defaults. |
| 188 | for _, opt := range opts { |
| 189 | if opt != nil { |
| 190 | opt(s) |
| 191 | } |
| 192 | } |
| 193 | if dir != "" { |
| 194 | s.blobs = NewBlobStore(filepath.Join(dir, "blobs")) |
| 195 | s.load() |
| 196 | s.RecoverTransactions() |
| 197 | s.mu.Lock() |
| 198 | s.gcLocked() |
| 199 | s.mu.Unlock() |
| 200 | } |
| 201 | return s |
| 202 | } |
| 203 | |
| 204 | // Barrier returns the workspace mutation barrier for this store. |
| 205 | func (s *Store) Barrier() *MutationBarrier { |
| 206 | if s == nil { |
| 207 | return nil |
| 208 | } |
| 209 | return s.barrier |
| 210 | } |
| 211 | |
| 212 | // Blobs returns the content-addressed blob store (may be nil for in-memory). |
| 213 | func (s *Store) Blobs() *BlobStore { |
| 214 | if s == nil { |
| 215 | return nil |
| 216 | } |
| 217 | return s.blobs |
| 218 | } |
| 219 | |
| 220 | // SetSessionID records the owning session id on new checkpoints. |
| 221 | func (s *Store) SetSessionID(id string) { |
| 222 | if s == nil { |
| 223 | return |
| 224 | } |
| 225 | s.mu.Lock() |
| 226 | s.sessionID = id |
| 227 | s.mu.Unlock() |
| 228 | } |
| 229 | |
| 230 | // SetActiveWriters updates the active writer list mirrored into the current checkpoint. |
| 231 | func (s *Store) SetActiveWriters(writers []ActiveWriter) { |
| 232 | if s == nil { |
| 233 | return |
| 234 | } |
| 235 | s.mu.Lock() |
| 236 | defer s.mu.Unlock() |
| 237 | s.activeWriters = append([]ActiveWriter(nil), writers...) |
| 238 | if s.cur != nil { |
| 239 | s.cur.ActiveWriters = append([]ActiveWriter(nil), writers...) |
| 240 | s.recomputeCoverageLocked(s.cur) |
| 241 | s.persistBestEffort(s.cur) |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | func (s *Store) activeWriterConflicts() []RewindConflict { |
| 246 | if s == nil { |
| 247 | return nil |
| 248 | } |
| 249 | s.mu.Lock() |
| 250 | defer s.mu.Unlock() |
| 251 | conflicts := make([]RewindConflict, 0, len(s.activeWriters)) |
| 252 | for range s.activeWriters { |
| 253 | conflicts = append(conflicts, RewindConflict{Reason: ConflictBusyWriter}) |
| 254 | } |
| 255 | return conflicts |
| 256 | } |
| 257 | |
| 258 | // LastUndoTransactionID returns the committed transaction id available for undo. |
| 259 | func (s *Store) LastUndoTransactionID() string { |
| 260 | if s == nil { |
| 261 | return "" |
| 262 | } |
| 263 | s.mu.Lock() |
| 264 | defer s.mu.Unlock() |
| 265 | if s.lastUndo == nil || s.lastUndo.State != TxCommitted { |
| 266 | return "" |
| 267 | } |
| 268 | return s.lastUndo.ID |
| 269 | } |
| 270 | |
| 271 | // InvalidateUndo clears the last undo slot (new turn / new mutation / new rewind). |
| 272 | func (s *Store) InvalidateUndo() { |
| 273 | if s == nil { |
| 274 | return |
| 275 | } |
| 276 | s.mu.Lock() |
| 277 | s.lastUndo = nil |
| 278 | s.mu.Unlock() |
| 279 | } |
| 280 | |
| 281 | // Begin opens a checkpoint for a new user turn, finalizing the previous one. The |
| 282 | // prompt labels it in the picker; msgIndex is the conversation-rewind boundary. |
| 283 | func (s *Store) Begin(turn int, prompt string, msgIndex int) { |
| 284 | s.mu.Lock() |
| 285 | defer s.mu.Unlock() |
| 286 | if s.cur != nil { |
| 287 | s.recomputeCoverageLocked(s.cur) |
| 288 | s.done = append(s.done, s.cur) |
| 289 | } |
| 290 | s.cur = &Checkpoint{ |
| 291 | SchemaVersion: SchemaV3, |
| 292 | Turn: turn, |
| 293 | Time: time.Now(), |
| 294 | Prompt: prompt, |
| 295 | MsgIndex: msgIndex, |
| 296 | SessionID: s.sessionID, |
| 297 | Coverage: CoverageNone, |
| 298 | } |
| 299 | s.seen = map[string]bool{} |
| 300 | s.lastUndo = nil // new turn invalidates undo |
| 301 | s.persistBestEffort(s.cur) |
| 302 | s.gcLocked() |
| 303 | } |
| 304 | |
| 305 | // Bounds returns turn → MsgIndex over all checkpoints (persisted + current), so |
| 306 | // the controller can rebuild its conversation-rewind boundaries after loading a |
| 307 | // resumed session's checkpoints from disk. |
| 308 | func (s *Store) Bounds() map[int]int { |
| 309 | s.mu.Lock() |
| 310 | defer s.mu.Unlock() |
| 311 | m := make(map[int]int, len(s.done)) |
| 312 | for _, c := range s.done { |
| 313 | m[c.Turn] = c.MsgIndex |
| 314 | } |
| 315 | if s.cur != nil { |
| 316 | m[s.cur.Turn] = s.cur.MsgIndex |
| 317 | } |
| 318 | return m |
| 319 | } |
| 320 | |
| 321 | // Snapshot records the pre-edit state of the file a writer is about to change. |
| 322 | // Only the first touch of a path in the current turn is kept (that is its |
| 323 | // turn-start content). A no-op before the first Begin. |
| 324 | // |
| 325 | // Legacy entry point used by SetPreEditHook; prefer CaptureBefore / MutationObserver. |
| 326 | func (s *Store) Snapshot(ch diff.Change) { |
| 327 | s.CaptureBeforeFromChange(ch, CaptureBeforeOpts{Source: CapturePreviewer}) |
| 328 | } |
| 329 | |
| 330 | // CaptureBeforeFromChange records a preimage using a Previewer change when possible. |
| 331 | func (s *Store) CaptureBeforeFromChange(ch diff.Change, opts CaptureBeforeOpts) { |
| 332 | if ch.Path == "" { |
| 333 | return |
| 334 | } |
| 335 | pathKey := NormalizeRelPath(s.root, ch.Path) |
| 336 | if opts.Source == "" { |
| 337 | opts.Source = CapturePreviewer |
| 338 | } |
| 339 | |
| 340 | var enc *fileenc.Kind |
| 341 | var mode uint32 |
| 342 | var sha string |
| 343 | var content *string |
| 344 | var rawContent []byte |
| 345 | |
| 346 | if ch.Kind != diff.Create { |
| 347 | old := ch.OldText |
| 348 | content = &old |
| 349 | sha = Digest([]byte(old)) |
| 350 | // Detect encoding from disk for non-UTF8 restore fidelity. |
| 351 | enc = s.detectEncoding(ch.Path) |
| 352 | // Capture mode via Lstat; also detect symlink/hardlink gaps. |
| 353 | fp, gap, err := CapturePath(ch.Path, CaptureOptions{ |
| 354 | WorkspaceRoot: s.root, |
| 355 | ReadContent: false, |
| 356 | }) |
| 357 | if gap != nil { |
| 358 | s.RecordGap(*gap) |
| 359 | } |
| 360 | if err == nil { |
| 361 | mode = fp.Mode |
| 362 | } |
| 363 | // Prefer disk bytes when available for exact restore (encoding). |
| 364 | if abs, aerr := safePath(s.root, ch.Path); aerr == nil { |
| 365 | if raw, rerr := secureReadFile(s.root, abs); rerr == nil { |
| 366 | sha = Digest(raw) |
| 367 | rawContent = append([]byte(nil), raw...) |
| 368 | e, detected := fileenc.Detect(raw) |
| 369 | decoded := string(fileenc.Decode(detected, e)) |
| 370 | content = &decoded |
| 371 | enc = &e |
| 372 | } |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | s.mu.Lock() |
| 377 | defer s.mu.Unlock() |
| 378 | if s.cur == nil || s.seen[pathKey] { |
| 379 | return |
| 380 | } |
| 381 | s.seen[pathKey] = true |
| 382 | snap := FileSnap{ |
| 383 | Path: ch.Path, |
| 384 | Content: content, |
| 385 | Encoding: enc, |
| 386 | Mode: mode, |
| 387 | SHA256: sha, |
| 388 | CaptureSource: opts.Source, |
| 389 | rawContent: rawContent, |
| 390 | } |
| 391 | // Keep inline content in memory so FileState and the legacy restore API can |
| 392 | // distinguish existing files from the nil-content deletion sentinel. |
| 393 | s.cur.Files = append(s.cur.Files, snap) |
| 394 | if s.cur.SchemaVersion < SchemaV3 { |
| 395 | s.cur.SchemaVersion = SchemaV3 |
| 396 | } |
| 397 | s.recomputeCoverageLocked(s.cur) |
| 398 | s.persistBestEffort(s.cur) |
| 399 | s.gcLocked() |
| 400 | } |
| 401 | |
| 402 | // CaptureBefore records a preimage by Lstat+read of path. |
| 403 | func (s *Store) CaptureBefore(path string, opts CaptureBeforeOpts) { |
| 404 | if path == "" { |
| 405 | return |
| 406 | } |
| 407 | pathKey := NormalizeRelPath(s.root, path) |
| 408 | if opts.Source == "" { |
| 409 | opts.Source = CaptureBeforeMutation |
| 410 | } |
| 411 | fp, gap, _ := CapturePath(path, CaptureOptions{ |
| 412 | WorkspaceRoot: s.root, |
| 413 | ReadContent: true, |
| 414 | }) |
| 415 | if gap != nil { |
| 416 | s.RecordGap(*gap) |
| 417 | } |
| 418 | s.mu.Lock() |
| 419 | defer s.mu.Unlock() |
| 420 | if s.cur == nil || s.seen[pathKey] { |
| 421 | return |
| 422 | } |
| 423 | s.seen[pathKey] = true |
| 424 | snap := FileSnap{ |
| 425 | Path: path, |
| 426 | CaptureSource: opts.Source, |
| 427 | } |
| 428 | if fp.Existed { |
| 429 | snap.Mode = fp.Mode |
| 430 | snap.SHA256 = fp.SHA256 |
| 431 | snap.rawContent = append([]byte(nil), fp.Content...) |
| 432 | // Decoded text for API compat (FileState / legacy RestoreCode path). |
| 433 | enc, raw := fileenc.Detect(fp.Content) |
| 434 | text := string(fileenc.Decode(raw, enc)) |
| 435 | snap.Content = &text |
| 436 | snap.Encoding = &enc |
| 437 | if snap.SHA256 == "" { |
| 438 | snap.SHA256 = Digest(fp.Content) |
| 439 | } |
| 440 | } |
| 441 | // Content nil + no blob → create (did not exist) |
| 442 | s.cur.Files = append(s.cur.Files, snap) |
| 443 | if s.cur.SchemaVersion < SchemaV3 { |
| 444 | s.cur.SchemaVersion = SchemaV3 |
| 445 | } |
| 446 | s.recomputeCoverageLocked(s.cur) |
| 447 | s.persistBestEffort(s.cur) |
| 448 | s.gcLocked() |
| 449 | } |
| 450 | |
| 451 | // RecordGap appends a coverage gap to the current checkpoint. |
| 452 | func (s *Store) RecordGap(gap CoverageGap) { |
| 453 | if s == nil { |
| 454 | return |
| 455 | } |
| 456 | s.mu.Lock() |
| 457 | defer s.mu.Unlock() |
| 458 | if s.cur == nil { |
| 459 | return |
| 460 | } |
| 461 | // Dedupe identical gaps. |
| 462 | for _, g := range s.cur.CoverageGaps { |
| 463 | if g.Reason == gap.Reason && g.Detail == gap.Detail && g.Tool == gap.Tool && g.Path == gap.Path { |
| 464 | return |
| 465 | } |
| 466 | } |
| 467 | s.cur.CoverageGaps = append(s.cur.CoverageGaps, gap) |
| 468 | s.recomputeCoverageLocked(s.cur) |
| 469 | s.persistBestEffort(s.cur) |
| 470 | } |
| 471 | |
| 472 | func (s *Store) recomputeCoverageLocked(c *Checkpoint) { |
| 473 | if c == nil { |
| 474 | return |
| 475 | } |
| 476 | if c.Legacy || c.SchemaVersion < SchemaV2 { |
| 477 | c.Coverage = CoverageLegacy |
| 478 | return |
| 479 | } |
| 480 | if c.ExpiredFilePayload { |
| 481 | c.Coverage = CoveragePartial |
| 482 | return |
| 483 | } |
| 484 | hasFiles := len(c.Files) > 0 |
| 485 | hasGaps := len(c.CoverageGaps) > 0 |
| 486 | switch { |
| 487 | case !hasFiles && !hasGaps: |
| 488 | c.Coverage = CoverageNone |
| 489 | case !hasFiles && hasGaps: |
| 490 | c.Coverage = CoverageNone |
| 491 | case hasFiles && hasGaps: |
| 492 | c.Coverage = CoveragePartial |
| 493 | default: |
| 494 | c.Coverage = CoverageComplete |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | func (s *Store) detectEncoding(p string) *fileenc.Kind { |
| 499 | abs, err := safePath(s.root, p) |
| 500 | if err != nil { |
| 501 | return nil |
| 502 | } |
| 503 | b, err := secureReadFile(s.root, abs) |
| 504 | if err != nil { |
| 505 | return nil |
| 506 | } |
| 507 | enc, _ := fileenc.Detect(b) |
| 508 | return &enc |
| 509 | } |
| 510 | |
| 511 | func (s *Store) expiredDir() string { |
| 512 | return filepath.Join(s.dir, "expired") |
| 513 | } |
| 514 | |
| 515 | func (s *Store) checkpointPath(c *Checkpoint) string { |
| 516 | dir := s.dir |
| 517 | if c != nil && c.ExpiredFilePayload { |
| 518 | dir = s.expiredDir() |
| 519 | } |
| 520 | return filepath.Join(dir, fmt.Sprintf("turn-%d.json", c.Turn)) |
| 521 | } |
| 522 | |
| 523 | func (s *Store) persist(c *Checkpoint) error { |
| 524 | if s.dir == "" || c == nil { |
| 525 | return nil |
| 526 | } |
| 527 | if c.SchemaVersion >= SchemaV3 { |
| 528 | return s.persistV3(c) |
| 529 | } |
| 530 | // Keep inline Content even when BlobRef is present. Previous Reasonix builds |
| 531 | // ignore BlobRef and interpret nil Content as "the file did not exist"; |
| 532 | // omitting it would make an older concurrently running binary delete files. |
| 533 | wire := *c |
| 534 | wire.Files = make([]FileSnap, len(c.Files)) |
| 535 | copy(wire.Files, c.Files) |
| 536 | b, err := json.Marshal(&wire) |
| 537 | if err != nil { |
| 538 | return err |
| 539 | } |
| 540 | path := s.checkpointPath(c) |
| 541 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 542 | return err |
| 543 | } |
| 544 | if err := fileutil.AtomicWriteFileStrict(path, b, 0o644); err != nil { |
| 545 | return err |
| 546 | } |
| 547 | return nil |
| 548 | } |
| 549 | |
| 550 | func (s *Store) persistBestEffort(c *Checkpoint) { |
| 551 | if err := s.persist(c); err != nil { |
| 552 | slog.Warn("checkpoint: persist failed", "turn", c.Turn, "err", err) |
| 553 | } |
| 554 | } |
| 555 | |
| 556 | // gcLocked removes old v3 turn directories and retains the legacy v1/v2 blob |
| 557 | // quota policy for checkpoints written by older releases. |
| 558 | // Caller holds s.mu. |
| 559 | func (s *Store) gcLocked() { |
| 560 | s.pruneV3TurnsLocked() |
| 561 | if s.blobs == nil || s.retainN <= 0 { |
| 562 | return |
| 563 | } |
| 564 | // Collect recoverable checkpoints (have file payloads) oldest first. |
| 565 | all := s.all() |
| 566 | type entry struct { |
| 567 | c *Checkpoint |
| 568 | } |
| 569 | var withFiles []entry |
| 570 | for _, c := range all { |
| 571 | if c.SchemaVersion < SchemaV3 && len(c.Files) > 0 { |
| 572 | withFiles = append(withFiles, entry{c: c}) |
| 573 | } |
| 574 | } |
| 575 | // Expire payloads for all but the newest retainN. |
| 576 | if len(withFiles) > s.retainN { |
| 577 | expiredAny := false |
| 578 | for _, e := range withFiles[:len(withFiles)-s.retainN] { |
| 579 | if s.protectTurns[e.c.Turn] { |
| 580 | continue |
| 581 | } |
| 582 | if err := s.expirePayloadLocked(e.c); err != nil { |
| 583 | slog.Warn("checkpoint: expire payload failed", "turn", e.c.Turn, "err", err) |
| 584 | continue |
| 585 | } |
| 586 | expiredAny = true |
| 587 | } |
| 588 | if expiredAny { |
| 589 | s.pruneBlobsLocked() |
| 590 | } |
| 591 | } |
| 592 | // Blob quota. |
| 593 | size, err := s.blobs.Size() |
| 594 | if err != nil || size <= s.blobQuota { |
| 595 | return |
| 596 | } |
| 597 | for _, e := range withFiles { |
| 598 | if size <= s.blobQuota { |
| 599 | break |
| 600 | } |
| 601 | if s.protectTurns[e.c.Turn] || e.c.ExpiredFilePayload { |
| 602 | continue |
| 603 | } |
| 604 | // Rough: expire and recompute size. |
| 605 | if err := s.expirePayloadLocked(e.c); err != nil { |
| 606 | slog.Warn("checkpoint: expire payload failed", "turn", e.c.Turn, "err", err) |
| 607 | continue |
| 608 | } |
| 609 | s.pruneBlobsLocked() |
| 610 | size, _ = s.blobs.Size() |
| 611 | } |
| 612 | } |
| 613 | |
| 614 | // pruneBlobsLocked performs mark-and-sweep after checkpoint metadata has been |
| 615 | // persisted. Transaction manifests and the current undo slot also keep their |
| 616 | // forward/restore payloads live. Caller holds s.mu. |
| 617 | func (s *Store) pruneBlobsLocked() { |
| 618 | if s.blobs == nil { |
| 619 | return |
| 620 | } |
| 621 | live := map[string]struct{}{} |
| 622 | mark := func(ref string) { |
| 623 | if validBlobRef(ref) { |
| 624 | live[ref] = struct{}{} |
| 625 | } |
| 626 | } |
| 627 | for _, c := range s.all() { |
| 628 | for _, f := range c.Files { |
| 629 | mark(f.BlobRef) |
| 630 | } |
| 631 | } |
| 632 | if s.lastUndo != nil { |
| 633 | for _, target := range s.lastUndo.Targets { |
| 634 | mark(target.RestoreBlob) |
| 635 | mark(target.ForwardBlob) |
| 636 | } |
| 637 | } |
| 638 | if s.dir != "" { |
| 639 | entries, _ := os.ReadDir(s.txDir()) |
| 640 | for _, entry := range entries { |
| 641 | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { |
| 642 | continue |
| 643 | } |
| 644 | var tx TransactionManifest |
| 645 | if readJSONFile(filepath.Join(s.txDir(), entry.Name()), &tx) != nil { |
| 646 | continue |
| 647 | } |
| 648 | for _, target := range tx.Targets { |
| 649 | mark(target.RestoreBlob) |
| 650 | mark(target.ForwardBlob) |
| 651 | } |
| 652 | } |
| 653 | } |
| 654 | if err := s.blobs.Prune(live); err != nil { |
| 655 | slog.Warn("checkpoint: prune blobs", "err", err) |
| 656 | } |
| 657 | } |
| 658 | |
| 659 | // NextTurn returns the turn number a new checkpoint should take: one past the |
| 660 | // highest existing turn (0 when empty), so a resumed session keeps numbering |
| 661 | // without colliding with checkpoints loaded from disk. |
| 662 | func (s *Store) NextTurn() int { |
| 663 | s.mu.Lock() |
| 664 | defer s.mu.Unlock() |
| 665 | next := 0 |
| 666 | for _, c := range s.done { |
| 667 | if c.Turn >= next { |
| 668 | next = c.Turn + 1 |
| 669 | } |
| 670 | } |
| 671 | if s.cur != nil && s.cur.Turn >= next { |
| 672 | next = s.cur.Turn + 1 |
| 673 | } |
| 674 | return next |
| 675 | } |
| 676 | |
| 677 | // List returns every checkpoint's metadata, oldest turn first. |
| 678 | func (s *Store) List() []Meta { |
| 679 | s.mu.Lock() |
| 680 | defer s.mu.Unlock() |
| 681 | out := make([]Meta, 0, len(s.done)+1) |
| 682 | for _, c := range s.all() { |
| 683 | paths := make([]string, len(c.Files)) |
| 684 | for i, f := range c.Files { |
| 685 | paths[i] = f.Path |
| 686 | } |
| 687 | meta := Meta{ |
| 688 | Turn: c.Turn, |
| 689 | Time: c.Time, |
| 690 | Prompt: c.Prompt, |
| 691 | Paths: paths, |
| 692 | Coverage: c.Coverage, |
| 693 | CoverageGaps: append([]CoverageGap(nil), c.CoverageGaps...), |
| 694 | ExpiredFilePayload: c.ExpiredFilePayload, |
| 695 | ActiveWriters: append([]ActiveWriter(nil), c.ActiveWriters...), |
| 696 | Legacy: c.Legacy || c.Coverage == CoverageLegacy, |
| 697 | } |
| 698 | switch { |
| 699 | case meta.Legacy: |
| 700 | meta.CanUndoFiles = false |
| 701 | meta.DisabledReason = "legacy checkpoint cannot verify later manual edits" |
| 702 | case meta.ExpiredFilePayload: |
| 703 | meta.CanUndoFiles = false |
| 704 | meta.DisabledReason = "file recovery payload expired" |
| 705 | case meta.Coverage == CoverageNone: |
| 706 | meta.CanUndoFiles = false |
| 707 | case meta.Coverage == CoveragePartial: |
| 708 | meta.CanUndoFiles = len(paths) > 0 |
| 709 | default: |
| 710 | meta.CanUndoFiles = len(paths) > 0 |
| 711 | } |
| 712 | out = append(out, meta) |
| 713 | } |
| 714 | return out |
| 715 | } |
| 716 | |
| 717 | // FileState returns the earliest pre-edit state recorded for p across the |
| 718 | // session. Paths are compared after resolving them against the workspace root, |
| 719 | // because older checkpoints may contain absolute paths while newer writers use |
| 720 | // workspace-relative paths. |
| 721 | func (s *Store) FileState(p string) (FileState, bool) { |
| 722 | want, err := safePath(s.root, p) |
| 723 | if err != nil { |
| 724 | return FileState{}, false |
| 725 | } |
| 726 | |
| 727 | s.mu.Lock() |
| 728 | defer s.mu.Unlock() |
| 729 | var earliest *FileSnap |
| 730 | var latestAfterSHA string |
| 731 | var latestAfterExisted *bool |
| 732 | for _, c := range s.all() { |
| 733 | for _, f := range c.Files { |
| 734 | got, err := safePath(s.root, f.Path) |
| 735 | if err != nil || got != want { |
| 736 | continue |
| 737 | } |
| 738 | if earliest == nil { |
| 739 | copy := f |
| 740 | earliest = © |
| 741 | } |
| 742 | // Ownership belongs to the final observed mutation, while the restore |
| 743 | // payload remains the earliest preimage. A later capture without an |
| 744 | // after fingerprint deliberately clears an older ownership proof. |
| 745 | latestAfterSHA = f.AfterSHA256 |
| 746 | latestAfterExisted = f.AfterExisted |
| 747 | } |
| 748 | } |
| 749 | if earliest == nil || earliest.PayloadExpired { |
| 750 | return FileState{}, false |
| 751 | } |
| 752 | state := FileState{ |
| 753 | Encoding: earliest.Encoding, |
| 754 | Mode: earliest.Mode, |
| 755 | SHA256: earliest.SHA256, |
| 756 | BlobRef: earliest.BlobRef, |
| 757 | Owned: latestAfterSHA != "" || latestAfterExisted != nil, |
| 758 | } |
| 759 | if earliest.Content != nil { |
| 760 | content := *earliest.Content |
| 761 | state.Content = &content |
| 762 | } else if earliest.BlobRef != "" && s.blobs != nil { |
| 763 | if raw, err := s.blobs.Get(earliest.BlobRef); err == nil { |
| 764 | enc, payload := fileenc.Detect(raw) |
| 765 | text := string(fileenc.Decode(payload, enc)) |
| 766 | state.Content = &text |
| 767 | state.Encoding = &enc |
| 768 | } |
| 769 | } |
| 770 | return state, true |
| 771 | } |
| 772 | |
| 773 | // all returns done + cur in turn order. Caller holds the lock. |
| 774 | func (s *Store) all() []*Checkpoint { |
| 775 | cps := append([]*Checkpoint(nil), s.done...) |
| 776 | if s.cur != nil { |
| 777 | cps = append(cps, s.cur) |
| 778 | } |
| 779 | sort.Slice(cps, func(i, j int) bool { return cps[i].Turn < cps[j].Turn }) |
| 780 | return cps |
| 781 | } |
| 782 | |
| 783 | // TruncateFrom discards checkpoints at or after fromTurn. Conversation rewind |
| 784 | // removes those future turns from the transcript, so their file snapshots must |
| 785 | // not remain visible or collide with newly-created checkpoints that reuse the |
| 786 | // same turn numbers after the rewrite. |
| 787 | func (s *Store) TruncateFrom(fromTurn int) error { |
| 788 | s.mu.Lock() |
| 789 | defer s.mu.Unlock() |
| 790 | deleteTurns := map[int]bool{} |
| 791 | for _, c := range s.done { |
| 792 | if c.Turn >= fromTurn { |
| 793 | deleteTurns[c.Turn] = true |
| 794 | } |
| 795 | } |
| 796 | if s.cur != nil && s.cur.Turn >= fromTurn { |
| 797 | deleteTurns[s.cur.Turn] = true |
| 798 | } |
| 799 | if err := s.removeTurnArtifacts(deleteTurns); err != nil { |
| 800 | return err |
| 801 | } |
| 802 | |
| 803 | done := s.done[:0] |
| 804 | for _, c := range s.done { |
| 805 | if c.Turn >= fromTurn { |
| 806 | continue |
| 807 | } |
| 808 | done = append(done, c) |
| 809 | } |
| 810 | for i := len(done); i < len(s.done); i++ { |
| 811 | s.done[i] = nil |
| 812 | } |
| 813 | s.done = done |
| 814 | if s.cur != nil && s.cur.Turn >= fromTurn { |
| 815 | s.cur = nil |
| 816 | s.seen = map[string]bool{} |
| 817 | } |
| 818 | return nil |
| 819 | } |
| 820 | |
| 821 | // RestoreCode reverts the workspace to its state at the start of turn `fromTurn` |
| 822 | // using a transactional prepare+commit. Legacy checkpoints are refused because |
| 823 | // they cannot prove that a later manual edit is safe to overwrite. Returns the |
| 824 | // paths written and deleted. |
| 825 | // |
| 826 | // On any failure after partial publish, compensation restores the pre-rewind |
| 827 | // workspace. Unlike the pre-v2 loop, a mid-way error does not leave a half-applied |
| 828 | // restore. |
| 829 | func (s *Store) RestoreCode(fromTurn int) (written, deleted []string, err error) { |
| 830 | plan, err := s.PrepareRewind(fromTurn, RewindCode, 0, 0, false) |
| 831 | if err != nil { |
| 832 | return nil, nil, err |
| 833 | } |
| 834 | if plan.Legacy && len(plan.Files) > 0 { |
| 835 | return nil, nil, fmt.Errorf("legacy checkpoint cannot safely restore files without explicit conflict confirmation") |
| 836 | } |
| 837 | // When complete/partial with no conflicts, commit. |
| 838 | if !plan.CanFiles && !plan.Legacy { |
| 839 | if plan.DisabledReason != "" { |
| 840 | return nil, nil, fmt.Errorf("%s", plan.DisabledReason) |
| 841 | } |
| 842 | if len(plan.Conflicts) > 0 { |
| 843 | return nil, nil, fmt.Errorf("file conflicts detected") |
| 844 | } |
| 845 | // No files — success no-op. |
| 846 | return nil, nil, nil |
| 847 | } |
| 848 | result, err := s.CommitRewindWithForward(plan.PlanID, nil, nil, nil) |
| 849 | if err != nil { |
| 850 | return result.Written, result.Deleted, err |
| 851 | } |
| 852 | return result.Written, result.Deleted, nil |
| 853 | } |
| 854 | |
| 855 | func (s *Store) detectCurrentEncoding(path string) *fileenc.Kind { |
| 856 | b, err := secureReadFile(s.root, path) |
| 857 | if err != nil { |
| 858 | return nil |
| 859 | } |
| 860 | enc, _ := fileenc.Detect(b) |
| 861 | return &enc |
| 862 | } |
| 863 | |
| 864 | // safePath resolves p against root and rejects anything escaping it — restore |
| 865 | // must never write outside the workspace, even if a snapshot path is hostile or |
| 866 | // the project moved since it was taken. |
| 867 | func safePath(root, p string) (string, error) { |
| 868 | abs := p |
| 869 | if !filepath.IsAbs(abs) { |
| 870 | abs = filepath.Join(root, p) |
| 871 | } |
| 872 | abs = filepath.Clean(abs) |
| 873 | if root != "" { |
| 874 | if err := validateWorkspacePath(root, abs); err != nil { |
| 875 | return "", err |
| 876 | } |
| 877 | } |
| 878 | return abs, nil |
| 879 | } |
| 880 | |
| 881 | var errSymlinkPath = errors.New("workspace path contains symbolic link") |
| 882 | |
| 883 | func workspaceRelative(root, abs string) (string, error) { |
| 884 | if root == "" { |
| 885 | return filepath.Clean(abs), nil |
| 886 | } |
| 887 | r := filepath.Clean(root) |
| 888 | rel, err := filepath.Rel(r, filepath.Clean(abs)) |
| 889 | if err != nil || !filepath.IsLocal(rel) { |
| 890 | return "", fmt.Errorf("checkpoint path %q escapes workspace %q", abs, root) |
| 891 | } |
| 892 | return rel, nil |
| 893 | } |
| 894 | |
| 895 | func splitLocalPath(rel string) []string { |
| 896 | var parts []string |
| 897 | for rel != "." && rel != "" { |
| 898 | dir, base := filepath.Split(rel) |
| 899 | if base != "" { |
| 900 | parts = append([]string{base}, parts...) |
| 901 | } |
| 902 | rel = filepath.Clean(dir) |
| 903 | if rel == string(filepath.Separator) { |
| 904 | break |
| 905 | } |
| 906 | } |
| 907 | return parts |
| 908 | } |
| 909 | |
| 910 | func validateWorkspacePath(root, abs string) error { |
| 911 | rel, err := workspaceRelative(root, abs) |
| 912 | if err != nil { |
| 913 | return err |
| 914 | } |
| 915 | cur := filepath.Clean(root) |
| 916 | for _, part := range splitLocalPath(rel) { |
| 917 | cur = filepath.Join(cur, part) |
| 918 | info, statErr := os.Lstat(cur) |
| 919 | if os.IsNotExist(statErr) { |
| 920 | return nil |
| 921 | } |
| 922 | if statErr != nil { |
| 923 | return statErr |
| 924 | } |
| 925 | if info.Mode()&os.ModeSymlink != 0 { |
| 926 | return fmt.Errorf("%w: %s", errSymlinkPath, cur) |
| 927 | } |
| 928 | } |
| 929 | return nil |
| 930 | } |
| 931 | |
| 932 | func writeNewFile(path string, data []byte, mode os.FileMode) error { |
| 933 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 934 | return err |
| 935 | } |
| 936 | file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) |
| 937 | if err != nil { |
| 938 | return err |
| 939 | } |
| 940 | remove := true |
| 941 | defer func() { |
| 942 | _ = file.Close() |
| 943 | if remove { |
| 944 | _ = os.Remove(path) |
| 945 | } |
| 946 | }() |
| 947 | if _, err := file.Write(data); err != nil { |
| 948 | return err |
| 949 | } |
| 950 | if err := file.Sync(); err != nil { |
| 951 | return err |
| 952 | } |
| 953 | if err := file.Close(); err != nil { |
| 954 | return err |
| 955 | } |
| 956 | remove = false |
| 957 | return nil |
| 958 | } |
| 959 |