| 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 = © |
| 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 |