Files
2026-07-13 13:00:08 +08:00

351 lines
9.7 KiB
Go

// Package checkpoint is reasonix's snapshot-based edit safety net. Before a writer
// tool changes a file, the agent records the file's pre-edit content here, keyed
// to the current user turn; a frontend can then rewind the workspace (and, via the
// controller, the conversation) to an earlier turn.
//
// It is deliberately git-free (like Claude Code's rewind): snapshots live beside
// the session, never touch the user's git, and work in a non-git directory. Only
// edit-tool changes are tracked — bash side effects are not (a shell command's
// targets can't be known in advance), which is why the capture hook only fires for
// tools that can Preview their change.
package checkpoint
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"sync"
"time"
"reasonix/internal/diff"
fileenc "reasonix/internal/fileutil/encoding"
)
// FileSnap is one file's state at the moment it was first touched in a turn.
// Content == nil means the file did not exist then, so a restore deletes it.
type FileSnap struct {
Path string `json:"path"`
Content *string `json:"content"`
Encoding *fileenc.Kind `json:"encoding,omitempty"`
}
// Checkpoint anchors the pre-edit state of every distinct file touched during one
// user turn. MsgIndex is len(Session.Messages) at the turn's start — the
// conversation-rewind boundary — persisted so a resumed session can rewind the
// conversation and fork, not just the code.
type Checkpoint struct {
Turn int `json:"turn"`
Time time.Time `json:"time"`
Prompt string `json:"prompt"`
MsgIndex int `json:"msgIndex"`
Files []FileSnap `json:"files"`
}
// Meta is the picker-facing summary of a checkpoint (no file contents).
type Meta struct {
Turn int
Time time.Time
Prompt string
Paths []string
}
// Store holds a session's checkpoints in memory and, when dir is set, persists one
// JSON file per turn under it (cheap delete, corruption-isolated). All methods are
// safe for concurrent use — the agent snapshots from tool goroutines.
type Store struct {
dir string // <session>.ckpt/, or "" for in-memory only
root string // workspace root, for restore path-escape guards
mu sync.Mutex
done []*Checkpoint // finalized turns
cur *Checkpoint // the active turn's checkpoint
seen map[string]bool // paths already snapshotted this turn (dedup)
}
// New returns a store for the given checkpoint dir and workspace root, loading any
// checkpoints already persisted under dir. A "" dir disables persistence (the
// store still works in memory for the session).
func New(dir, root string) *Store {
s := &Store{dir: dir, root: root, seen: map[string]bool{}}
if dir != "" {
s.load()
}
return s
}
func (s *Store) load() {
ents, err := os.ReadDir(s.dir)
if err != nil {
return
}
for _, e := range ents {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
b, err := fileenc.ReadFileUTF8(filepath.Join(s.dir, e.Name()))
if err != nil {
continue
}
var c Checkpoint
if json.Unmarshal(b, &c) == nil {
s.done = append(s.done, &c)
}
}
sort.Slice(s.done, func(i, j int) bool { return s.done[i].Turn < s.done[j].Turn })
}
// Begin opens a checkpoint for a new user turn, finalizing the previous one. The
// prompt labels it in the picker; msgIndex is the conversation-rewind boundary.
func (s *Store) Begin(turn int, prompt string, msgIndex int) {
s.mu.Lock()
defer s.mu.Unlock()
if s.cur != nil {
s.done = append(s.done, s.cur)
}
s.cur = &Checkpoint{Turn: turn, Time: time.Now(), Prompt: prompt, MsgIndex: msgIndex}
s.seen = map[string]bool{}
s.persist(s.cur)
}
// Bounds returns turn → MsgIndex over all checkpoints (persisted + current), so
// the controller can rebuild its conversation-rewind boundaries after loading a
// resumed session's checkpoints from disk.
func (s *Store) Bounds() map[int]int {
s.mu.Lock()
defer s.mu.Unlock()
m := make(map[int]int, len(s.done)+1)
for _, c := range s.done {
m[c.Turn] = c.MsgIndex
}
if s.cur != nil {
m[s.cur.Turn] = s.cur.MsgIndex
}
return m
}
// Snapshot records the pre-edit state of the file a writer is about to change.
// Only the first touch of a path in the current turn is kept (that is its
// turn-start content). A no-op before the first Begin.
func (s *Store) Snapshot(ch diff.Change) {
if ch.Path == "" {
return
}
var enc *fileenc.Kind
if ch.Kind != diff.Create {
enc = s.detectEncoding(ch.Path)
}
s.mu.Lock()
defer s.mu.Unlock()
if s.cur == nil || s.seen[ch.Path] {
return
}
s.seen[ch.Path] = true
var content *string
if ch.Kind != diff.Create { // create == file didn't exist → leave nil (restore deletes)
old := ch.OldText
content = &old
}
s.cur.Files = append(s.cur.Files, FileSnap{Path: ch.Path, Content: content, Encoding: enc})
s.persist(s.cur)
}
func (s *Store) detectEncoding(p string) *fileenc.Kind {
abs, err := safePath(s.root, p)
if err != nil {
return nil
}
b, err := os.ReadFile(abs)
if err != nil {
return nil
}
enc, _ := fileenc.Detect(b)
return &enc
}
func (s *Store) persist(c *Checkpoint) {
if s.dir == "" {
return
}
b, err := json.Marshal(c)
if err != nil {
return
}
if err := os.MkdirAll(s.dir, 0o755); err != nil {
slog.Warn("checkpoint: create dir failed", "dir", s.dir, "err", err)
return
}
if err := os.WriteFile(filepath.Join(s.dir, fmt.Sprintf("turn-%d.json", c.Turn)), b, 0o644); err != nil {
slog.Warn("checkpoint: persist failed", "turn", c.Turn, "err", err)
}
}
// NextTurn returns the turn number a new checkpoint should take: one past the
// highest existing turn (0 when empty), so a resumed session keeps numbering
// without colliding with checkpoints loaded from disk.
func (s *Store) NextTurn() int {
s.mu.Lock()
defer s.mu.Unlock()
next := 0
for _, c := range s.done {
if c.Turn >= next {
next = c.Turn + 1
}
}
if s.cur != nil && s.cur.Turn >= next {
next = s.cur.Turn + 1
}
return next
}
// List returns every checkpoint's metadata, oldest turn first.
func (s *Store) List() []Meta {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Meta, 0, len(s.done)+1)
for _, c := range s.all() {
paths := make([]string, len(c.Files))
for i, f := range c.Files {
paths[i] = f.Path
}
out = append(out, Meta{Turn: c.Turn, Time: c.Time, Prompt: c.Prompt, Paths: paths})
}
return out
}
// all returns done + cur in turn order. Caller holds the lock.
func (s *Store) all() []*Checkpoint {
cps := append([]*Checkpoint(nil), s.done...)
if s.cur != nil {
cps = append(cps, s.cur)
}
sort.Slice(cps, func(i, j int) bool { return cps[i].Turn < cps[j].Turn })
return cps
}
// TruncateFrom discards checkpoints at or after fromTurn. Conversation rewind
// removes those future turns from the transcript, so their file snapshots must
// not remain visible or collide with newly-created checkpoints that reuse the
// same turn numbers after the rewrite.
func (s *Store) TruncateFrom(fromTurn int) {
s.mu.Lock()
done := s.done[:0]
deleteTurns := map[int]bool{}
for _, c := range s.done {
if c.Turn >= fromTurn {
deleteTurns[c.Turn] = true
continue
}
done = append(done, c)
}
for i := len(done); i < len(s.done); i++ {
s.done[i] = nil
}
s.done = done
if s.cur != nil && s.cur.Turn >= fromTurn {
deleteTurns[s.cur.Turn] = true
s.cur = nil
s.seen = map[string]bool{}
}
dir := s.dir
s.mu.Unlock()
if dir == "" || len(deleteTurns) == 0 {
return
}
for turn := range deleteTurns {
if err := os.Remove(filepath.Join(dir, fmt.Sprintf("turn-%d.json", turn))); err != nil && !os.IsNotExist(err) {
slog.Warn("checkpoint: truncate failed", "turn", turn, "err", err)
}
}
}
// RestoreCode reverts the workspace to its state at the start of turn `fromTurn`:
// for every file touched in turn fromTurn or later, it writes back that file's
// earliest recorded content (or deletes it when the earliest snapshot was nil).
// Returns the paths written and deleted.
func (s *Store) RestoreCode(fromTurn int) (written, deleted []string, err error) {
s.mu.Lock()
// earliest snapshot per path across checkpoints >= fromTurn (turn order → first wins).
earliest := map[string]FileSnap{}
order := []string{}
for _, c := range s.all() {
if c.Turn < fromTurn {
continue
}
for _, f := range c.Files {
if _, ok := earliest[f.Path]; ok {
continue
}
earliest[f.Path] = f
order = append(order, f.Path)
}
}
root := s.root
s.mu.Unlock()
for _, p := range order {
abs, gerr := safePath(root, p)
if gerr != nil {
err = gerr
continue
}
snap := earliest[p]
if snap.Content == nil {
if rmErr := os.Remove(abs); rmErr == nil {
deleted = append(deleted, p)
} else if !os.IsNotExist(rmErr) {
err = rmErr
}
continue
}
if mkErr := os.MkdirAll(filepath.Dir(abs), 0o755); mkErr != nil {
err = mkErr
continue
}
enc := fileenc.UTF8
if snap.Encoding != nil {
enc = *snap.Encoding
} else if current := detectCurrentEncoding(abs); current != nil {
enc = *current
}
if wErr := os.WriteFile(abs, fileenc.Encode(*snap.Content, enc), 0o644); wErr != nil {
err = wErr
continue
}
written = append(written, p)
}
return written, deleted, err
}
func detectCurrentEncoding(path string) *fileenc.Kind {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
enc, _ := fileenc.Detect(b)
return &enc
}
// safePath resolves p against root and rejects anything escaping it — restore
// must never write outside the workspace, even if a snapshot path is hostile or
// the project moved since it was taken.
func safePath(root, p string) (string, error) {
abs := p
if !filepath.IsAbs(abs) {
abs = filepath.Join(root, p)
}
abs = filepath.Clean(abs)
if root != "" {
r := filepath.Clean(root)
rel, err := filepath.Rel(r, abs)
if err != nil || !filepath.IsLocal(rel) {
return "", fmt.Errorf("checkpoint path %q escapes workspace %q", p, root)
}
}
return abs, nil
}