351 lines
9.7 KiB
Go
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
|
|
}
|