返回 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 TestCaptureRejectsAncestorSymlink(t *testing.T) {
259 root := t.TempDir()
260 outside := t.TempDir()
261 write(t, filepath.Join(outside, "secret.txt"), "secret")
262 if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
263 t.Skipf("symlink unavailable: %v", err)
264 }
265 _, gap, err := CapturePath(filepath.Join(root, "link", "secret.txt"), CaptureOptions{WorkspaceRoot: root, ReadContent: true})
266 if err == nil || gap == nil || gap.Reason != GapSymlink {
267 t.Fatalf("ancestor symlink capture: gap=%+v err=%v", gap, err)
268 }
269 }
270
271 func TestPublishRejectsAncestorSwappedToSymlink(t *testing.T) {
272 root := t.TempDir()
273 out := t.TempDir()
274 dir := filepath.Join(root, "dir")
275 target := filepath.Join(dir, "a.txt")
276 write(t, target, "inside")
277 write(t, filepath.Join(out, "a.txt"), "outside")
278 s := New("", root)
279 tmp, backup := transactionSiblingPaths(target, "swap", 0)
280 if err := s.writePublishTemp(tmp, []byte("rewound"), 0o644); err != nil {
281 t.Fatal(err)
282 }
283 moved := filepath.Join(root, "moved")
284 if err := os.Rename(dir, moved); err != nil {
285 t.Fatal(err)
286 }
287 if err := os.Symlink(out, dir); err != nil {
288 t.Skipf("symlink unavailable: %v", err)
289 }
290 targetSpec := &TransactionTarget{Path: "dir/a.txt", AbsPath: target, PublishTmp: tmp, BackupPath: backup, Action: "write", RestoreMode: 0o644}
291 if err := s.publishTarget(targetSpec); err == nil {
292 t.Fatal("publish through swapped ancestor symlink succeeded")
293 }
294 if got := read(t, filepath.Join(out, "a.txt")); got != "outside" {
295 t.Fatalf("outside file changed to %q", got)
296 }
297 if got := read(t, filepath.Join(moved, "a.txt")); got != "inside" {
298 t.Fatalf("original workspace file changed to %q", got)
299 }
300 }
301
302 func TestFileRevertRejectsStalePreviewEvenWithOldOverwriteApproval(t *testing.T) {
303 root := t.TempDir()
304 a := filepath.Join(root, "a.txt")
305 write(t, a, "before")
306 s := New("", root)
307 s.Begin(0, "edit", 0)
308 s.CaptureBefore(a, CaptureBeforeOpts{Source: CaptureBeforeMutation})
309 write(t, a, "owned")
310 s.CaptureAfter(a, CaptureAfterOpts{Seq: 1, Source: CaptureAfterMutation})
311
312 plan, err := s.PrepareFileRevert(a, 1)
313 if err != nil || !plan.CanFiles {
314 t.Fatalf("prepare: plan=%+v err=%v", plan, err)
315 }
316 write(t, a, "external")
317 if _, err := s.CommitFileRevert(plan.PlanID, ResolveOverwriteCheckpoint); err == nil {
318 t.Fatal("stale overwrite approval must not authorize a later external edit")
319 }
320 if got := read(t, a); got != "external" {
321 t.Fatalf("stale commit changed file to %q", got)
322 }
323
324 fresh, err := s.PrepareFileRevert(a, 1)
325 if err != nil || len(fresh.Conflicts) == 0 {
326 t.Fatalf("fresh preview should expose external conflict: plan=%+v err=%v", fresh, err)
327 }
328 result, err := s.CommitFileRevert(fresh.PlanID, ResolveOverwriteCheckpoint)
329 if err != nil || !result.OK {
330 t.Fatalf("fresh explicit overwrite failed: result=%+v err=%v", result, err)
331 }
332 if got := read(t, a); got != "before" {
333 t.Fatalf("fresh confirmed revert = %q, want before", got)
334 }
335 }
336
337 func TestUndoRestoresEmptyForwardFile(t *testing.T) {
338 root := t.TempDir()
339 a := filepath.Join(root, "a.txt")
340 write(t, a, "before")
341 s := New("", root)
342 s.Begin(0, "empty", 0)
343 s.CaptureBefore(a, CaptureBeforeOpts{Source: CaptureBeforeMutation})
344 write(t, a, "")
345 s.CaptureAfter(a, CaptureAfterOpts{Seq: 1, Source: CaptureAfterMutation})
346 plan, err := s.PrepareRewind(0, RewindCode, 1, 0, false)
347 if err != nil || !plan.CanFiles {
348 t.Fatalf("prepare: plan=%+v err=%v", plan, err)
349 }
350 result, err := s.CommitRewindWithForward(plan.PlanID, nil, nil, nil)
351 if err != nil || !result.OK {
352 t.Fatalf("rewind: result=%+v err=%v", result, err)
353 }
354 undo, err := s.UndoRewind(result.TransactionID, nil)
355 if err != nil || !undo.OK {
356 t.Fatalf("undo: result=%+v err=%v", undo, err)
357 }
358 if got := read(t, a); got != "" {
359 t.Fatalf("undo restored %q, want empty file", got)
360 }
361 }
362
363 func TestPrecheckDetectsManualEdit(t *testing.T) {
364 root := t.TempDir()
365 a := filepath.Join(root, "a.txt")
366 write(t, a, "v0")
367 s := New("", root)
368 s.Begin(0, "p", 0)
369 s.CaptureBeforeFromChange(diffChange(a, "v0"), CaptureBeforeOpts{Source: CapturePreviewer})
370 write(t, a, "v1")
371 s.CaptureAfter(a, CaptureAfterOpts{Seq: 1, Source: CaptureAfterMutation})
372 write(t, a, "manual")
373
374 plan, err := s.PrepareRewind(0, RewindCode, 1, 0, false)
375 if err != nil {
376 t.Fatal(err)
377 }
378 if plan.CanFiles {
379 t.Fatalf("expected CanFiles=false on manual edit, plan=%+v", plan)
380 }
381 if len(plan.Conflicts) == 0 {
382 t.Fatal("expected conflicts")
383 }
384 if _, err := s.CommitRewindWithForward(plan.PlanID, nil, nil, nil); err == nil {
385 t.Fatal("commit should fail")
386 }
387 if got := read(t, a); got != "manual" {
388 t.Fatalf("a = %q, want manual", got)
389 }
390 }
391
392 func TestTransactionCrashRecoveryPreparedIsAbandoned(t *testing.T) {
393 root := t.TempDir()
394 dir := filepath.Join(t.TempDir(), "sess.ckpt")
395 s := New(dir, root)
396 tx := &TransactionManifest{
397 SchemaVersion: SchemaV2,
398 ID: "tx-prep",
399 WorkspaceRoot: root,
400 State: TxPrepared,
401 Kind: "rewind",
402 }
403 if err := s.persistTransaction(tx); err != nil {
404 t.Fatal(err)
405 }
406 _ = New(dir, root)
407 var loaded TransactionManifest
408 if err := readJSONFile(s.txManifestPath("tx-prep"), &loaded); err != nil {
409 t.Fatal(err)
410 }
411 if loaded.State != TxAborted {
412 t.Fatalf("state = %s, want aborted", loaded.State)
413 }
414 }
415
416 func TestCompensationRecoversCrashBetweenBackupAndPublishRenames(t *testing.T) {
417 root := t.TempDir()
418 target := filepath.Join(root, "a.txt")
419 write(t, target, "forward")
420 info, err := os.Stat(target)
421 if err != nil {
422 t.Fatal(err)
423 }
424 mode := uint32(info.Mode().Perm())
425 publish, backup := transactionSiblingPaths(target, "tx-crash-gap", 0)
426 write(t, publish, "restore")
427 if err := os.Rename(target, backup); err != nil {
428 t.Fatal(err)
429 }
430
431 targetSpec := TransactionTarget{
432 Path: "a.txt", AbsPath: target, Action: "write", Published: true,
433 RestoreExisted: true, RestoreSHA: Digest([]byte("restore")), RestoreMode: mode,
434 ForwardExisted: true, ForwardSHA: Digest([]byte("forward")), ForwardMode: mode,
435 ForwardInline: []byte("forward"), PublishTmp: publish, BackupPath: backup,
436 }
437 store := New("", root)
438 if err := store.compensatePublished([]TransactionTarget{targetSpec}, []FileStage{{Path: "a.txt"}}); err != nil {
439 t.Fatalf("compensate crash gap: %v", err)
440 }
441 if got := read(t, target); got != "forward" {
442 t.Fatalf("target after compensation = %q, want forward", got)
443 }
444 if _, err := os.Stat(publish); !os.IsNotExist(err) {
445 t.Fatalf("publish temp remains after compensation: %v", err)
446 }
447 }
448
449 func TestLegacyFileRevertIsRefusedWithoutOwnershipFingerprint(t *testing.T) {
450 root := t.TempDir()
451 dir := t.TempDir()
452 path := filepath.Join(root, "a.txt")
453 write(t, path, "manual")
454 before := "before"
455 legacy := Checkpoint{Turn: 0, Time: time.Now(), Files: []FileSnap{{Path: "a.txt", Content: &before}}}
456 raw, err := json.Marshal(legacy)
457 if err != nil {
458 t.Fatal(err)
459 }
460 if err := os.WriteFile(filepath.Join(dir, "turn-0.json"), raw, 0o644); err != nil {
461 t.Fatal(err)
462 }
463
464 store := New(dir, root)
465 plan, err := store.PrepareFileRevert("a.txt", 1)
466 if err != nil {
467 t.Fatal(err)
468 }
469 if plan.CanFiles || plan.PlanID != "" {
470 t.Fatalf("legacy single-file revert was authorized: %+v", plan)
471 }
472 if _, err := store.CommitFileRevert(plan.PlanID, ResolveOverwriteCheckpoint); err == nil {
473 t.Fatal("legacy file revert commit must be refused")
474 }
475 if got := read(t, path); got != "manual" {
476 t.Fatalf("legacy refusal changed file to %q", got)
477 }
478 }
479
480 func TestFileRevertRequiresLatestOwnershipFingerprint(t *testing.T) {
481 root := t.TempDir()
482 path := filepath.Join(root, "a.txt")
483 write(t, path, "before")
484 store := New("", root)
485 observer := NewMutationObserver(ObserverOptions{Store: store})
486 store.Begin(0, "first", 0)
487 observer.BeforeMutation("a.txt", "edit", CaptureBeforeMutation)
488 write(t, path, "middle")
489 observer.AfterMutation("a.txt", "edit")
490 store.Begin(1, "second", 2)
491 observer.BeforeMutation("a.txt", "edit", CaptureBeforeMutation)
492 write(t, path, "after")
493 // Simulate a writer whose mandatory after observation could not establish
494 // an identity. The earlier fingerprint must not be reused as current proof.
495
496 state, ok := store.FileState("a.txt")
497 if !ok {
498 t.Fatal("expected earliest session preimage")
499 }
500 if state.Owned {
501 t.Fatal("stale earlier after fingerprint still marked file session-owned")
502 }
503 plan, err := store.PrepareFileRevert("a.txt", 1)
504 if err != nil {
505 t.Fatal(err)
506 }
507 if plan.CanFiles || plan.PlanID != "" {
508 t.Fatalf("missing latest ownership proof enabled file revert: %+v", plan)
509 }
510 }
511
512 func TestUndoRejectsPermissionOnlyChange(t *testing.T) {
513 root := t.TempDir()
514 path := filepath.Join(root, "a.txt")
515 write(t, path, "before")
516 store := New("", root)
517 observer := NewMutationObserver(ObserverOptions{Store: store})
518 store.Begin(0, "edit", 0)
519 observer.BeforeMutation("a.txt", "edit", CaptureBeforeMutation)
520 write(t, path, "after")
521 observer.AfterMutation("a.txt", "edit")
522 plan, err := store.PrepareRewind(0, RewindCode, 1, 0, false)
523 if err != nil || !plan.CanFiles {
524 t.Fatalf("prepare: plan=%+v err=%v", plan, err)
525 }
526 result, err := store.CommitRewindWithForward(plan.PlanID, nil, nil, nil)
527 if err != nil {
528 t.Fatal(err)
529 }
530 before, err := os.Stat(path)
531 if err != nil {
532 t.Fatal(err)
533 }
534 wantMode := os.FileMode(0o600)
535 if before.Mode().Perm() == wantMode {
536 wantMode = 0o644
537 }
538 if err := os.Chmod(path, wantMode); err != nil {
539 t.Fatal(err)
540 }
541 changed, err := os.Stat(path)
542 if err != nil {
543 t.Fatal(err)
544 }
545 if changed.Mode().Perm() == before.Mode().Perm() {
546 t.Skip("filesystem does not expose permission-only changes")
547 }
548 if _, err := store.UndoRewind(result.TransactionID, nil); err == nil {
549 t.Fatal("undo overwrote a permission-only user change")
550 }
551 info, err := os.Stat(path)
552 if err != nil {
553 t.Fatal(err)
554 }
555 if got := info.Mode().Perm(); got != wantMode {
556 t.Fatalf("mode after refused undo = %o, want %o", got, wantMode)
557 }
558 }
559
560 func TestRewindDeduplicatesEquivalentPathForms(t *testing.T) {
561 root := t.TempDir()
562 path := filepath.Join(root, "a.txt")
563 write(t, path, "before")
564 store := New("", root)
565 observer := NewMutationObserver(ObserverOptions{Store: store})
566 store.Begin(0, "first", 0)
567 observer.BeforeMutation("a.txt", "edit", CaptureBeforeMutation)
568 write(t, path, "middle")
569 observer.AfterMutation("a.txt", "edit")
570 store.Begin(1, "second", 2)
571 observer.BeforeMutation(path, "edit", CaptureBeforeMutation)
572 write(t, path, "after")
573 observer.AfterMutation(path, "edit")
574
575 plan, err := store.PrepareRewind(0, RewindCode, 2, 0, false)
576 if err != nil {
577 t.Fatal(err)
578 }
579 if !plan.CanFiles || plan.FileCount != 1 || len(plan.Files) != 1 {
580 t.Fatalf("equivalent paths were not one rewind target: %+v", plan)
581 }
582 result, err := store.CommitRewindWithForward(plan.PlanID, nil, nil, nil)
583 if err != nil || !result.OK {
584 t.Fatalf("commit: result=%+v err=%v", result, err)
585 }
586 if got := read(t, path); got != "before" {
587 t.Fatalf("rewind = %q, want before", got)
588 }
589 }
590
591 type failCheckpointRestoreApplier struct {
592 conversation string
593 }
594
595 func (a *failCheckpointRestoreApplier) ApplyConversationTruncate(_ int, _ []byte) error {
596 a.conversation = "rewound"
597 return nil
598 }
599 func (a *failCheckpointRestoreApplier) RestoreConversation(_ []byte) error {
600 a.conversation = "forward"
601 return nil
602 }
603 func (a *failCheckpointRestoreApplier) TruncateCheckpoints(_ int) error { return nil }
604 func (a *failCheckpointRestoreApplier) RestoreCheckpoints(_ []byte) error {
605 return errors.New("injected checkpoint restore failure")
606 }
607
608 func TestUndoCheckpointRestoreFailureRestoresOriginalRewind(t *testing.T) {
609 store := New("", t.TempDir())
610 applier := &failCheckpointRestoreApplier{conversation: "rewound"}
611 original := &TransactionManifest{
612 ID: "original", State: TxCommitted, Kind: "rewind", Scope: RewindBoth,
613 HasBoundary: true, BoundaryIndex: 2, TruncateFrom: 1,
614 ConversationForward: []byte(`{"messages":["forward"]}`),
615 CheckpointBackup: []byte(`[{"turn":1}]`),
616 }
617 undo := &TransactionManifest{
618 ID: "undo", State: TxPrepared, Kind: "undo", Scope: RewindBoth,
619 ParentTransaction: original.ID,
620 }
621 if _, err := store.commitUndoTransaction(undo, original, applier); err == nil {
622 t.Fatal("expected injected checkpoint restore failure")
623 }
624 if applier.conversation != "rewound" {
625 t.Fatalf("failed undo left conversation in %q state, want rewound", applier.conversation)
626 }
627 }
628
629 func TestFailedFileCompensationRemainsRecoverable(t *testing.T) {
630 root := t.TempDir()
631 target := filepath.Join(root, "a.txt")
632 write(t, target, "external")
633 tx := &TransactionManifest{
634 ID: "tx-pending-compensation", State: TxCommitting, Kind: "rewind",
635 Targets: []TransactionTarget{{
636 Path: "a.txt", AbsPath: target, Action: "write", Published: true,
637 RestoreExisted: true, RestoreSHA: Digest([]byte("restore")),
638 ForwardExisted: true, ForwardSHA: Digest([]byte("forward")), ForwardInline: []byte("forward"),
639 }},
640 }
641 store := New("", root)
642 if err := store.failTransaction(tx, tx.Targets, []FileStage{{Path: "a.txt"}}, errors.New("injected failure")); err == nil {
643 t.Fatal("expected compensation failure")
644 }
645 if tx.State != TxCommitting {
646 t.Fatalf("transaction state = %s, want committing for startup retry", tx.State)
647 }
648 }
649
650 func diffChange(path, old string) diff.Change {
651 return diff.Change{Path: path, Kind: diff.Modify, OldText: old}
652 }
653
653 lines GO