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