返回 DeepSeek-Reasonix
layout_v3_test.go
根目录 / internal / checkpoint / layout_v3_test.go
1 package checkpoint
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10 )
11
12 func TestV3PersistAndReload(t *testing.T) {
13 root := t.TempDir()
14 dir := filepath.Join(t.TempDir(), "ckpt")
15 s := New(dir, root)
16 s.Begin(1, "edit", 0)
17 target := filepath.Join(root, "a.txt")
18 if err := os.WriteFile(target, []byte("hello"), 0o644); err != nil {
19 t.Fatal(err)
20 }
21 s.CaptureBefore(target, CaptureBeforeOpts{})
22
23 meta := filepath.Join(dir, "turns", "1", "meta.json")
24 if _, err := os.Stat(meta); err != nil {
25 t.Fatalf("v3 meta missing: %v", err)
26 }
27 markerPath := filepath.Join(dir, "turn-1.json")
28 markerBytes, err := os.ReadFile(markerPath)
29 if err != nil {
30 t.Fatalf("compatibility marker missing: %v", err)
31 }
32 var marker Checkpoint
33 if err := json.Unmarshal(markerBytes, &marker); err != nil {
34 t.Fatalf("decode compatibility marker: %v", err)
35 }
36 if marker.SchemaVersion != SchemaV2 || !marker.ExpiredFilePayload || len(marker.Files) != 0 {
37 t.Fatalf("compatibility marker = %+v, want payload-free expired v2", marker)
38 }
39 before := filepath.Join(dir, "turns", "1", "files", "0000.before")
40 raw, err := os.ReadFile(before)
41 if err != nil {
42 t.Fatalf("before payload: %v", err)
43 }
44 if string(raw) != "hello" {
45 t.Fatalf("before payload = %q", raw)
46 }
47 if size, err := s.blobs.Size(); err != nil || size != 0 {
48 t.Fatalf("v3 capture should not duplicate payloads in blobs: size=%d err=%v", size, err)
49 }
50
51 reloaded := New(dir, root)
52 if len(reloaded.done) != 1 || reloaded.done[0].Turn != 1 || reloaded.done[0].SchemaVersion != SchemaV3 {
53 t.Fatalf("reloaded = %+v", reloaded.done)
54 }
55 got := reloaded.done[0].Files
56 if len(got) != 1 || got[0].Content == nil || *got[0].Content != "hello" {
57 t.Fatalf("reloaded files = %+v", got)
58 }
59 }
60
61 func TestV3PersistsMalformedEncodedPreimageExactly(t *testing.T) {
62 root := t.TempDir()
63 dir := filepath.Join(t.TempDir(), "ckpt")
64 target := filepath.Join(root, "odd-utf16.txt")
65 want := []byte{0xff, 0xfe, 0x00}
66 if err := os.WriteFile(target, want, 0o644); err != nil {
67 t.Fatal(err)
68 }
69 s := New(dir, root)
70 s.Begin(0, "edit", 0)
71 s.CaptureBefore(target, CaptureBeforeOpts{})
72
73 got, err := os.ReadFile(filepath.Join(dir, "turns", "0", "files", "0000.before"))
74 if err != nil {
75 t.Fatal(err)
76 }
77 if string(got) != string(want) {
78 t.Fatalf("raw preimage = %x, want %x", got, want)
79 }
80 }
81
82 func TestV3LoadRejectsCorruptPayload(t *testing.T) {
83 root := t.TempDir()
84 dir := filepath.Join(t.TempDir(), "ckpt")
85 target := filepath.Join(root, "a.txt")
86 if err := os.WriteFile(target, []byte("before"), 0o644); err != nil {
87 t.Fatal(err)
88 }
89 s := New(dir, root)
90 s.Begin(0, "edit", 0)
91 s.CaptureBefore(target, CaptureBeforeOpts{})
92 if err := os.WriteFile(filepath.Join(dir, "turns", "0", "files", "0000.before"), []byte("corrupt"), 0o644); err != nil {
93 t.Fatal(err)
94 }
95
96 reloaded := New(dir, root)
97 if len(reloaded.done) != 1 || len(reloaded.done[0].Files) != 1 {
98 t.Fatalf("reloaded = %+v", reloaded.done)
99 }
100 if reloaded.done[0].Files[0].Content != nil {
101 t.Fatal("corrupt payload must not become restore content")
102 }
103 conflicts := reloaded.precheckFiles(0)
104 if len(conflicts) != 1 || conflicts[0].Reason != ConflictMissingPayload {
105 t.Fatalf("conflicts = %+v, want missing payload", conflicts)
106 }
107 }
108
109 func TestV3RetentionRemovesWholeOldTurnDirectories(t *testing.T) {
110 root := t.TempDir()
111 dir := filepath.Join(t.TempDir(), "ckpt")
112 target := filepath.Join(root, "a.txt")
113 s := New(dir, root)
114 s.retainN = 2
115 for turn := range 4 {
116 if err := os.WriteFile(target, []byte{byte('0' + turn)}, 0o644); err != nil {
117 t.Fatal(err)
118 }
119 s.Begin(turn, "edit", turn)
120 s.CaptureBefore(target, CaptureBeforeOpts{})
121 }
122 for _, turn := range []string{"0", "1"} {
123 if _, err := os.Stat(filepath.Join(dir, "turns", turn)); !os.IsNotExist(err) {
124 t.Fatalf("old turn %s was not removed: %v", turn, err)
125 }
126 }
127 for _, turn := range []string{"2", "3"} {
128 if _, err := os.Stat(filepath.Join(dir, "turns", turn, "meta.json")); err != nil {
129 t.Fatalf("retained turn %s missing: %v", turn, err)
130 }
131 }
132 metas := s.List()
133 if len(metas) != 2 || metas[0].Turn != 2 || metas[1].Turn != 3 {
134 t.Fatalf("retained turns = %+v", metas)
135 }
136 }
137
138 func TestV3LoadKeepsLegacyTurnJSON(t *testing.T) {
139 dir := filepath.Join(t.TempDir(), "ckpt")
140 if err := os.MkdirAll(dir, 0o755); err != nil {
141 t.Fatal(err)
142 }
143 legacy := []byte(`{"schemaVersion":2,"turn":0,"prompt":"old","files":[{"path":"a.txt","content":"v2"}]}`)
144 if err := os.WriteFile(filepath.Join(dir, "turn-0.json"), legacy, 0o644); err != nil {
145 t.Fatal(err)
146 }
147 s := New(dir, t.TempDir())
148 s.Begin(1, "new", 1)
149 s.Begin(2, "flush", 2)
150 reloaded := New(dir, t.TempDir())
151 if len(reloaded.done) < 1 {
152 t.Fatal("expected reloaded checkpoints")
153 }
154 var sawLegacy, sawV3 bool
155 for _, c := range reloaded.done {
156 if c.Turn == 0 && c.SchemaVersion == SchemaV2 {
157 sawLegacy = true
158 }
159 if c.Turn == 1 && c.SchemaVersion == SchemaV3 {
160 sawV3 = true
161 }
162 }
163 if !sawLegacy || !sawV3 {
164 t.Fatalf("legacy=%v v3=%v done=%+v", sawLegacy, sawV3, reloaded.done)
165 }
166 }
167
168 func TestV3CompatibilityMarkerKeepsPreviousReaderMonotonic(t *testing.T) {
169 root := t.TempDir()
170 dir := filepath.Join(t.TempDir(), "ckpt")
171 target := filepath.Join(root, "a.txt")
172 if err := os.WriteFile(target, []byte("before"), 0o644); err != nil {
173 t.Fatal(err)
174 }
175 s := New(dir, root)
176 s.Begin(0, "v3", 0)
177 s.CaptureBefore(target, CaptureBeforeOpts{})
178
179 previousNext := 0
180 entries, err := os.ReadDir(dir)
181 if err != nil {
182 t.Fatal(err)
183 }
184 for _, entry := range entries {
185 if entry.IsDir() {
186 continue
187 }
188 var turn int
189 if n, scanErr := fmt.Sscanf(entry.Name(), "turn-%d.json", &turn); scanErr != nil || n != 1 {
190 continue
191 }
192 if turn >= previousNext {
193 previousNext = turn + 1
194 }
195 }
196 if previousNext != 1 {
197 t.Fatalf("previous reader NextTurn = %d, want 1", previousNext)
198 }
199
200 legacy := Checkpoint{
201 SchemaVersion: SchemaV2,
202 Turn: previousNext,
203 Time: time.Now().Add(time.Second),
204 Prompt: "downgrade-new",
205 Files: []FileSnap{},
206 }
207 b, err := json.Marshal(&legacy)
208 if err != nil {
209 t.Fatal(err)
210 }
211 if err := os.WriteFile(filepath.Join(dir, "turn-1.json"), b, 0o644); err != nil {
212 t.Fatal(err)
213 }
214
215 reloaded := New(dir, root)
216 metas := reloaded.List()
217 if len(metas) != 2 || metas[0].Turn != 0 || metas[1].Turn != 1 || metas[1].Prompt != "downgrade-new" {
218 t.Fatalf("reloaded checkpoints = %+v", metas)
219 }
220 }
221
222 func TestV3MarkerDeletionFromPreviousReaderTombstonesTurn(t *testing.T) {
223 root := t.TempDir()
224 dir := filepath.Join(t.TempDir(), "ckpt")
225 target := filepath.Join(root, "a.txt")
226 s := New(dir, root)
227 for turn := range 3 {
228 if err := os.WriteFile(target, []byte{byte('0' + turn)}, 0o644); err != nil {
229 t.Fatal(err)
230 }
231 s.Begin(turn, "edit", turn)
232 s.CaptureBefore(target, CaptureBeforeOpts{})
233 }
234
235 // Supported previous readers truncate only their visible turn-N.json files;
236 // they do not know about turns/<n>. Simulate a downgrade rewind at turn 1.
237 for _, turn := range []int{1, 2} {
238 if err := os.Remove(filepath.Join(dir, fmt.Sprintf("turn-%d.json", turn))); err != nil {
239 t.Fatal(err)
240 }
241 if _, err := os.Stat(filepath.Join(dir, "turns", fmt.Sprint(turn), "meta.json")); err != nil {
242 t.Fatalf("v3 directory %d unexpectedly missing: %v", turn, err)
243 }
244 }
245
246 reloaded := New(dir, root)
247 metas := reloaded.List()
248 if len(metas) != 1 || metas[0].Turn != 0 {
249 t.Fatalf("markerless future turns resurrected: %+v", metas)
250 }
251 if got := reloaded.NextTurn(); got != 1 {
252 t.Fatalf("NextTurn after downgrade truncate = %d, want 1", got)
253 }
254 }
255
256 func TestV3LoadPrefersNewerLegacyCheckpointOnHistoricalTurnCollision(t *testing.T) {
257 root := t.TempDir()
258 dir := filepath.Join(t.TempDir(), "ckpt")
259 s := New(dir, root)
260 s.Begin(0, "v3-old", 0)
261
262 legacy := Checkpoint{
263 SchemaVersion: SchemaV2,
264 Turn: 0,
265 Time: s.cur.Time.Add(time.Minute),
266 Prompt: "downgrade-new",
267 Files: []FileSnap{},
268 }
269 b, err := json.Marshal(&legacy)
270 if err != nil {
271 t.Fatal(err)
272 }
273 if err := os.WriteFile(filepath.Join(dir, "turn-0.json"), b, 0o644); err != nil {
274 t.Fatal(err)
275 }
276
277 reloaded := New(dir, root)
278 if len(reloaded.done) != 1 || reloaded.done[0].SchemaVersion != SchemaV2 || reloaded.done[0].Prompt != "downgrade-new" {
279 t.Fatalf("collision selected %+v, want newer legacy checkpoint", reloaded.done)
280 }
281 }
282
283 func TestV3PayloadQuotaPrunesOldestWholeTurn(t *testing.T) {
284 root := t.TempDir()
285 dir := filepath.Join(t.TempDir(), "ckpt")
286 target := filepath.Join(root, "a.txt")
287 s := New(dir, root)
288 s.retainN = 100
289 s.blobQuota = 8
290 for turn, body := range []string{"123456", "abcdef"} {
291 if err := os.WriteFile(target, []byte(body), 0o644); err != nil {
292 t.Fatal(err)
293 }
294 s.Begin(turn, "edit", turn)
295 s.CaptureBefore(target, CaptureBeforeOpts{})
296 }
297 if _, err := os.Stat(filepath.Join(dir, "turns", "0")); !os.IsNotExist(err) {
298 t.Fatalf("old v3 turn survived payload quota: %v", err)
299 }
300 if _, err := os.Stat(filepath.Join(dir, "turn-0.json")); !os.IsNotExist(err) {
301 t.Fatalf("old compatibility marker survived payload quota: %v", err)
302 }
303 if _, err := os.Stat(filepath.Join(dir, "turns", "1", "meta.json")); err != nil {
304 t.Fatalf("current v3 turn was pruned: %v", err)
305 }
306 }
307
307 lines GO