返回 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 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 = &copy
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
959 lines GO