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