返回 DeepSeek-Reasonix
transaction.go
根目录 / internal / checkpoint / transaction.go
1 package checkpoint
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "slices"
11 "sort"
12 "strings"
13 "time"
14
15 fileenc "reasonix/internal/fileutil/encoding"
16 )
17
18 // InjectFail is a test seam. When set, CommitRewind fails at the named phase
19 // after optionally publishing the first N files. Empty = disabled.
20 //
21 // Known phases: "publish_file", "delete_file", "conversation", "truncate",
22 // "after_conversation_before_finalize", "finalize".
23 type InjectFail struct {
24 Phase string
25 AfterFiles int // fail after successfully handling this many file targets
26 }
27
28 // ConversationApplier applies conversation truncation during commit and restores
29 // forward conversation on compensate. Implemented by the control layer.
30 type ConversationApplier interface {
31 // ApplyConversationTruncate replaces the live message log with msgs[:boundary].
32 // forward is the full pre-truncate snapshot for later restore.
33 ApplyConversationTruncate(boundary int, forward []byte) error
34 // RestoreConversation reinstalls the forward snapshot.
35 RestoreConversation(forward []byte) error
36 // TruncateCheckpoints drops checkpoints at or after turn.
37 TruncateCheckpoints(fromTurn int) error
38 // RestoreCheckpoints reinstalls backed-up future checkpoints.
39 RestoreCheckpoints(backup []byte) error
40 }
41
42 // PrepareRewind builds a plan and optionally a prepared transaction without
43 // mutating workspace or conversation. Conflict detection uses last-owned after
44 // fingerprints when available.
45 func (s *Store) PrepareRewind(turn int, scope RewindScope, sessionRev int64, boundary int, hasBound bool) (RewindPlan, error) {
46 if s == nil {
47 return RewindPlan{}, fmt.Errorf("checkpoints unavailable")
48 }
49 plan := RewindPlan{
50 PlanID: newID("plan"),
51 Turn: turn,
52 Scope: scope,
53 SessionRevision: sessionRev,
54 BoundaryIndex: boundary,
55 HasBoundary: hasBound,
56 CreatedAt: time.Now(),
57 WorkspaceToken: fmt.Sprintf("%d", s.barrier.Generation()),
58 }
59
60 s.mu.Lock()
61 writers := append([]ActiveWriter(nil), s.activeWriters...)
62 plan.ActiveWriters = writers
63 cov, gaps, legacy, expired := s.coverageFromTurnLocked(turn)
64 plan.Coverage = cov
65 plan.CoverageGaps = gaps
66 plan.Legacy = legacy
67 plan.ExpiredFilePayload = expired
68 files := s.filesFromTurnLocked(turn)
69 plan.Files = files
70 plan.FileCount = len(files)
71 s.mu.Unlock()
72
73 wantFiles := scope == RewindCode || scope == RewindBoth
74 wantConv := scope == RewindConversation || scope == RewindBoth
75
76 if len(writers) > 0 {
77 plan.CanFiles = false
78 plan.CanConversation = false
79 plan.DisabledReason = "active background writer"
80 for _, w := range writers {
81 plan.Conflicts = append(plan.Conflicts, RewindConflict{
82 Path: "",
83 Reason: ConflictBusyWriter,
84 })
85 _ = w
86 }
87 return plan, nil
88 }
89
90 if wantConv {
91 if !hasBound {
92 plan.CanConversation = false
93 if scope == RewindConversation || scope == RewindBoth {
94 plan.DisabledReason = "conversation boundary unavailable"
95 }
96 } else {
97 plan.CanConversation = true
98 }
99 }
100
101 if wantFiles {
102 if len(files) == 0 && scope == RewindBoth {
103 // The file half of a combined rewind is an atomic no-op when this
104 // conversation range never touched a tracked file.
105 plan.CanFiles = true
106 } else if cov == CoverageNone {
107 plan.CanFiles = false
108 plan.DisabledReason = "no file captures"
109 } else if expired {
110 plan.CanFiles = false
111 plan.DisabledReason = "file recovery payload expired"
112 plan.Conflicts = append(plan.Conflicts, RewindConflict{Reason: ConflictExpired})
113 } else if legacy {
114 // Legacy: files can be restored only with explicit warning; batch
115 // overwrite without prompt is forbidden. Prepare still reports files
116 // but CanFiles stays false for the unprompted path.
117 plan.CanFiles = false
118 plan.DisabledReason = "legacy checkpoint cannot verify later manual edits"
119 plan.Conflicts = append(plan.Conflicts, RewindConflict{Reason: ConflictCoverageLegacy})
120 } else {
121 conflicts := s.precheckFiles(turn)
122 plan.Conflicts = append(plan.Conflicts, conflicts...)
123 plan.CanFiles = len(conflicts) == 0 && len(files) > 0
124 if len(conflicts) > 0 {
125 plan.DisabledReason = "file conflicts detected"
126 }
127 }
128 }
129
130 // both requires both sides to pass precheck.
131 if scope == RewindBoth {
132 if !plan.CanFiles || !plan.CanConversation {
133 if plan.DisabledReason == "" {
134 plan.DisabledReason = "both scope requires file and conversation precheck"
135 }
136 }
137 }
138
139 // Persist plan token so Commit can verify freshness.
140 s.mu.Lock()
141 if s.plans == nil {
142 s.plans = map[string]preparedPlan{}
143 }
144 s.plans[plan.PlanID] = preparedPlan{plan: plan, created: time.Now()}
145 // Drop stale plans older than 10 minutes.
146 for id, p := range s.plans {
147 if time.Since(p.created) > 10*time.Minute {
148 delete(s.plans, id)
149 }
150 }
151 s.mu.Unlock()
152 return plan, nil
153 }
154
155 type preparedPlan struct {
156 plan RewindPlan
157 created time.Time
158 previewFingerprint *Fingerprint
159 }
160
161 // ValidatePlanSessionRevision binds a preview to the controller's exact
162 // conversation revision. The controller holds its rotation gate while calling
163 // this and committing, so no turn can slip between validation and mutation.
164 func (s *Store) ValidatePlanSessionRevision(planID string, current int64) error {
165 if s == nil {
166 return fmt.Errorf("checkpoints unavailable")
167 }
168 s.mu.Lock()
169 defer s.mu.Unlock()
170 prepared, ok := s.plans[planID]
171 if !ok {
172 return fmt.Errorf("unknown or expired plan %q", planID)
173 }
174 if prepared.plan.SessionRevision != current {
175 return fmt.Errorf("conversation changed since preview")
176 }
177 return nil
178 }
179
180 // CommitRewind executes a previously prepared plan under exclusive barrier.
181 // conversation/checkpoints are applied via applier when non-nil.
182 func (s *Store) CommitRewind(planID string, applier ConversationApplier, inject *InjectFail) (RewindResult, error) {
183 if s == nil {
184 return RewindResult{}, fmt.Errorf("checkpoints unavailable")
185 }
186 s.mu.Lock()
187 pp, ok := s.plans[planID]
188 if ok {
189 delete(s.plans, planID)
190 }
191 s.mu.Unlock()
192 if !ok {
193 return RewindResult{OK: false, Error: "unknown or expired plan"}, fmt.Errorf("unknown or expired plan %q", planID)
194 }
195 plan := pp.plan
196
197 // Re-validate gate conditions before any mutation.
198 if plan.Scope == RewindBoth && (!plan.CanFiles || !plan.CanConversation) {
199 return RewindResult{OK: false, Error: plan.DisabledReason, Conflicts: plan.Conflicts, Coverage: plan.Coverage}, fmt.Errorf("%s", plan.DisabledReason)
200 }
201 if (plan.Scope == RewindCode || plan.Scope == RewindBoth) && !plan.CanFiles && plan.Scope != RewindConversation {
202 if plan.Scope == RewindCode || plan.Scope == RewindBoth {
203 return RewindResult{OK: false, Error: plan.DisabledReason, Conflicts: plan.Conflicts, Coverage: plan.Coverage}, fmt.Errorf("%s", plan.DisabledReason)
204 }
205 }
206 if (plan.Scope == RewindConversation || plan.Scope == RewindBoth) && !plan.CanConversation {
207 return RewindResult{OK: false, Error: plan.DisabledReason}, fmt.Errorf("%s", plan.DisabledReason)
208 }
209
210 // Workspace exclusive barrier.
211 if !s.barrier.TryEnterExclusive() {
212 err := fmt.Errorf("workspace mutation in progress")
213 return RewindResult{OK: false, Error: err.Error(), Conflicts: []RewindConflict{{Reason: ConflictBusyWriter}}}, err
214 }
215 defer s.barrier.ExitExclusive()
216 if conflicts := s.activeWriterConflicts(); len(conflicts) > 0 {
217 err := fmt.Errorf("active background writer")
218 return RewindResult{OK: false, Error: err.Error(), Conflicts: conflicts, Coverage: plan.Coverage}, err
219 }
220
221 if plan.Scope == RewindCode || plan.Scope == RewindBoth {
222 if plan.WorkspaceToken != fmt.Sprintf("%d", s.barrier.Generation()) {
223 conflict := RewindConflict{Reason: ConflictStalePlan}
224 return RewindResult{OK: false, Error: "workspace changed since preview", Conflicts: []RewindConflict{conflict}, Coverage: plan.Coverage}, fmt.Errorf("workspace changed since preview")
225 }
226 conflicts := s.precheckFiles(plan.Turn)
227 if len(conflicts) > 0 {
228 return RewindResult{OK: false, Error: "file conflicts detected", Conflicts: conflicts, Coverage: plan.Coverage}, fmt.Errorf("file conflicts detected")
229 }
230 }
231
232 tx, err := s.prepareTransaction(plan, applier)
233 if err != nil {
234 return RewindResult{OK: false, Error: err.Error()}, err
235 }
236
237 result, err := s.commitTransaction(tx, applier, inject)
238 return result, err
239 }
240
241 // UndoRewind reverses a committed transaction when still available.
242 func (s *Store) UndoRewind(transactionID string, applier ConversationApplier) (RewindResult, error) {
243 if s == nil {
244 return RewindResult{}, fmt.Errorf("checkpoints unavailable")
245 }
246 s.mu.Lock()
247 last := s.lastUndo
248 s.mu.Unlock()
249 if last == nil || last.ID != transactionID || last.State != TxCommitted {
250 return RewindResult{OK: false, Error: "undo not available"}, fmt.Errorf("undo not available for %q", transactionID)
251 }
252
253 if !s.barrier.TryEnterExclusive() {
254 err := fmt.Errorf("workspace mutation in progress")
255 return RewindResult{OK: false, Error: err.Error(), Conflicts: []RewindConflict{{Reason: ConflictBusyWriter}}}, err
256 }
257 defer s.barrier.ExitExclusive()
258 if conflicts := s.activeWriterConflicts(); len(conflicts) > 0 {
259 err := fmt.Errorf("active background writer")
260 return RewindResult{OK: false, Error: err.Error(), Conflicts: conflicts}, err
261 }
262
263 // Precheck that current disk still matches what we published (targets' restore state).
264 for _, t := range last.Targets {
265 fp, err := FingerprintPath(s.root, t.AbsPath)
266 if err != nil && !os.IsNotExist(err) {
267 return RewindResult{OK: false, Error: err.Error()}, fmt.Errorf("fingerprint %s before undo: %w", t.Path, err)
268 }
269 // After commit, disk should match restore image. If it doesn't, refuse.
270 if t.Action == "delete" {
271 if fp.Existed {
272 return RewindResult{OK: false, Error: "file changed since rewind", Conflicts: []RewindConflict{{
273 Path: t.Path, Reason: ConflictManualEdit, CurrentSHA: fp.SHA256,
274 }}}, fmt.Errorf("file changed since rewind: %s", t.Path)
275 }
276 } else {
277 if !fingerprintMatches(fp, t.RestoreExisted, t.RestoreSHA, t.RestoreMode) {
278 restoreExisted := t.RestoreExisted
279 return RewindResult{OK: false, Error: "file changed since rewind", Conflicts: []RewindConflict{{
280 Path: t.Path, Reason: CompareIdentity(fp, t.RestoreSHA, &restoreExisted, t.RestoreMode),
281 CurrentSHA: fp.SHA256, LastOwnedSHA: t.RestoreSHA, CurrentMode: fp.Mode, CheckpointMode: t.RestoreMode,
282 }}}, fmt.Errorf("file changed since rewind: %s", t.Path)
283 }
284 }
285 }
286
287 // Build inverse transaction: restore forward images.
288 undo := &TransactionManifest{
289 SchemaVersion: SchemaV2,
290 ID: newID("tx"),
291 SessionID: last.SessionID,
292 WorkspaceRoot: last.WorkspaceRoot,
293 State: TxPrepared,
294 Kind: "undo",
295 Turn: last.Turn,
296 Scope: last.Scope,
297 CreatedAt: time.Now(),
298 UpdatedAt: time.Now(),
299 SessionRevision: last.SessionRevision,
300 ParentTransaction: last.ID,
301 HasBoundary: last.HasBoundary,
302 BoundaryIndex: last.BoundaryIndex,
303 ConversationForward: last.ConversationForward,
304 CheckpointBackup: last.CheckpointBackup,
305 TruncateFrom: last.TruncateFrom,
306 }
307 for _, t := range last.Targets {
308 inv := TransactionTarget{
309 Path: t.Path,
310 AbsPath: t.AbsPath,
311 RestoreExisted: t.ForwardExisted,
312 RestoreMode: t.ForwardMode,
313 RestoreSHA: t.ForwardSHA,
314 RestoreBlob: t.ForwardBlob,
315 RestoreInline: clonePayload(t.ForwardInline),
316 ForwardExisted: t.RestoreExisted,
317 ForwardMode: t.RestoreMode,
318 ForwardSHA: t.RestoreSHA,
319 ForwardBlob: t.RestoreBlob,
320 ForwardInline: clonePayload(t.RestoreInline),
321 }
322 if t.ForwardExisted {
323 inv.Action = "write"
324 } else {
325 inv.Action = "delete"
326 }
327 inv.PublishTmp, inv.BackupPath = transactionSiblingPaths(inv.AbsPath, undo.ID, len(undo.Targets))
328 if inv.Action != "write" {
329 inv.PublishTmp = ""
330 }
331 undo.Targets = append(undo.Targets, inv)
332 }
333 if err := s.persistTransaction(undo); err != nil {
334 return RewindResult{OK: false, Error: err.Error()}, err
335 }
336
337 // Stage publish temps for write targets.
338 for i := range undo.Targets {
339 t := &undo.Targets[i]
340 if t.Action != "write" {
341 continue
342 }
343 data, err := s.loadBlobOrInline(t.RestoreBlob, t.RestoreInline)
344 if err != nil {
345 s.cleanupPublishTemps(undo.Targets)
346 _ = s.abortTransaction(undo, err)
347 return RewindResult{OK: false, Error: err.Error()}, err
348 }
349 mode := os.FileMode(0o644)
350 if t.RestoreMode != 0 {
351 mode = os.FileMode(t.RestoreMode)
352 }
353 if err := s.writePublishTemp(t.PublishTmp, data, mode); err != nil {
354 s.cleanupPublishTemps(undo.Targets)
355 _ = s.abortTransaction(undo, err)
356 return RewindResult{OK: false, Error: err.Error()}, err
357 }
358 }
359 undo.State = TxPrepared
360 if err := s.persistTransaction(undo); err != nil {
361 err = s.failTransaction(undo, undo.Targets, nil, err)
362 return RewindResult{OK: false, Error: err.Error()}, err
363 }
364
365 // For undo of conversation: restore forward conversation and checkpoints.
366 // Commit path for undo: publish files, then restore conversation/checkpoints.
367 result, err := s.commitUndoTransaction(undo, last, applier)
368 return result, err
369 }
370
371 func (s *Store) commitUndoTransaction(undo, original *TransactionManifest, applier ConversationApplier) (RewindResult, error) {
372 undo.State = TxCommitting
373 undo.UpdatedAt = time.Now()
374 if err := s.persistTransaction(undo); err != nil {
375 err = s.failTransaction(undo, undo.Targets, nil, err)
376 return RewindResult{OK: false, Error: err.Error()}, err
377 }
378
379 result := RewindResult{TransactionID: undo.ID, Coverage: CoverageComplete}
380 var stages []FileStage
381
382 // Publish files (inverse).
383 for i := range undo.Targets {
384 t := &undo.Targets[i]
385 st := FileStage{Path: t.Path, Phase: "commit", Action: t.Action}
386 t.Published = true
387 undo.UpdatedAt = time.Now()
388 if err := s.persistTransaction(undo); err != nil {
389 t.Published = false
390 st.Error = err.Error()
391 stages = append(stages, st)
392 err = s.failTransaction(undo, undo.Targets, stages, err)
393 result.Error = err.Error()
394 result.Files = stages
395 return result, err
396 }
397 if err := s.publishTarget(t); err != nil {
398 st.Error = err.Error()
399 stages = append(stages, st)
400 err = s.failTransaction(undo, undo.Targets[:i+1], stages, err)
401 result.OK = false
402 result.Error = err.Error()
403 result.Files = stages
404 return result, err
405 }
406 undo.UpdatedAt = time.Now()
407 if err := s.persistTransaction(undo); err != nil {
408 st.Error = err.Error()
409 stages = append(stages, st)
410 err = s.failTransaction(undo, undo.Targets[:i+1], stages, err)
411 result.Error = err.Error()
412 result.Files = stages
413 return result, err
414 }
415 st.Phase = "done"
416 stages = append(stages, st)
417 if t.Action == "write" {
418 result.Written = append(result.Written, t.Path)
419 } else {
420 result.Deleted = append(result.Deleted, t.Path)
421 }
422 }
423
424 // Restore conversation and checkpoints to pre-rewind state.
425 if applier != nil && len(original.ConversationForward) > 0 {
426 if err := applier.RestoreConversation(original.ConversationForward); err != nil {
427 restoreErr := s.restoreOriginalRewind(original, applier)
428 err = s.failTransactionAfterStateCompensation(undo, undo.Targets, stages, err, restoreErr)
429 result.OK = false
430 result.Error = err.Error()
431 result.Files = stages
432 return result, err
433 }
434 result.ConversationOK = true
435 }
436 if applier != nil && len(original.CheckpointBackup) > 0 {
437 if err := applier.RestoreCheckpoints(original.CheckpointBackup); err != nil {
438 // Return every side to the original rewind state before compensating
439 // the inverse file publish. Re-restoring the forward conversation here
440 // would leave conversation and files at opposite endpoints.
441 restoreErr := s.restoreOriginalRewind(original, applier)
442 err = s.failTransactionAfterStateCompensation(undo, undo.Targets, stages, err, restoreErr)
443 result.OK = false
444 result.Error = err.Error()
445 result.Files = stages
446 return result, err
447 }
448 }
449
450 // Controller appliers restore this same store and then rebuild their boundary
451 // index. Only the store-only path needs a direct restore here.
452 if applier == nil && len(original.CheckpointBackup) > 0 {
453 if err := s.restoreCheckpointBackup(original.CheckpointBackup); err != nil {
454 err = s.failTransactionAfterStateCompensation(undo, undo.Targets, stages, err, nil)
455 result.OK = false
456 result.Error = err.Error()
457 result.Files = stages
458 return result, err
459 }
460 }
461
462 undo.State = TxCommitted
463 undo.UpdatedAt = time.Now()
464 if err := s.persistTransaction(undo); err != nil {
465 restoreErr := s.restoreOriginalRewind(original, applier)
466 err = s.failTransactionAfterStateCompensation(undo, undo.Targets, stages, err, restoreErr)
467 result.OK = false
468 result.Error = err.Error()
469 result.Files = stages
470 return result, err
471 }
472
473 // Mark original as undone; clear lastUndo.
474 original.State = TxUndone
475 original.UpdatedAt = time.Now()
476 if err := s.persistTransaction(original); err != nil {
477 // The committed undo manifest durably names its parent, so startup will
478 // suppress the stale parent even if this secondary write failed.
479 slog.Warn("checkpoint: persist original transaction as undone", "err", err)
480 }
481 s.mu.Lock()
482 s.lastUndo = nil
483 s.mu.Unlock()
484
485 result.OK = true
486 result.UndoAvailable = false
487 result.Files = stages
488 return result, nil
489 }
490
491 func (s *Store) restoreOriginalRewind(original *TransactionManifest, applier ConversationApplier) error {
492 if original == nil || applier == nil {
493 return nil
494 }
495 if original.Scope != RewindConversation && original.Scope != RewindBoth {
496 return nil
497 }
498 var err error
499 if original.HasBoundary {
500 err = errors.Join(err, applier.ApplyConversationTruncate(original.BoundaryIndex, original.ConversationForward))
501 }
502 err = errors.Join(err, applier.TruncateCheckpoints(original.TruncateFrom))
503 return err
504 }
505
506 func (s *Store) prepareTransaction(plan RewindPlan, applier ConversationApplier) (*TransactionManifest, error) {
507 tx := newRewindTransaction(s.root, plan)
508 prepared := false
509 defer func() {
510 if !prepared {
511 s.cleanupPublishTemps(tx.Targets)
512 }
513 }()
514
515 if plan.Scope == RewindCode || plan.Scope == RewindBoth {
516 earliest := s.earliestRevisions(plan.Turn)
517 // Stable order for deterministic inject tests.
518 paths := make([]string, 0, len(earliest))
519 for p := range earliest {
520 paths = append(paths, p)
521 }
522 sort.Strings(paths)
523 for targetIndex, p := range paths {
524 rev := earliest[p]
525 abs, err := safePath(s.root, p)
526 if err != nil {
527 return nil, err
528 }
529 // Capture forward image.
530 fwd, gap, err := CapturePath(abs, CaptureOptions{WorkspaceRoot: s.root, ReadContent: true})
531 if err != nil && gap != nil {
532 return nil, fmt.Errorf("capture forward %s: %w", p, err)
533 }
534 t := TransactionTarget{
535 Path: p,
536 AbsPath: abs,
537 RestoreExisted: rev.Existed,
538 RestoreMode: rev.Mode,
539 RestoreSHA: rev.SHA256,
540 RestoreBlob: rev.BlobRef,
541 RestoreEncoding: rev.Encoding,
542 ForwardExisted: fwd.Existed,
543 ForwardMode: fwd.Mode,
544 ForwardSHA: fwd.SHA256,
545 }
546 if rev.Existed {
547 t.Action = "write"
548 if t.RestoreBlob == "" && rev.Content == nil {
549 return nil, fmt.Errorf("missing restore payload for %s", p)
550 }
551 // Stage publish temp. Blobs hold raw on-disk bytes; inline
552 // Content is decoded text and must be re-encoded. Legacy v1
553 // snapshots often omit Encoding — fall back to the current
554 // file's encoding (same as the pre-v2 RestoreCode path).
555 var data []byte
556 if rev.BlobRef != "" {
557 var lerr error
558 data, lerr = s.loadRevisionBytes(rev)
559 if lerr != nil {
560 return nil, lerr
561 }
562 } else if rev.Content != nil {
563 enc := fileenc.UTF8
564 if rev.Encoding != nil {
565 enc = *rev.Encoding
566 } else if current := s.detectCurrentEncoding(abs); current != nil {
567 enc = *current
568 }
569 data = fileenc.Encode(*rev.Content, enc)
570 } else {
571 return nil, fmt.Errorf("missing restore payload for %s", p)
572 }
573 mode := os.FileMode(0o644)
574 if rev.Mode != 0 {
575 mode = os.FileMode(rev.Mode)
576 }
577 if t.RestoreBlob == "" && s.blobs != nil {
578 ref, err := s.blobs.Put(data)
579 if err != nil {
580 return nil, err
581 }
582 t.RestoreBlob = ref
583 } else if t.RestoreBlob == "" {
584 t.RestoreInline = clonePayload(data)
585 }
586 t.PublishTmp, t.BackupPath = transactionSiblingPaths(abs, tx.ID, targetIndex)
587 if err := s.writePublishTemp(t.PublishTmp, data, mode); err != nil {
588 return nil, err
589 }
590 } else {
591 t.Action = "delete"
592 _, t.BackupPath = transactionSiblingPaths(abs, tx.ID, targetIndex)
593 }
594 if fwd.Existed && s.blobs != nil {
595 ref, err := s.blobs.Put(fwd.Content)
596 if err != nil {
597 if t.PublishTmp != "" {
598 _ = secureRemove(s.root, t.PublishTmp)
599 }
600 return nil, err
601 }
602 t.ForwardBlob = ref
603 } else if fwd.Existed {
604 t.ForwardInline = clonePayload(fwd.Content)
605 }
606 // Backup existing file for delete path (move later at commit).
607 tx.Targets = append(tx.Targets, t)
608 }
609 }
610
611 if shouldTruncateConversation(tx) && applier != nil {
612 // Backup future checkpoints for undo.
613 backup, err := s.backupCheckpointsFrom(plan.Turn)
614 if err != nil {
615 return nil, err
616 }
617 tx.CheckpointBackup = backup
618 }
619
620 if err := s.persistTransaction(tx); err != nil {
621 return nil, err
622 }
623 prepared = true
624 return tx, nil
625 }
626
627 func transactionSiblingPaths(absPath, transactionID string, index int) (publish, backup string) {
628 dir := filepath.Dir(absPath)
629 base := filepath.Base(absPath)
630 prefix := fmt.Sprintf(".%s.reasonix-%s-%d", base, transactionID, index)
631 return filepath.Join(dir, prefix+".tmp"), filepath.Join(dir, prefix+".bak")
632 }
633
634 func (s *Store) writePublishTemp(path string, data []byte, mode os.FileMode) error {
635 if err := secureWriteNew(s.root, path, data, mode); err != nil {
636 return fmt.Errorf("create publish temp: %w", err)
637 }
638 return nil
639 }
640
641 func (s *Store) cleanupPublishTemps(targets []TransactionTarget) {
642 for _, target := range targets {
643 if target.PublishTmp != "" {
644 _ = secureRemove(s.root, target.PublishTmp)
645 }
646 }
647 }
648
649 func (s *Store) commitTransaction(tx *TransactionManifest, applier ConversationApplier, inject *InjectFail) (RewindResult, error) {
650 tx.State = TxCommitting
651 tx.UpdatedAt = time.Now()
652 if err := s.persistTransaction(tx); err != nil {
653 err = s.failTransaction(tx, tx.Targets, nil, err)
654 return RewindResult{OK: false, Error: err.Error()}, err
655 }
656
657 result := RewindResult{TransactionID: tx.ID, Coverage: tx.Coverage, CoverageGaps: append([]CoverageGap(nil), tx.CoverageGaps...)}
658 stages := make([]FileStage, 0, len(tx.Targets))
659 filesDone := 0
660
661 for i := range tx.Targets {
662 t := &tx.Targets[i]
663 st := FileStage{Path: t.Path, Phase: "commit", Action: t.Action}
664 phase := "publish_file"
665 if t.Action == "delete" {
666 phase = "delete_file"
667 }
668 if inject != nil && inject.Phase == phase && filesDone >= inject.AfterFiles {
669 err := fmt.Errorf("injected failure at %s after %d files", inject.Phase, inject.AfterFiles)
670 st.Error = err.Error()
671 stages = append(stages, st)
672 err = s.failTransaction(tx, tx.Targets[:i], stages, err)
673 result.OK = false
674 result.Error = err.Error()
675 result.Files = stages
676 return result, err
677 }
678 // Persist a conservative "may have published" intent before the first
679 // filesystem rename. Recovery can safely compensate even if the crash
680 // happened just before publish.
681 t.Published = true
682 tx.UpdatedAt = time.Now()
683 if err := s.persistTransaction(tx); err != nil {
684 t.Published = false
685 st.Error = err.Error()
686 stages = append(stages, st)
687 err = s.failTransaction(tx, tx.Targets, stages, err)
688 result.Error = err.Error()
689 result.Files = stages
690 return result, err
691 }
692 if err := s.publishTarget(t); err != nil {
693 st.Error = err.Error()
694 stages = append(stages, st)
695 err = s.failTransaction(tx, tx.Targets[:i+1], stages, err)
696 result.OK = false
697 result.Error = err.Error()
698 result.Files = stages
699 return result, err
700 }
701 if inject != nil && inject.Phase == "after_publish_before_progress" && filesDone >= inject.AfterFiles {
702 // Deliberately leave the durable state as committing to simulate a
703 // process crash at the narrowest progress-persistence window.
704 err := fmt.Errorf("injected crash after publish before progress")
705 result.Error = err.Error()
706 result.Files = append(stages, st)
707 return result, err
708 }
709 tx.UpdatedAt = time.Now()
710 if err := s.persistTransaction(tx); err != nil {
711 st.Error = err.Error()
712 stages = append(stages, st)
713 err = s.failTransaction(tx, tx.Targets[:i+1], stages, err)
714 result.Error = err.Error()
715 result.Files = stages
716 return result, err
717 }
718 st.Phase = "done"
719 stages = append(stages, st)
720 filesDone++
721 if t.Action == "write" {
722 result.Written = append(result.Written, t.Path)
723 } else {
724 result.Deleted = append(result.Deleted, t.Path)
725 }
726 }
727 if err := s.persistTransaction(tx); err != nil {
728 err = s.failTransaction(tx, tx.Targets, stages, err)
729 result.Error = err.Error()
730 result.Files = stages
731 return result, err
732 }
733
734 if shouldTruncateConversation(tx) {
735 if inject != nil && inject.Phase == "conversation" {
736 err := fmt.Errorf("injected failure at conversation")
737 err = s.failTransaction(tx, tx.Targets, stages, err)
738 result.OK = false
739 result.Error = err.Error()
740 result.Files = stages
741 return result, err
742 }
743 if applier != nil && tx.HasBoundary {
744 if err := applier.ApplyConversationTruncate(tx.BoundaryIndex, tx.ConversationForward); err != nil {
745 restoreErr := s.restoreTransactionConversation(tx, applier)
746 err = s.failTransactionAfterStateCompensation(tx, tx.Targets, stages, err, restoreErr)
747 result.OK = false
748 result.Error = err.Error()
749 result.Files = stages
750 return result, err
751 }
752 result.ConversationOK = true
753 }
754 if inject != nil && inject.Phase == "truncate" {
755 err := fmt.Errorf("injected failure at truncate")
756 restoreErr := s.restoreTransactionConversation(tx, applier)
757 err = s.failTransactionAfterStateCompensation(tx, tx.Targets, stages, err, restoreErr)
758 result.OK = false
759 result.Error = err.Error()
760 result.Files = stages
761 return result, err
762 }
763 if applier != nil {
764 if err := applier.TruncateCheckpoints(tx.TruncateFrom); err != nil {
765 restoreErr := s.restoreTransactionConversation(tx, applier)
766 err = s.failTransactionAfterStateCompensation(tx, tx.Targets, stages, err, restoreErr)
767 result.OK = false
768 result.Error = err.Error()
769 result.Files = stages
770 return result, err
771 }
772 } else if err := s.TruncateFrom(tx.TruncateFrom); err != nil {
773 restoreErr := s.restoreTransactionConversation(tx, applier)
774 err = s.failTransactionAfterStateCompensation(tx, tx.Targets, stages, err, restoreErr)
775 result.OK = false
776 result.Error = err.Error()
777 result.Files = stages
778 return result, err
779 }
780 }
781
782 if inject != nil && inject.Phase == "finalize" {
783 err := fmt.Errorf("injected failure at finalize")
784 restoreErr := s.restoreTransactionConversation(tx, applier)
785 err = s.failTransactionAfterStateCompensation(tx, tx.Targets, stages, err, restoreErr)
786 result.OK = false
787 result.Error = err.Error()
788 result.Files = stages
789 return result, err
790 }
791 if inject != nil && inject.Phase == "after_conversation_before_finalize" {
792 // Simulate process death after both conversation mutations are durable but
793 // before the transaction can be marked committed. Startup must restore the
794 // forward transcript/checkpoints before compensating files.
795 err := fmt.Errorf("injected crash after conversation before finalize")
796 result.Error = err.Error()
797 result.Files = stages
798 return result, err
799 }
800
801 tx.State = TxCommitted
802 tx.UpdatedAt = time.Now()
803 if err := s.persistTransaction(tx); err != nil {
804 restoreErr := s.restoreTransactionConversation(tx, applier)
805 err = s.failTransactionAfterStateCompensation(tx, tx.Targets, stages, err, restoreErr)
806 result.OK = false
807 result.Error = err.Error()
808 result.Files = stages
809 return result, err
810 }
811 s.mu.Lock()
812 s.lastUndo = tx
813 s.mu.Unlock()
814
815 result.OK = true
816 result.UndoAvailable = true
817 result.Files = stages
818 return result, nil
819 }
820
821 func (s *Store) restoreTransactionConversation(tx *TransactionManifest, applier ConversationApplier) error {
822 if tx == nil {
823 return nil
824 }
825 var restoreErr error
826 if applier != nil {
827 if len(tx.ConversationForward) > 0 {
828 restoreErr = errors.Join(restoreErr, applier.RestoreConversation(tx.ConversationForward))
829 }
830 if len(tx.CheckpointBackup) > 0 {
831 restoreErr = errors.Join(restoreErr, applier.RestoreCheckpoints(tx.CheckpointBackup))
832 }
833 } else if len(tx.CheckpointBackup) > 0 {
834 restoreErr = errors.Join(restoreErr, s.restoreCheckpointBackup(tx.CheckpointBackup))
835 }
836 return restoreErr
837 }
838
839 // SetConversationForward attaches the pre-truncate conversation snapshot to a
840 // prepared transaction before commit. The controller calls this after Prepare.
841 func (s *Store) SetConversationForward(txID string, forward []byte) error {
842 path := s.txManifestPath(txID)
843 var tx TransactionManifest
844 if err := readJSONFile(path, &tx); err != nil {
845 // Also check in-memory last prepare path: store plans don't hold tx yet.
846 // Commit builds tx fresh; controller should pass forward via Commit options.
847 return err
848 }
849 setTransactionConversationForward(&tx, forward)
850 tx.UpdatedAt = time.Now()
851 return s.persistTransaction(&tx)
852 }
853
854 // CommitRewindWithForward is CommitRewind plus conversation forward payload.
855 func (s *Store) CommitRewindWithForward(planID string, forward []byte, applier ConversationApplier, inject *InjectFail) (RewindResult, error) {
856 if s == nil {
857 return RewindResult{}, fmt.Errorf("checkpoints unavailable")
858 }
859 s.mu.Lock()
860 pp, ok := s.plans[planID]
861 if ok {
862 delete(s.plans, planID)
863 }
864 s.mu.Unlock()
865 if !ok {
866 return RewindResult{OK: false, Error: "unknown or expired plan"}, fmt.Errorf("unknown or expired plan %q", planID)
867 }
868 plan := pp.plan
869
870 if plan.Scope == RewindBoth && (!plan.CanFiles || !plan.CanConversation) {
871 return RewindResult{OK: false, Error: plan.DisabledReason, Conflicts: plan.Conflicts}, fmt.Errorf("%s", plan.DisabledReason)
872 }
873 if plan.Scope == RewindCode && !plan.CanFiles {
874 return RewindResult{OK: false, Error: plan.DisabledReason, Conflicts: plan.Conflicts}, fmt.Errorf("%s", plan.DisabledReason)
875 }
876 if (plan.Scope == RewindConversation || plan.Scope == RewindBoth) && !plan.CanConversation {
877 return RewindResult{OK: false, Error: plan.DisabledReason}, fmt.Errorf("%s", plan.DisabledReason)
878 }
879
880 if !s.barrier.TryEnterExclusive() {
881 err := fmt.Errorf("workspace mutation in progress")
882 return RewindResult{OK: false, Error: err.Error(), Conflicts: []RewindConflict{{Reason: ConflictBusyWriter}}}, err
883 }
884 defer s.barrier.ExitExclusive()
885 if conflicts := s.activeWriterConflicts(); len(conflicts) > 0 {
886 err := fmt.Errorf("active background writer")
887 return RewindResult{OK: false, Error: err.Error(), Conflicts: conflicts, Coverage: plan.Coverage}, err
888 }
889
890 if plan.Scope == RewindCode || plan.Scope == RewindBoth {
891 if plan.WorkspaceToken != fmt.Sprintf("%d", s.barrier.Generation()) {
892 conflict := RewindConflict{Reason: ConflictStalePlan}
893 return RewindResult{OK: false, Error: "workspace changed since preview", Conflicts: []RewindConflict{conflict}, Coverage: plan.Coverage}, fmt.Errorf("workspace changed since preview")
894 }
895 if conflicts := s.precheckFiles(plan.Turn); len(conflicts) > 0 {
896 return RewindResult{OK: false, Error: "file conflicts detected", Conflicts: conflicts}, fmt.Errorf("file conflicts detected")
897 }
898 }
899
900 tx, err := s.prepareTransaction(plan, applier)
901 if err != nil {
902 return RewindResult{OK: false, Error: err.Error()}, err
903 }
904 setTransactionConversationForward(tx, forward)
905 if err := s.persistTransaction(tx); err != nil {
906 return RewindResult{OK: false, Error: err.Error()}, err
907 }
908 return s.commitTransaction(tx, applier, inject)
909 }
910
911 func (s *Store) publishTarget(t *TransactionTarget) error {
912 if t.BackupPath == "" {
913 return fmt.Errorf("missing transaction backup path for %s", t.Path)
914 }
915 backupExists, err := securePathExists(s.root, t.BackupPath)
916 if err != nil {
917 return err
918 }
919 if backupExists {
920 return fmt.Errorf("transaction backup already exists for %s", t.Path)
921 }
922 targetExists, err := securePathExists(s.root, t.AbsPath)
923 if err != nil {
924 return err
925 }
926 if targetExists {
927 if err := secureRename(s.root, t.AbsPath, t.BackupPath); err != nil {
928 return fmt.Errorf("backup %s: %w", t.Path, err)
929 }
930 }
931 if t.Action == "delete" {
932 return nil
933 }
934 if t.PublishTmp == "" {
935 return fmt.Errorf("missing publish tmp for %s", t.Path)
936 }
937 if err := secureRename(s.root, t.PublishTmp, t.AbsPath); err != nil {
938 restoreErr := error(nil)
939 if exists, statErr := securePathExists(s.root, t.BackupPath); statErr == nil && exists {
940 restoreErr = secureRename(s.root, t.BackupPath, t.AbsPath)
941 }
942 return errors.Join(fmt.Errorf("publish %s: %w", t.Path, err), restoreErr)
943 }
944 if t.RestoreMode != 0 {
945 if err := secureChmod(s.root, t.AbsPath, os.FileMode(t.RestoreMode)); err != nil {
946 return fmt.Errorf("chmod restored %s: %w", t.Path, err)
947 }
948 }
949 return nil
950 }
951
952 func (s *Store) compensatePublished(targets []TransactionTarget, stages []FileStage) error {
953 var first error
954 for _, v := range slices.Backward(targets) {
955 t := v
956 if !t.Published {
957 if t.PublishTmp != "" {
958 _ = secureRemove(s.root, t.PublishTmp)
959 }
960 continue
961 }
962 // Published is a durable intent. If the target still exactly matches its
963 // forward image, the crash happened before publish and compensation is a
964 // no-op. Any other unrelated state is preserved with a recovery copy.
965 var err error
966 cur, fpErr := FingerprintPath(s.root, t.AbsPath)
967 if fpErr != nil {
968 markCompensationStage(stages, t.Path, fpErr)
969 if first == nil {
970 first = fpErr
971 }
972 continue
973 } else if fingerprintMatches(cur, t.ForwardExisted, t.ForwardSHA, t.ForwardMode) {
974 if t.PublishTmp != "" {
975 _ = secureRemove(s.root, t.PublishTmp)
976 }
977 markCompensationStage(stages, t.Path, nil)
978 continue
979 }
980 // Crash window: publishTarget durably records Published before moving the
981 // target to its backup. A process death after that first rename leaves the
982 // target absent, the forward image in BackupPath, and (for writes) the
983 // publish temp still present. Recognize that owned intermediate state before
984 // classifying the absent target as an external modification.
985 if t.ForwardExisted && !cur.Existed && t.BackupPath != "" {
986 backup, backupErr := FingerprintPath(s.root, t.BackupPath)
987 publishPending := t.Action == "delete"
988 if t.Action == "write" && t.PublishTmp != "" {
989 publishPending, _ = securePathExists(s.root, t.PublishTmp)
990 }
991 if backupErr == nil && publishPending && fingerprintMatches(backup, true, t.ForwardSHA, t.ForwardMode) {
992 err = secureRename(s.root, t.BackupPath, t.AbsPath)
993 if err == nil && t.PublishTmp != "" {
994 if removeErr := secureRemove(s.root, t.PublishTmp); removeErr != nil && !os.IsNotExist(removeErr) {
995 err = removeErr
996 }
997 }
998 markCompensationStage(stages, t.Path, err)
999 if err != nil && first == nil {
1000 first = err
1001 }
1002 continue
1003 }
1004 }
1005 if t.ForwardExisted {
1006 data, lerr := s.loadBlobOrInline(t.ForwardBlob, t.ForwardInline)
1007 if lerr != nil && t.BackupPath != "" {
1008 data, lerr = secureReadFile(s.root, t.BackupPath)
1009 }
1010 if !fingerprintMatches(cur, t.RestoreExisted, t.RestoreSHA, t.RestoreMode) {
1011 if lerr == nil {
1012 suffix := t.RestoreSHA
1013 if len(suffix) > 8 {
1014 suffix = suffix[:8]
1015 }
1016 if suffix == "" {
1017 suffix = "unknown"
1018 }
1019 recov := t.AbsPath + ".reasonix-recovery-" + suffix
1020 _ = secureWriteNew(s.root, recov, data, os.FileMode(t.ForwardMode))
1021 err = fmt.Errorf("external modification after publish; recovery copy at %s", recov)
1022 } else {
1023 err = lerr
1024 }
1025 } else if backupExists, backupErr := securePathExists(s.root, t.BackupPath); backupErr == nil && backupExists {
1026 if cur.Existed {
1027 err = secureRemove(s.root, t.AbsPath)
1028 }
1029 if err == nil {
1030 err = secureRename(s.root, t.BackupPath, t.AbsPath)
1031 }
1032 } else if lerr != nil {
1033 err = lerr
1034 } else {
1035 mode := os.FileMode(0o644)
1036 if t.ForwardMode != 0 {
1037 mode = os.FileMode(t.ForwardMode)
1038 }
1039 if cur.Existed {
1040 if werr := secureRemove(s.root, t.AbsPath); werr != nil {
1041 err = werr
1042 }
1043 }
1044 tmp, _ := transactionSiblingPaths(t.AbsPath, newID("compensate"), 0)
1045 if werr := s.writePublishTemp(tmp, data, mode); werr != nil {
1046 err = werr
1047 } else if err == nil {
1048 if werr := secureRename(s.root, tmp, t.AbsPath); werr != nil {
1049 err = werr
1050 }
1051 }
1052 }
1053 } else {
1054 // Forward did not exist — remove what we published.
1055 if !fingerprintMatches(cur, t.RestoreExisted, t.RestoreSHA, t.RestoreMode) {
1056 // External rewrite of a file we restored then someone changed —
1057 // for compensate of delete action inverse: leave it.
1058 err = fmt.Errorf("external modification; not removing %s", t.AbsPath)
1059 } else {
1060 err = secureRemove(s.root, t.AbsPath)
1061 if os.IsNotExist(err) {
1062 err = nil
1063 }
1064 }
1065 }
1066 markCompensationStage(stages, t.Path, err)
1067 if err != nil && first == nil {
1068 first = err
1069 }
1070 }
1071 return first
1072 }
1073
1074 func fingerprintMatches(fp Fingerprint, existed bool, sha string, mode uint32) bool {
1075 if fp.Existed != existed {
1076 return false
1077 }
1078 if !existed {
1079 return true
1080 }
1081 if sha != "" && fp.SHA256 != sha {
1082 return false
1083 }
1084 return mode == 0 || fp.Mode == 0 || fp.Mode == mode
1085 }
1086
1087 func markCompensationStage(stages []FileStage, path string, err error) {
1088 for i := range stages {
1089 if stages[i].Path != path {
1090 continue
1091 }
1092 stages[i].Compensated = err == nil
1093 if err != nil {
1094 stages[i].CompError = err.Error()
1095 }
1096 }
1097 }
1098
1099 func (s *Store) failTransaction(tx *TransactionManifest, targets []TransactionTarget, stages []FileStage, cause error) error {
1100 return s.failTransactionAfterStateCompensation(tx, targets, stages, cause, nil)
1101 }
1102
1103 // failTransactionAfterStateCompensation compensates files and records whether
1104 // the conversation/checkpoint side was also restored. Any incomplete side keeps
1105 // the manifest committing so startup can retry the whole compensation.
1106 func (s *Store) failTransactionAfterStateCompensation(tx *TransactionManifest, targets []TransactionTarget, stages []FileStage, cause, stateCompensationErr error) error {
1107 if tx != nil {
1108 targets = tx.Targets
1109 }
1110 compensationErr := s.compensatePublished(targets, stages)
1111 combined := errors.Join(cause, stateCompensationErr)
1112 if compensationErr != nil {
1113 combined = errors.Join(combined, fmt.Errorf("compensation failed: %w", compensationErr))
1114 }
1115 if compensationErr != nil || stateCompensationErr != nil {
1116 // Do not make a failed compensation terminal. Startup recovery retries
1117 // committing manifests; marking this aborted would strand a half-applied
1118 // workspace permanently.
1119 tx.State = TxCommitting
1120 tx.Error = combined.Error()
1121 tx.UpdatedAt = time.Now()
1122 if persistErr := s.persistTransaction(tx); persistErr != nil {
1123 combined = errors.Join(combined, fmt.Errorf("persist pending compensation: %w", persistErr))
1124 }
1125 return combined
1126 }
1127 if abortErr := s.abortTransaction(tx, combined); abortErr != nil {
1128 combined = errors.Join(combined, fmt.Errorf("persist aborted transaction: %w", abortErr))
1129 }
1130 return combined
1131 }
1132
1133 func (s *Store) abortTransaction(tx *TransactionManifest, cause error) error {
1134 tx.State = TxAborted
1135 tx.Error = cause.Error()
1136 tx.UpdatedAt = time.Now()
1137 return s.persistTransaction(tx)
1138 }
1139
1140 func (s *Store) persistTransaction(tx *TransactionManifest) error {
1141 if s.dir == "" {
1142 return nil
1143 }
1144 return writeJSONAtomic(s.txManifestPath(tx.ID), tx)
1145 }
1146
1147 func (s *Store) txDir() string {
1148 if s.dir == "" {
1149 return filepath.Join(os.TempDir(), "reasonix-ckpt-tx")
1150 }
1151 return filepath.Join(s.dir, "transactions")
1152 }
1153
1154 func (s *Store) txManifestPath(id string) string {
1155 return filepath.Join(s.txDir(), id+".json")
1156 }
1157
1158 // RecoverTransactions scans for incomplete file-only transactions. Conversation
1159 // transactions are intentionally deferred until the controller has installed the
1160 // resumed session and can provide a ConversationApplier.
1161 func (s *Store) RecoverTransactions() []string {
1162 return s.recoverTransactions(nil)
1163 }
1164
1165 // RecoverTransactionsWithApplier finishes startup recovery after the resumed
1166 // conversation is live. A committing rewind first restores its forward
1167 // transcript/checkpoints, then compensates files; a committing undo first
1168 // reapplies its parent rewind, then compensates files. The manifest remains
1169 // committing if either side fails so a later startup can retry idempotently.
1170 func (s *Store) RecoverTransactionsWithApplier(applier ConversationApplier) []string {
1171 return s.recoverTransactions(applier)
1172 }
1173
1174 func (s *Store) recoverTransactions(applier ConversationApplier) []string {
1175 if s == nil || s.dir == "" {
1176 return nil
1177 }
1178 dir := s.txDir()
1179 ents, err := os.ReadDir(dir)
1180 if err != nil {
1181 return nil
1182 }
1183 undoneParents := map[string]bool{}
1184 for _, entry := range ents {
1185 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
1186 continue
1187 }
1188 var tx TransactionManifest
1189 if readJSONFile(filepath.Join(dir, entry.Name()), &tx) == nil && tx.State == TxCommitted && tx.Kind == "undo" && tx.ParentTransaction != "" {
1190 undoneParents[tx.ParentTransaction] = true
1191 }
1192 }
1193 var notes []string
1194 for _, e := range ents {
1195 if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
1196 continue
1197 }
1198 var tx TransactionManifest
1199 if err := readJSONFile(filepath.Join(dir, e.Name()), &tx); err != nil {
1200 continue
1201 }
1202 switch tx.State {
1203 case TxPrepared:
1204 // Never published — safe to discard.
1205 for _, target := range tx.Targets {
1206 if target.PublishTmp != "" {
1207 _ = secureRemove(s.root, target.PublishTmp)
1208 }
1209 }
1210 tx.State = TxAborted
1211 tx.Error = "abandoned prepared transaction on recovery"
1212 tx.UpdatedAt = time.Now()
1213 _ = s.persistTransaction(&tx)
1214 notes = append(notes, fmt.Sprintf("aborted prepared %s", tx.ID))
1215 case TxCommitting:
1216 needsConversation := tx.Scope == RewindConversation || tx.Scope == RewindBoth
1217 if needsConversation && applier == nil {
1218 notes = append(notes, fmt.Sprintf("deferred conversation recovery %s", tx.ID))
1219 continue
1220 }
1221 if needsConversation {
1222 var restoreErr error
1223 if tx.Kind != "undo" && tx.HasBoundary && len(tx.ConversationForward) == 0 {
1224 restoreErr = fmt.Errorf("missing forward conversation payload")
1225 } else if tx.Kind == "undo" {
1226 if tx.HasBoundary {
1227 restoreErr = errors.Join(restoreErr, applier.ApplyConversationTruncate(tx.BoundaryIndex, tx.ConversationForward))
1228 }
1229 restoreErr = errors.Join(restoreErr, applier.TruncateCheckpoints(tx.TruncateFrom))
1230 } else {
1231 restoreErr = s.restoreTransactionConversation(&tx, applier)
1232 }
1233 if restoreErr != nil {
1234 notes = append(notes, fmt.Sprintf("conversation recovery %s pending: %v", tx.ID, restoreErr))
1235 tx.Error = fmt.Sprintf("crash recovery conversation compensation pending: %v", restoreErr)
1236 tx.UpdatedAt = time.Now()
1237 _ = s.persistTransaction(&tx)
1238 continue
1239 }
1240 }
1241 // Compensate published files back to forward images.
1242 stages := make([]FileStage, len(tx.Targets))
1243 for i, t := range tx.Targets {
1244 stages[i] = FileStage{Path: t.Path, Phase: "compensate"}
1245 }
1246 if err := s.compensatePublished(tx.Targets, stages); err != nil {
1247 notes = append(notes, fmt.Sprintf("compensate %s: %v", tx.ID, err))
1248 tx.Error = fmt.Sprintf("crash recovery compensation pending: %v", err)
1249 tx.UpdatedAt = time.Now()
1250 _ = s.persistTransaction(&tx)
1251 } else {
1252 notes = append(notes, fmt.Sprintf("compensated committing %s", tx.ID))
1253 tx.State = TxAborted
1254 tx.Error = "compensated after crash during commit"
1255 tx.UpdatedAt = time.Now()
1256 _ = s.persistTransaction(&tx)
1257 }
1258 case TxCommitted:
1259 if tx.Kind == "undo" || undoneParents[tx.ID] {
1260 continue
1261 }
1262 // Keep as last undo if newer.
1263 s.mu.Lock()
1264 if s.lastUndo == nil || s.lastUndo.UpdatedAt.Before(tx.UpdatedAt) {
1265 cp := tx
1266 s.lastUndo = &cp
1267 }
1268 s.mu.Unlock()
1269 }
1270 }
1271 return notes
1272 }
1273
1274 func (s *Store) precheckFiles(fromTurn int) []RewindConflict {
1275 earliest := s.earliestRevisions(fromTurn)
1276 var conflicts []RewindConflict
1277 for p, rev := range earliest {
1278 abs, err := safePath(s.root, p)
1279 if err != nil {
1280 conflicts = append(conflicts, RewindConflict{Path: p, Reason: ConflictPathUnsafe})
1281 continue
1282 }
1283 if rev.BlobRef == "" && rev.Content == nil && rev.Existed {
1284 if rev.SHA256 != "" && s.blobs != nil && !s.blobs.Has(rev.SHA256) && (rev.BlobRef == "" || !s.blobs.Has(rev.BlobRef)) {
1285 conflicts = append(conflicts, RewindConflict{Path: p, Reason: ConflictMissingPayload, CheckpointSHA: rev.SHA256})
1286 continue
1287 }
1288 if rev.Content == nil && rev.BlobRef == "" {
1289 conflicts = append(conflicts, RewindConflict{Path: p, Reason: ConflictMissingPayload})
1290 continue
1291 }
1292 }
1293 fp, err := FingerprintPath(s.root, abs)
1294 if err != nil {
1295 // unreadable etc.
1296 conflicts = append(conflicts, RewindConflict{Path: p, Reason: ConflictExternalChange, CheckpointSHA: rev.SHA256})
1297 continue
1298 }
1299 if MatchesRestoreImage(fp, rev.SHA256, rev.Existed) {
1300 continue
1301 }
1302 // Prefer after fingerprint for conflict detection.
1303 reason := CompareIdentity(fp, rev.AfterSHA256, rev.AfterExisted, rev.AfterMode)
1304 if reason == ConflictCoverageLegacy {
1305 // Legacy handled at plan level; skip per-file for batch.
1306 continue
1307 }
1308 if reason != "" {
1309 conflicts = append(conflicts, RewindConflict{
1310 Path: p,
1311 Reason: reason,
1312 CheckpointSHA: rev.SHA256,
1313 LastOwnedSHA: rev.AfterSHA256,
1314 CurrentSHA: fp.SHA256,
1315 CheckpointMode: rev.Mode,
1316 CurrentMode: fp.Mode,
1317 CurrentExisted: fp.Existed,
1318 CheckpointExist: rev.Existed,
1319 })
1320 }
1321 }
1322 sort.Slice(conflicts, func(i, j int) bool { return conflicts[i].Path < conflicts[j].Path })
1323 return conflicts
1324 }
1325
1326 func (s *Store) earliestRevisions(fromTurn int) map[string]FileRevision {
1327 s.mu.Lock()
1328 defer s.mu.Unlock()
1329 return s.earliestRevisionsLocked(fromTurn)
1330 }
1331
1332 func (s *Store) earliestRevisionsLocked(fromTurn int) map[string]FileRevision {
1333 earliest := map[string]FileRevision{}
1334 for _, c := range s.all() {
1335 if c.Turn < fromTurn {
1336 continue
1337 }
1338 for _, rev := range c.revisions() {
1339 pathKey := NormalizeRelPath(s.root, rev.Path)
1340 if first, ok := earliest[pathKey]; ok {
1341 // Preserve the earliest preimage, but carry forward the final
1342 // mutation's ownership identity. Missing final identity deliberately
1343 // clears an older proof instead of authorizing an unsafe restore.
1344 first.AfterExisted = rev.AfterExisted
1345 first.AfterSHA256 = rev.AfterSHA256
1346 first.AfterMode = rev.AfterMode
1347 earliest[pathKey] = first
1348 continue
1349 }
1350 rev.Path = pathKey
1351 earliest[pathKey] = rev
1352 }
1353 }
1354 return earliest
1355 }
1356
1357 func (s *Store) filesFromTurnLocked(fromTurn int) []string {
1358 seen := map[string]bool{}
1359 var out []string
1360 for _, c := range s.all() {
1361 if c.Turn < fromTurn {
1362 continue
1363 }
1364 for _, rev := range c.revisions() {
1365 pathKey := NormalizeRelPath(s.root, rev.Path)
1366 if seen[pathKey] {
1367 continue
1368 }
1369 seen[pathKey] = true
1370 out = append(out, pathKey)
1371 }
1372 }
1373 sort.Strings(out)
1374 return out
1375 }
1376
1377 func (s *Store) coverageFromTurnLocked(fromTurn int) (Coverage, []CoverageGap, bool, bool) {
1378 var gaps []CoverageGap
1379 legacy := false
1380 expired := false
1381 hasFiles := false
1382 partial := false
1383 for _, c := range s.all() {
1384 if c.Turn < fromTurn {
1385 continue
1386 }
1387 if c.SchemaVersion < SchemaV2 && c.SchemaVersion != 0 {
1388 legacy = true
1389 }
1390 if c.SchemaVersion == 0 {
1391 // v1 had no schemaVersion field
1392 legacy = true
1393 }
1394 if c.Coverage == CoverageLegacy || c.Legacy {
1395 legacy = true
1396 }
1397 if c.ExpiredFilePayload {
1398 expired = true
1399 }
1400 if c.Coverage == CoveragePartial {
1401 partial = true
1402 }
1403 gaps = append(gaps, c.CoverageGaps...)
1404 if len(c.revisions()) > 0 {
1405 hasFiles = true
1406 }
1407 }
1408 if legacy {
1409 return CoverageLegacy, append(gaps, CoverageGap{Reason: GapLegacyUnverified}), true, expired
1410 }
1411 if expired {
1412 return CoveragePartial, append(gaps, CoverageGap{Reason: GapExpiredPayload}), false, true
1413 }
1414 if !hasFiles {
1415 if len(gaps) > 0 {
1416 return CoverageNone, gaps, false, false
1417 }
1418 return CoverageNone, nil, false, false
1419 }
1420 if partial || len(gaps) > 0 {
1421 return CoveragePartial, gaps, false, false
1422 }
1423 return CoverageComplete, nil, false, false
1424 }
1425
1426 func (s *Store) loadRevisionBytes(rev FileRevision) ([]byte, error) {
1427 if rev.BlobRef != "" && s.blobs != nil {
1428 return s.blobs.Get(rev.BlobRef)
1429 }
1430 if rev.Content != nil {
1431 return []byte(*rev.Content), nil
1432 }
1433 if rev.SHA256 != "" && s.blobs != nil && s.blobs.Has(rev.SHA256) {
1434 return s.blobs.Get(rev.SHA256)
1435 }
1436 return nil, fmt.Errorf("missing payload for %s", rev.Path)
1437 }
1438
1439 func (s *Store) loadBlobOrInline(ref string, inline []byte) ([]byte, error) {
1440 if ref != "" && s.blobs != nil {
1441 return s.blobs.Get(ref)
1442 }
1443 if inline != nil {
1444 return inline, nil
1445 }
1446 return nil, fmt.Errorf("missing blob %q", ref)
1447 }
1448
1449 func (s *Store) backupCheckpointsFrom(fromTurn int) ([]byte, error) {
1450 s.mu.Lock()
1451 defer s.mu.Unlock()
1452 var future []*Checkpoint
1453 for _, c := range s.all() {
1454 if c.Turn >= fromTurn {
1455 cp := *c
1456 future = append(future, &cp)
1457 }
1458 }
1459 return json.Marshal(future)
1460 }
1461
1462 func (s *Store) restoreCheckpointBackup(backup []byte) error {
1463 var future []*Checkpoint
1464 if err := json.Unmarshal(backup, &future); err != nil {
1465 return err
1466 }
1467 s.mu.Lock()
1468 defer s.mu.Unlock()
1469 // Merge future checkpoints back (by turn).
1470 byTurn := map[int]*Checkpoint{}
1471 for _, c := range s.done {
1472 byTurn[c.Turn] = c
1473 }
1474 if s.cur != nil {
1475 byTurn[s.cur.Turn] = s.cur
1476 }
1477 for _, c := range future {
1478 byTurn[c.Turn] = c
1479 if err := s.persist(c); err != nil {
1480 return fmt.Errorf("persist restored checkpoint turn %d: %w", c.Turn, err)
1481 }
1482 counterpart := filepath.Join(s.expiredDir(), fmt.Sprintf("turn-%d.json", c.Turn))
1483 if c.ExpiredFilePayload {
1484 counterpart = filepath.Join(s.dir, fmt.Sprintf("turn-%d.json", c.Turn))
1485 }
1486 if err := os.Remove(counterpart); err != nil && !os.IsNotExist(err) {
1487 return fmt.Errorf("remove stale checkpoint counterpart turn %d: %w", c.Turn, err)
1488 }
1489 }
1490 // Rebuild done/cur: highest turn as cur if it was cur; else all in done.
1491 turns := make([]int, 0, len(byTurn))
1492 for t := range byTurn {
1493 turns = append(turns, t)
1494 }
1495 sort.Ints(turns)
1496 s.done = nil
1497 s.cur = nil
1498 for _, t := range turns {
1499 s.done = append(s.done, byTurn[t])
1500 }
1501 return nil
1502 }
1503
1504 func newID(prefix string) string {
1505 return fmt.Sprintf("%s-%d-%s", prefix, time.Now().UnixNano(), Digest(fmt.Appendf(nil, "%d", time.Now().UnixNano()))[:8])
1506 }
1507
1508 // RestoreCheckpointBackupPublic reloads backed-up checkpoints after an undo.
1509 func (s *Store) RestoreCheckpointBackupPublic(backup []byte) error {
1510 return s.restoreCheckpointBackup(backup)
1511 }
1512
1513 // PrepareFileRevert prepares a single-file restore to the earliest session preimage.
1514 func (s *Store) PrepareFileRevert(path string, sessionRev int64) (RewindPlan, error) {
1515 if s == nil {
1516 return RewindPlan{}, fmt.Errorf("checkpoints unavailable")
1517 }
1518 plan := RewindPlan{
1519 PlanID: newID("plan"),
1520 Scope: RewindCode,
1521 Path: path,
1522 SessionRevision: sessionRev,
1523 CreatedAt: time.Now(),
1524 WorkspaceToken: fmt.Sprintf("%d", s.barrier.Generation()),
1525 Files: []string{path},
1526 FileCount: 1,
1527 }
1528 state, ok := s.FileState(path)
1529 if !ok {
1530 plan.CanFiles = false
1531 plan.DisabledReason = "file is not session-owned"
1532 return plan, nil
1533 }
1534 _ = state
1535 abs, err := safePath(s.root, path)
1536 if err != nil {
1537 plan.CanFiles = false
1538 plan.DisabledReason = "path unsafe"
1539 plan.Conflicts = []RewindConflict{{Path: path, Reason: ConflictPathUnsafe}}
1540 return plan, nil
1541 }
1542 revs := s.earliestRevisions(0)
1543 rev, has := revs[path]
1544 if !has {
1545 for p, r := range revs {
1546 if ap, e := safePath(s.root, p); e == nil && ap == abs {
1547 rev, has = r, true
1548 plan.Path = p
1549 break
1550 }
1551 }
1552 }
1553 if !has {
1554 plan.CanFiles = false
1555 plan.DisabledReason = "file is not session-owned"
1556 return plan, nil
1557 }
1558 if rev.AfterExisted == nil && rev.AfterSHA256 == "" {
1559 // A v1 or incomplete capture has a preimage but no evidence that the
1560 // current file is still the session's last write. Do not turn the
1561 // generic conflict-overwrite affordance into an unsafe legacy restore.
1562 plan.PlanID = ""
1563 plan.CanFiles = false
1564 plan.Legacy = true
1565 plan.Coverage = CoverageLegacy
1566 plan.DisabledReason = "legacy checkpoint cannot verify later manual edits"
1567 return plan, nil
1568 }
1569 fp, fperr := FingerprintPath(s.root, abs)
1570 if fperr == nil {
1571 reason := CompareIdentity(fp, rev.AfterSHA256, rev.AfterExisted, rev.AfterMode)
1572 if reason != "" {
1573 plan.Conflicts = []RewindConflict{{
1574 Path: path, Reason: reason,
1575 CheckpointSHA: rev.SHA256, LastOwnedSHA: rev.AfterSHA256, CurrentSHA: fp.SHA256,
1576 CurrentExisted: fp.Existed, CheckpointExist: rev.Existed,
1577 }}
1578 plan.CanFiles = true
1579 plan.DisabledReason = "conflict requires explicit resolution"
1580 } else {
1581 plan.CanFiles = true
1582 }
1583 } else {
1584 plan.CanFiles = false
1585 plan.DisabledReason = "current file identity unavailable"
1586 plan.Conflicts = []RewindConflict{{Path: path, Reason: ConflictExternalChange}}
1587 }
1588 if rev.BlobRef == "" && rev.Content == nil && rev.Existed {
1589 plan.CanFiles = false
1590 plan.DisabledReason = "missing file payload"
1591 plan.Conflicts = append(plan.Conflicts, RewindConflict{Path: path, Reason: ConflictMissingPayload})
1592 }
1593 s.mu.Lock()
1594 if s.plans == nil {
1595 s.plans = map[string]preparedPlan{}
1596 }
1597 s.plans[plan.PlanID] = preparedPlan{plan: plan, created: time.Now(), previewFingerprint: &fp}
1598 s.mu.Unlock()
1599 return plan, nil
1600 }
1601
1602 // CommitFileRevert commits a single-file restore.
1603 func (s *Store) CommitFileRevert(planID string, resolution ConflictResolution) (RewindResult, error) {
1604 if s == nil {
1605 return RewindResult{}, fmt.Errorf("checkpoints unavailable")
1606 }
1607 s.mu.Lock()
1608 pp, ok := s.plans[planID]
1609 if ok {
1610 delete(s.plans, planID)
1611 }
1612 s.mu.Unlock()
1613 if !ok {
1614 return RewindResult{OK: false, Error: "unknown or expired plan"}, fmt.Errorf("unknown or expired plan")
1615 }
1616 plan := pp.plan
1617 if plan.Path == "" {
1618 return RewindResult{OK: false, Error: "not a file plan"}, fmt.Errorf("not a file plan")
1619 }
1620 if !plan.CanFiles {
1621 return RewindResult{OK: false, Error: plan.DisabledReason, Conflicts: plan.Conflicts}, fmt.Errorf("%s", plan.DisabledReason)
1622 }
1623 if len(plan.Conflicts) > 0 && resolution != ResolveOverwriteCheckpoint {
1624 if resolution == ResolveKeepCurrent {
1625 return RewindResult{OK: true, UndoAvailable: false}, nil
1626 }
1627 return RewindResult{OK: false, Error: "conflict requires explicit resolution", Conflicts: plan.Conflicts}, fmt.Errorf("conflict requires explicit resolution")
1628 }
1629 if !s.barrier.TryEnterExclusive() {
1630 err := fmt.Errorf("workspace mutation in progress")
1631 return RewindResult{OK: false, Error: err.Error(), Conflicts: []RewindConflict{{Path: plan.Path, Reason: ConflictBusyWriter}}}, err
1632 }
1633 defer s.barrier.ExitExclusive()
1634 if conflicts := s.activeWriterConflicts(); len(conflicts) > 0 {
1635 err := fmt.Errorf("active background writer")
1636 return RewindResult{OK: false, Error: err.Error(), Conflicts: conflicts}, err
1637 }
1638 if plan.WorkspaceToken != fmt.Sprintf("%d", s.barrier.Generation()) {
1639 conflict := RewindConflict{Path: plan.Path, Reason: ConflictStalePlan}
1640 return RewindResult{OK: false, Error: "workspace changed since preview", Conflicts: []RewindConflict{conflict}}, fmt.Errorf("workspace changed since preview")
1641 }
1642 absPreview, err := safePath(s.root, plan.Path)
1643 if err != nil {
1644 return RewindResult{OK: false, Error: err.Error()}, err
1645 }
1646 current, err := FingerprintPath(s.root, absPreview)
1647 if err != nil || pp.previewFingerprint == nil || !sameFingerprint(current, *pp.previewFingerprint) {
1648 conflict := RewindConflict{Path: plan.Path, Reason: ConflictStalePlan, CurrentSHA: current.SHA256, CurrentExisted: current.Existed}
1649 return RewindResult{OK: false, Error: "file changed since preview; preview again", Conflicts: []RewindConflict{conflict}}, fmt.Errorf("file changed since preview; preview again")
1650 }
1651
1652 // Restore via restoreCodeLegacy for the single earliest path using turn 0.
1653 // Build synthetic order of one path.
1654 revs := s.earliestRevisions(0)
1655 rev, has := revs[plan.Path]
1656 if !has {
1657 abs, _ := safePath(s.root, plan.Path)
1658 for p, r := range revs {
1659 if ap, e := safePath(s.root, p); e == nil && ap == abs {
1660 rev, has = r, true
1661 plan.Path = p
1662 break
1663 }
1664 }
1665 }
1666 if !has {
1667 return RewindResult{OK: false, Error: "file is not session-owned"}, fmt.Errorf("file is not session-owned")
1668 }
1669
1670 // Find which turn first touched this path for RestoreCode semantics:
1671 // restoring one file = write earliest preimage (not all files from a turn).
1672 abs, err := safePath(s.root, rev.Path)
1673 if err != nil {
1674 return RewindResult{OK: false, Error: err.Error()}, err
1675 }
1676 fwd, _, _ := CapturePath(abs, CaptureOptions{WorkspaceRoot: s.root, ReadContent: true})
1677 tx := &TransactionManifest{
1678 SchemaVersion: SchemaV2,
1679 ID: newID("tx"),
1680 WorkspaceRoot: s.root,
1681 State: TxPrepared,
1682 Kind: "file_revert",
1683 Scope: RewindCode,
1684 Path: rev.Path,
1685 CreatedAt: time.Now(),
1686 UpdatedAt: time.Now(),
1687 }
1688 t := TransactionTarget{
1689 Path: rev.Path, AbsPath: abs,
1690 RestoreExisted: rev.Existed, RestoreMode: rev.Mode, RestoreSHA: rev.SHA256, RestoreBlob: rev.BlobRef, RestoreEncoding: rev.Encoding,
1691 ForwardExisted: fwd.Existed, ForwardMode: fwd.Mode, ForwardSHA: fwd.SHA256,
1692 }
1693 t.PublishTmp, t.BackupPath = transactionSiblingPaths(abs, tx.ID, 0)
1694 if rev.Existed {
1695 t.Action = "write"
1696 data, lerr := s.loadRevisionBytes(rev)
1697 if lerr != nil && rev.Content != nil {
1698 enc := fileenc.UTF8
1699 if rev.Encoding != nil {
1700 enc = *rev.Encoding
1701 } else if current := s.detectCurrentEncoding(abs); current != nil {
1702 enc = *current
1703 }
1704 data = fileenc.Encode(*rev.Content, enc)
1705 lerr = nil
1706 }
1707 if lerr != nil {
1708 return RewindResult{OK: false, Error: lerr.Error()}, lerr
1709 }
1710 mode := os.FileMode(0o644)
1711 if rev.Mode != 0 {
1712 mode = os.FileMode(rev.Mode)
1713 }
1714 if t.RestoreBlob == "" && s.blobs != nil {
1715 ref, perr := s.blobs.Put(data)
1716 if perr != nil {
1717 return RewindResult{OK: false, Error: perr.Error()}, perr
1718 }
1719 t.RestoreBlob = ref
1720 } else if t.RestoreBlob == "" {
1721 t.RestoreInline = clonePayload(data)
1722 }
1723 if err := s.writePublishTemp(t.PublishTmp, data, mode); err != nil {
1724 return RewindResult{OK: false, Error: err.Error()}, err
1725 }
1726 } else {
1727 t.Action = "delete"
1728 t.PublishTmp = ""
1729 }
1730 if fwd.Existed && s.blobs != nil {
1731 ref, perr := s.blobs.Put(fwd.Content)
1732 if perr != nil {
1733 return RewindResult{OK: false, Error: perr.Error()}, perr
1734 }
1735 t.ForwardBlob = ref
1736 } else if fwd.Existed {
1737 t.ForwardInline = clonePayload(fwd.Content)
1738 }
1739 tx.Targets = []TransactionTarget{t}
1740 if err := s.persistTransaction(tx); err != nil {
1741 return RewindResult{OK: false, Error: err.Error()}, err
1742 }
1743 return s.commitTransaction(tx, nil, nil)
1744 }
1745
1746 func sameFingerprint(a, b Fingerprint) bool {
1747 return a.Existed == b.Existed && a.IsDir == b.IsDir && a.IsSymlink == b.IsSymlink &&
1748 a.Nlink == b.Nlink && a.Mode == b.Mode && a.Size == b.Size && a.SHA256 == b.SHA256
1749 }
1750
1751 func clonePayload(data []byte) []byte {
1752 out := make([]byte, len(data))
1753 copy(out, data)
1754 return out
1755 }
1756
1756 lines GO