返回 DeepSeek-Reasonix
transaction_test.go
根目录 / internal / checkpoint / transaction_test.go
1 package checkpoint
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "testing"
10 "time"
11
12 "reasonix/internal/diff"
13 )
14
15 type recordingConversationApplier struct {
16 conversation []byte
17 checkpoints []byte
18 }
19
20 func (a *recordingConversationApplier) ApplyConversationTruncate(_ int, _ []byte) error {
21 a.conversation = []byte("truncated")
22 return nil
23 }
24
25 func (a *recordingConversationApplier) RestoreConversation(forward []byte) error {
26 a.conversation = append([]byte(nil), forward...)
27 return nil
28 }
29
30 func (a *recordingConversationApplier) TruncateCheckpoints(_ int) error {
31 a.checkpoints = []byte("truncated")
32 return nil
33 }
34
35 func (a *recordingConversationApplier) RestoreCheckpoints(backup []byte) error {
36 a.checkpoints = append([]byte(nil), backup...)
37 return nil
38 }
39
40 func TestRestoreCodeAllOrNothingOnMidPublishFailure(t *testing.T) {
41 root := t.TempDir()
42 a := filepath.Join(root, "a.txt")
43 b := filepath.Join(root, "b.txt")
44 write(t, a, "a0")
45 write(t, b, "b0")
46
47 s := New("", root)
48 s.Begin(0, "edit both", 0)
49 s.Snapshot(diffChange(a, "a0"))
50 s.Snapshot(diffChange(b, "b0"))
51 write(t, a, "a1")
52 write(t, b, "b1")
53
54 plan, err := s.PrepareRewind(0, RewindCode, 1, 0, false)
55 if err != nil {
56 t.Fatal(err)
57 }
58 plan.CanFiles = true
59 plan.Conflicts = nil
60 plan.DisabledReason = ""
61 s.mu.Lock()
62 s.plans[plan.PlanID] = preparedPlan{plan: plan, created: plan.CreatedAt}
63 s.mu.Unlock()
64
65 _, err = s.CommitRewindWithForward(plan.PlanID, nil, nil, &InjectFail{Phase: "publish_file", AfterFiles: 1})
66 if err == nil {
67 t.Fatal("expected injected failure")
68 }
69
70 if got := read(t, a); got != "a1" {
71 t.Fatalf("a = %q, want a1 (compensated)", got)
72 }
73 if got := read(t, b); got != "b1" {
74 t.Fatalf("b = %q, want b1 (compensated)", got)
75 }
76 if leftovers, err := filepath.Glob(filepath.Join(root, ".*.reasonix-*")); err != nil || len(leftovers) != 0 {
77 t.Fatalf("transaction artifacts remain after compensation: %v err=%v", leftovers, err)
78 }
79 }
80
81 func TestRecoverCommittingTransaction(t *testing.T) {
82 root := t.TempDir()
83 dir := filepath.Join(t.TempDir(), "sess.ckpt")
84 a := filepath.Join(root, "a.txt")
85 write(t, a, "v0")
86
87 s := New(dir, root)
88 s.Begin(0, "p", 0)
89 s.Snapshot(diffChange(a, "v0"))
90 write(t, a, "v1")
91
92 tx := &TransactionManifest{
93 SchemaVersion: SchemaV2,
94 ID: "tx-crash",
95 WorkspaceRoot: root,
96 State: TxCommitting,
97 Kind: "rewind",
98 Turn: 0,
99 Scope: RewindCode,
100 Targets: []TransactionTarget{{
101 Path: a,
102 AbsPath: a,
103 Action: "write",
104 Published: true,
105 RestoreExisted: true,
106 RestoreSHA: Digest([]byte("v0")),
107 ForwardExisted: true,
108 ForwardSHA: Digest([]byte("v1")),
109 }},
110 }
111 ref, err := s.blobs.Put([]byte("v1"))
112 if err != nil {
113 t.Fatal(err)
114 }
115 tx.Targets[0].ForwardBlob = ref
116 if err := os.WriteFile(a, []byte("v0"), 0o644); err != nil {
117 t.Fatal(err)
118 }
119 if err := s.persistTransaction(tx); err != nil {
120 t.Fatal(err)
121 }
122
123 s2 := New(dir, root)
124 _ = s2.RecoverTransactions()
125 if got := read(t, a); got != "v1" {
126 t.Fatalf("after recovery a = %q, want v1", got)
127 }
128 }
129
130 func TestRecoverCrashAfterPublishBeforeProgressPersistence(t *testing.T) {
131 root := t.TempDir()
132 dir := filepath.Join(t.TempDir(), "sess.ckpt")
133 a := filepath.Join(root, "a.txt")
134 write(t, a, "before")
135 s := New(dir, root)
136 s.Begin(0, "edit", 0)
137 s.CaptureBefore(a, CaptureBeforeOpts{Source: CaptureBeforeMutation})
138 write(t, a, "after")
139 s.CaptureAfter(a, CaptureAfterOpts{Seq: 1, Source: CaptureAfterMutation})
140
141 plan, err := s.PrepareRewind(0, RewindCode, 1, 0, false)
142 if err != nil || !plan.CanFiles {
143 t.Fatalf("prepare: plan=%+v err=%v", plan, err)
144 }
145 if _, err := s.CommitRewindWithForward(plan.PlanID, nil, nil, &InjectFail{Phase: "after_publish_before_progress", AfterFiles: 0}); err == nil {
146 t.Fatal("expected simulated crash")
147 }
148 if got := read(t, a); got != "before" {
149 t.Fatalf("simulated crash did not occur after publish: %q", got)
150 }
151
152 _ = New(dir, root) // startup recovery runs while loading the store
153 if got := read(t, a); got != "after" {
154 t.Fatalf("crash recovery left partial rewind: got %q want after", got)
155 }
156 }
157
158 func TestRecoverCrashAfterConversationRestoresBothSidesBeforeFileCompensation(t *testing.T) {
159 root := t.TempDir()
160 dir := filepath.Join(t.TempDir(), "sess.ckpt")
161 a := filepath.Join(root, "a.txt")
162 write(t, a, "before")
163 s := New(dir, root)
164 s.Begin(0, "edit", 1)
165 s.CaptureBefore(a, CaptureBeforeOpts{Source: CaptureBeforeMutation})
166 write(t, a, "after")
167 s.CaptureAfter(a, CaptureAfterOpts{Seq: 1, Source: CaptureAfterMutation})
168
169 plan, err := s.PrepareRewind(0, RewindBoth, 1, 1, true)
170 if err != nil || !plan.CanFiles || !plan.CanConversation {
171 t.Fatalf("prepare: plan=%+v err=%v", plan, err)
172 }
173 forward, _ := json.Marshal([]string{"full conversation"})
174 applier := &recordingConversationApplier{conversation: append([]byte(nil), forward...)}
175 if _, err := s.CommitRewindWithForward(plan.PlanID, forward, applier, &InjectFail{Phase: "after_conversation_before_finalize"}); err == nil {
176 t.Fatal("expected simulated crash")
177 }
178 if got := read(t, a); got != "before" {
179 t.Fatalf("crash point file = %q, want published rewind", got)
180 }
181 if string(applier.conversation) != "truncated" || string(applier.checkpoints) != "truncated" {
182 t.Fatalf("crash point did not include conversation mutation: conversation=%q checkpoints=%q", applier.conversation, applier.checkpoints)
183 }
184
185 s2 := New(dir, root)
186 if got := read(t, a); got != "before" {
187 t.Fatalf("store-only startup must defer combined recovery, got file %q", got)
188 }
189 recovered := &recordingConversationApplier{conversation: []byte("truncated"), checkpoints: []byte("truncated")}
190 notes := s2.RecoverTransactionsWithApplier(recovered)
191 if len(notes) == 0 {
192 t.Fatal("expected a recovery note")
193 }
194 if got := read(t, a); got != "after" {
195 t.Fatalf("recovery file = %q, want forward image", got)
196 }
197 if !bytes.Equal(recovered.conversation, forward) {
198 t.Fatalf("conversation recovery = %q, want %q", recovered.conversation, forward)
199 }
200 if len(recovered.checkpoints) == 0 || bytes.Equal(recovered.checkpoints, []byte("truncated")) {
201 t.Fatalf("checkpoint backup was not restored: %q", recovered.checkpoints)
202 }
203 var manifest TransactionManifest
204 if err := readJSONFile(s2.txManifestPath(planTransactionID(t, dir)), &manifest); err != nil {
205 t.Fatal(err)
206 }
207 if manifest.State != TxAborted {
208 t.Fatalf("recovered transaction state = %s, want aborted", manifest.State)
209 }
210 }
211
212 func planTransactionID(t *testing.T, dir string) string {
213 t.Helper()
214 entries, err := os.ReadDir(filepath.Join(dir, "transactions"))
215 if err != nil {
216 t.Fatal(err)
217 }
218 if len(entries) != 1 {
219 t.Fatalf("transaction manifests = %d, want 1", len(entries))
220 }
221 return entries[0].Name()[:len(entries[0].Name())-len(".json")]
222 }
223
224 func TestBackgroundWriterStartingAfterPreviewBlocksCommit(t *testing.T) {
225 root := t.TempDir()
226 a := filepath.Join(root, "a.txt")
227 write(t, a, "before")
228 s := New("", root)
229 s.Begin(0, "edit", 0)
230 s.CaptureBefore(a, CaptureBeforeOpts{Source: CaptureBeforeMutation})
231 write(t, a, "after")
232 s.CaptureAfter(a, CaptureAfterOpts{Seq: 1, Source: CaptureAfterMutation})
233 plan, err := s.PrepareRewind(0, RewindCode, 1, 0, false)
234 if err != nil || !plan.CanFiles {
235 t.Fatalf("prepare: plan=%+v err=%v", plan, err)
236 }
237 observer := NewMutationObserver(ObserverOptions{Store: s})
238 if err := observer.RegisterWriter("bg-1", "background_subagent", 0); err != nil {
239 t.Fatal(err)
240 }
241 result, err := s.CommitRewindWithForward(plan.PlanID, nil, nil, nil)
242 if err == nil || len(result.Conflicts) == 0 || result.Conflicts[0].Reason != ConflictBusyWriter {
243 t.Fatalf("commit during background writer: result=%+v err=%v", result, err)
244 }
245 if got := read(t, a); got != "after" {
246 t.Fatalf("blocked commit changed file to %q", got)
247 }
248 observer.UnregisterWriter("bg-1")
249 fresh, err := s.PrepareRewind(0, RewindCode, 1, 0, false)
250 if err != nil || !fresh.CanFiles {
251 t.Fatalf("fresh prepare after writer: plan=%+v err=%v", fresh, err)
252 }
253 if result, err := s.CommitRewindWithForward(fresh.PlanID, nil, nil, nil); err != nil || !result.OK {
254 t.Fatalf("commit after writer: result=%+v err=%v", result, err)
255 }
256 }
257
258 func TestCaptureScratchPathIsNotOutsideWorkspace(t *testing.T) {
259 root := t.TempDir()
260 scratchPath := filepath.Join(os.TempDir(), "reasonix-capture-probe.py")
261 _, gap, err := CapturePath(scratchPath, CaptureOptions{WorkspaceRoot: root, ReadContent: true})
262 if err != nil || gap == nil || gap.Reason != GapScratch {
263 t.Fatalf("scratch capture: gap=%+v err=%v", gap, err)
264 }
265 }
266
267 func TestCaptureScratchSymlinkIntoWorkspaceStaysProjectGap(t *testing.T) {
268 root := t.TempDir()
269 scratch := t.TempDir()
270 link := filepath.Join(scratch, "workspace-link")
271 if err := os.Symlink(root, link); err != nil {
272 t.Skipf("symlink unavailable: %v", err)
273 }
274 path := filepath.Join(link, "probe.py")
275 _, gap, err := CapturePath(path, CaptureOptions{WorkspaceRoot: root, ReadContent: true})
276 if err == nil || gap == nil || gap.Reason == GapScratch || !HasProjectCoverageGap([]CoverageGap{*gap}) {
277 t.Fatalf("scratch alias capture: gap=%+v err=%v, want a project coverage gap", gap, err)
278 }
279 }
280
281 func TestCaptureRejectsAncestorSymlink(t *testing.T) {
282 root := t.TempDir()
283 outside := t.TempDir()
284 write(t, filepath.Join(outside, "secret.txt"), "secret")
285 if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
286 t.Skipf("symlink unavailable: %v", err)
287 }
288 _, gap, err := CapturePath(filepath.Join(root, "link", "secret.txt"), CaptureOptions{WorkspaceRoot: root, ReadContent: true})
289 if err == nil || gap == nil || gap.Reason != GapSymlink {
290 t.Fatalf("ancestor symlink capture: gap=%+v err=%v", gap, err)
291 }
292 }
293
294 func TestPublishRejectsAncestorSwappedToSymlink(t *testing.T) {
295 root := t.TempDir()
296 out := t.TempDir()
297 dir := filepath.Join(root, "dir")
298 target := filepath.Join(dir, "a.txt")
299 write(t, target, "inside")
300 write(t, filepath.Join(out, "a.txt"), "outside")
301 s := New("", root)
302 tmp, backup := transactionSiblingPaths(target, "swap", 0)
303 if err := s.writePublishTemp(tmp, []byte("rewound"), 0o644); err != nil {
304 t.Fatal(err)
305 }
306 moved := filepath.Join(root, "moved")
307 if err := os.Rename(dir, moved); err != nil {
308 t.Fatal(err)
309 }
310 if err := os.Symlink(out, dir); err != nil {
311 t.Skipf("symlink unavailable: %v", err)
312 }
313 targetSpec := &TransactionTarget{Path: "dir/a.txt", AbsPath: target, PublishTmp: tmp, BackupPath: backup, Action: "write", RestoreMode: 0o644}
314 if err := s.publishTarget(targetSpec); err == nil {
315 t.Fatal("publish through swapped ancestor symlink succeeded")
316 }
317 if got := read(t, filepath.Join(out, "a.txt")); got != "outside" {
318 t.Fatalf("outside file changed to %q", got)
319 }
320 if got := read(t, filepath.Join(moved, "a.txt")); got != "inside" {
321 t.Fatalf("original workspace file changed to %q", got)
322 }
323 }
324
325 func TestFileRevertRejectsStalePreviewEvenWithOldOverwriteApproval(t *testing.T) {
326 root := t.TempDir()
327 a := filepath.Join(root, "a.txt")
328 write(t, a, "before")
329 s := New("", root)
330 s.Begin(0, "edit", 0)
331 s.CaptureBefore(a, CaptureBeforeOpts{Source: CaptureBeforeMutation})
332 write(t, a, "owned")
333 s.CaptureAfter(a, CaptureAfterOpts{Seq: 1, Source: CaptureAfterMutation})
334
335 plan, err := s.PrepareFileRevert(a, 1)
336 if err != nil || !plan.CanFiles {
337 t.Fatalf("prepare: plan=%+v err=%v", plan, err)
338 }
339 write(t, a, "external")
340 if _, err := s.CommitFileRevert(plan.PlanID, ResolveOverwriteCheckpoint); err == nil {
341 t.Fatal("stale overwrite approval must not authorize a later external edit")
342 }
343 if got := read(t, a); got != "external" {
344 t.Fatalf("stale commit changed file to %q", got)
345 }
346
347 fresh, err := s.PrepareFileRevert(a, 1)
348 if err != nil || len(fresh.Conflicts) == 0 {
349 t.Fatalf("fresh preview should expose external conflict: plan=%+v err=%v", fresh, err)
350 }
351 result, err := s.CommitFileRevert(fresh.PlanID, ResolveOverwriteCheckpoint)
352 if err != nil || !result.OK {
353 t.Fatalf("fresh explicit overwrite failed: result=%+v err=%v", result, err)
354 }
355 if got := read(t, a); got != "before" {
356 t.Fatalf("fresh confirmed revert = %q, want before", got)
357 }
358 }
359
360 func TestUndoRestoresEmptyForwardFile(t *testing.T) {
361 root := t.TempDir()
362 a := filepath.Join(root, "a.txt")
363 write(t, a, "before")
364 s := New("", root)
365 s.Begin(0, "empty", 0)
366 s.CaptureBefore(a, CaptureBeforeOpts{Source: CaptureBeforeMutation})
367 write(t, a, "")
368 s.CaptureAfter(a, CaptureAfterOpts{Seq: 1, Source: CaptureAfterMutation})
369 plan, err := s.PrepareRewind(0, RewindCode, 1, 0, false)
370 if err != nil || !plan.CanFiles {
371 t.Fatalf("prepare: plan=%+v err=%v", plan, err)
372 }
373 result, err := s.CommitRewindWithForward(plan.PlanID, nil, nil, nil)
374 if err != nil || !result.OK {
375 t.Fatalf("rewind: result=%+v err=%v", result, err)
376 }
377 undo, err := s.UndoRewind(result.TransactionID, nil)
378 if err != nil || !undo.OK {
379 t.Fatalf("undo: result=%+v err=%v", undo, err)
380 }
381 if got := read(t, a); got != "" {
382 t.Fatalf("undo restored %q, want empty file", got)
383 }
384 }
385
386 func TestPrecheckDetectsManualEdit(t *testing.T) {
387 root := t.TempDir()
388 a := filepath.Join(root, "a.txt")
389 write(t, a, "v0")
390 s := New("", root)
391 s.Begin(0, "p", 0)
392 s.CaptureBeforeFromChange(diffChange(a, "v0"), CaptureBeforeOpts{Source: CapturePreviewer})
393 write(t, a, "v1")
394 s.CaptureAfter(a, CaptureAfterOpts{Seq: 1, Source: CaptureAfterMutation})
395 write(t, a, "manual")
396
397 plan, err := s.PrepareRewind(0, RewindCode, 1, 0, false)
398 if err != nil {
399 t.Fatal(err)
400 }
401 if plan.CanFiles {
402 t.Fatalf("expected CanFiles=false on manual edit, plan=%+v", plan)
403 }
404 if len(plan.Conflicts) == 0 {
405 t.Fatal("expected conflicts")
406 }
407 if _, err := s.CommitRewindWithForward(plan.PlanID, nil, nil, nil); err == nil {
408 t.Fatal("commit should fail")
409 }
410 if got := read(t, a); got != "manual" {
411 t.Fatalf("a = %q, want manual", got)
412 }
413 }
414
415 func TestTransactionCrashRecoveryPreparedIsAbandoned(t *testing.T) {
416 root := t.TempDir()
417 dir := filepath.Join(t.TempDir(), "sess.ckpt")
418 s := New(dir, root)
419 tx := &TransactionManifest{
420 SchemaVersion: SchemaV2,
421 ID: "tx-prep",
422 WorkspaceRoot: root,
423 State: TxPrepared,
424 Kind: "rewind",
425 }
426 if err := s.persistTransaction(tx); err != nil {
427 t.Fatal(err)
428 }
429 _ = New(dir, root)
430 var loaded TransactionManifest
431 if err := readJSONFile(s.txManifestPath("tx-prep"), &loaded); err != nil {
432 t.Fatal(err)
433 }
434 if loaded.State != TxAborted {
435 t.Fatalf("state = %s, want aborted", loaded.State)
436 }
437 }
438
439 func TestCompensationRecoversCrashBetweenBackupAndPublishRenames(t *testing.T) {
440 root := t.TempDir()
441 target := filepath.Join(root, "a.txt")
442 write(t, target, "forward")
443 info, err := os.Stat(target)
444 if err != nil {
445 t.Fatal(err)
446 }
447 mode := uint32(info.Mode().Perm())
448 publish, backup := transactionSiblingPaths(target, "tx-crash-gap", 0)
449 write(t, publish, "restore")
450 if err := os.Rename(target, backup); err != nil {
451 t.Fatal(err)
452 }
453
454 targetSpec := TransactionTarget{
455 Path: "a.txt", AbsPath: target, Action: "write", Published: true,
456 RestoreExisted: true, RestoreSHA: Digest([]byte("restore")), RestoreMode: mode,
457 ForwardExisted: true, ForwardSHA: Digest([]byte("forward")), ForwardMode: mode,
458 ForwardInline: []byte("forward"), PublishTmp: publish, BackupPath: backup,
459 }
460 store := New("", root)
461 if err := store.compensatePublished([]TransactionTarget{targetSpec}, []FileStage{{Path: "a.txt"}}); err != nil {
462 t.Fatalf("compensate crash gap: %v", err)
463 }
464 if got := read(t, target); got != "forward" {
465 t.Fatalf("target after compensation = %q, want forward", got)
466 }
467 if _, err := os.Stat(publish); !os.IsNotExist(err) {
468 t.Fatalf("publish temp remains after compensation: %v", err)
469 }
470 }
471
472 func TestLegacyFileRevertIsRefusedWithoutOwnershipFingerprint(t *testing.T) {
473 root := t.TempDir()
474 dir := t.TempDir()
475 path := filepath.Join(root, "a.txt")
476 write(t, path, "manual")
477 before := "before"
478 legacy := Checkpoint{Turn: 0, Time: time.Now(), Files: []FileSnap{{Path: "a.txt", Content: &before}}}
479 raw, err := json.Marshal(legacy)
480 if err != nil {
481 t.Fatal(err)
482 }
483 if err := os.WriteFile(filepath.Join(dir, "turn-0.json"), raw, 0o644); err != nil {
484 t.Fatal(err)
485 }
486
487 store := New(dir, root)
488 plan, err := store.PrepareFileRevert("a.txt", 1)
489 if err != nil {
490 t.Fatal(err)
491 }
492 if plan.CanFiles || plan.PlanID != "" {
493 t.Fatalf("legacy single-file revert was authorized: %+v", plan)
494 }
495 if _, err := store.CommitFileRevert(plan.PlanID, ResolveOverwriteCheckpoint); err == nil {
496 t.Fatal("legacy file revert commit must be refused")
497 }
498 if got := read(t, path); got != "manual" {
499 t.Fatalf("legacy refusal changed file to %q", got)
500 }
501 }
502
503 func TestFileRevertRequiresLatestOwnershipFingerprint(t *testing.T) {
504 root := t.TempDir()
505 path := filepath.Join(root, "a.txt")
506 write(t, path, "before")
507 store := New("", root)
508 observer := NewMutationObserver(ObserverOptions{Store: store})
509 store.Begin(0, "first", 0)
510 observer.BeforeMutation("a.txt", "edit", CaptureBeforeMutation)
511 write(t, path, "middle")
512 observer.AfterMutation("a.txt", "edit")
513 store.Begin(1, "second", 2)
514 observer.BeforeMutation("a.txt", "edit", CaptureBeforeMutation)
515 write(t, path, "after")
516 // Simulate a writer whose mandatory after observation could not establish
517 // an identity. The earlier fingerprint must not be reused as current proof.
518
519 state, ok := store.FileState("a.txt")
520 if !ok {
521 t.Fatal("expected earliest session preimage")
522 }
523 if state.Owned {
524 t.Fatal("stale earlier after fingerprint still marked file session-owned")
525 }
526 plan, err := store.PrepareFileRevert("a.txt", 1)
527 if err != nil {
528 t.Fatal(err)
529 }
530 if plan.CanFiles || plan.PlanID != "" {
531 t.Fatalf("missing latest ownership proof enabled file revert: %+v", plan)
532 }
533 }
534
535 func TestUndoRejectsPermissionOnlyChange(t *testing.T) {
536 root := t.TempDir()
537 path := filepath.Join(root, "a.txt")
538 write(t, path, "before")
539 store := New("", root)
540 observer := NewMutationObserver(ObserverOptions{Store: store})
541 store.Begin(0, "edit", 0)
542 observer.BeforeMutation("a.txt", "edit", CaptureBeforeMutation)
543 write(t, path, "after")
544 observer.AfterMutation("a.txt", "edit")
545 plan, err := store.PrepareRewind(0, RewindCode, 1, 0, false)
546 if err != nil || !plan.CanFiles {
547 t.Fatalf("prepare: plan=%+v err=%v", plan, err)
548 }
549 result, err := store.CommitRewindWithForward(plan.PlanID, nil, nil, nil)
550 if err != nil {
551 t.Fatal(err)
552 }
553 before, err := os.Stat(path)
554 if err != nil {
555 t.Fatal(err)
556 }
557 wantMode := os.FileMode(0o600)
558 if before.Mode().Perm() == wantMode {
559 wantMode = 0o644
560 }
561 if err := os.Chmod(path, wantMode); err != nil {
562 t.Fatal(err)
563 }
564 changed, err := os.Stat(path)
565 if err != nil {
566 t.Fatal(err)
567 }
568 if changed.Mode().Perm() == before.Mode().Perm() {
569 t.Skip("filesystem does not expose permission-only changes")
570 }
571 if _, err := store.UndoRewind(result.TransactionID, nil); err == nil {
572 t.Fatal("undo overwrote a permission-only user change")
573 }
574 info, err := os.Stat(path)
575 if err != nil {
576 t.Fatal(err)
577 }
578 if got := info.Mode().Perm(); got != wantMode {
579 t.Fatalf("mode after refused undo = %o, want %o", got, wantMode)
580 }
581 }
582
583 func TestRewindDeduplicatesEquivalentPathForms(t *testing.T) {
584 root := t.TempDir()
585 path := filepath.Join(root, "a.txt")
586 write(t, path, "before")
587 store := New("", root)
588 observer := NewMutationObserver(ObserverOptions{Store: store})
589 store.Begin(0, "first", 0)
590 observer.BeforeMutation("a.txt", "edit", CaptureBeforeMutation)
591 write(t, path, "middle")
592 observer.AfterMutation("a.txt", "edit")
593 store.Begin(1, "second", 2)
594 observer.BeforeMutation(path, "edit", CaptureBeforeMutation)
595 write(t, path, "after")
596 observer.AfterMutation(path, "edit")
597
598 plan, err := store.PrepareRewind(0, RewindCode, 2, 0, false)
599 if err != nil {
600 t.Fatal(err)
601 }
602 if !plan.CanFiles || plan.FileCount != 1 || len(plan.Files) != 1 {
603 t.Fatalf("equivalent paths were not one rewind target: %+v", plan)
604 }
605 result, err := store.CommitRewindWithForward(plan.PlanID, nil, nil, nil)
606 if err != nil || !result.OK {
607 t.Fatalf("commit: result=%+v err=%v", result, err)
608 }
609 if got := read(t, path); got != "before" {
610 t.Fatalf("rewind = %q, want before", got)
611 }
612 }
613
614 type failCheckpointRestoreApplier struct {
615 conversation string
616 }
617
618 func (a *failCheckpointRestoreApplier) ApplyConversationTruncate(_ int, _ []byte) error {
619 a.conversation = "rewound"
620 return nil
621 }
622 func (a *failCheckpointRestoreApplier) RestoreConversation(_ []byte) error {
623 a.conversation = "forward"
624 return nil
625 }
626 func (a *failCheckpointRestoreApplier) TruncateCheckpoints(_ int) error { return nil }
627 func (a *failCheckpointRestoreApplier) RestoreCheckpoints(_ []byte) error {
628 return errors.New("injected checkpoint restore failure")
629 }
630
631 func TestUndoCheckpointRestoreFailureRestoresOriginalRewind(t *testing.T) {
632 store := New("", t.TempDir())
633 applier := &failCheckpointRestoreApplier{conversation: "rewound"}
634 original := &TransactionManifest{
635 ID: "original", State: TxCommitted, Kind: "rewind", Scope: RewindBoth,
636 HasBoundary: true, BoundaryIndex: 2, TruncateFrom: 1,
637 ConversationForward: []byte(`{"messages":["forward"]}`),
638 CheckpointBackup: []byte(`[{"turn":1}]`),
639 }
640 undo := &TransactionManifest{
641 ID: "undo", State: TxPrepared, Kind: "undo", Scope: RewindBoth,
642 ParentTransaction: original.ID,
643 }
644 if _, err := store.commitUndoTransaction(undo, original, applier); err == nil {
645 t.Fatal("expected injected checkpoint restore failure")
646 }
647 if applier.conversation != "rewound" {
648 t.Fatalf("failed undo left conversation in %q state, want rewound", applier.conversation)
649 }
650 }
651
652 func TestFailedFileCompensationRemainsRecoverable(t *testing.T) {
653 root := t.TempDir()
654 target := filepath.Join(root, "a.txt")
655 write(t, target, "external")
656 tx := &TransactionManifest{
657 ID: "tx-pending-compensation", State: TxCommitting, Kind: "rewind",
658 Targets: []TransactionTarget{{
659 Path: "a.txt", AbsPath: target, Action: "write", Published: true,
660 RestoreExisted: true, RestoreSHA: Digest([]byte("restore")),
661 ForwardExisted: true, ForwardSHA: Digest([]byte("forward")), ForwardInline: []byte("forward"),
662 }},
663 }
664 store := New("", root)
665 if err := store.failTransaction(tx, tx.Targets, []FileStage{{Path: "a.txt"}}, errors.New("injected failure")); err == nil {
666 t.Fatal("expected compensation failure")
667 }
668 if tx.State != TxCommitting {
669 t.Fatalf("transaction state = %s, want committing for startup retry", tx.State)
670 }
671 }
672
673 func diffChange(path, old string) diff.Change {
674 return diff.Change{Path: path, Kind: diff.Modify, OldText: old}
675 }
676
676 lines GO