返回 DeepSeek-Reasonix
session_v5_migration_checkpoint_test.go
根目录 / desktop / session_v5_migration_checkpoint_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10
11 "reasonix/desktop/internal/workspacestate"
12 "reasonix/internal/agent"
13 "reasonix/internal/config"
14 "reasonix/internal/provider"
15 "reasonix/internal/session"
16 "reasonix/internal/store"
17 )
18
19 func TestMigrationRevisionIgnoresCatalogRebuildButTracksDurableSources(t *testing.T) {
20 path := filepath.Join(t.TempDir(), "history.jsonl")
21 if err := os.WriteFile(path, []byte("history"), 0600); err != nil {
22 t.Fatal(err)
23 }
24 files := legacyMigrationSourceFiles(path)
25 before, err := desktopMigrationSourceRevision(files)
26 if err != nil {
27 t.Fatal(err)
28 }
29 for _, index := range []string{store.SessionEventIndex(path), store.SessionDisplayIndex(path), store.SessionTranscriptProjection(path)} {
30 if err := os.WriteFile(index, []byte("rebuilt cache"), 0600); err != nil {
31 t.Fatal(err)
32 }
33 }
34 after, err := desktopMigrationSourceRevision(files)
35 if err != nil || after != before {
36 t.Fatalf("catalog rebuild changed source identity: %v", err)
37 }
38 if err := os.WriteFile(store.SessionMeta(path), []byte(`{"workspace_root":"changed"}`), 0600); err != nil {
39 t.Fatal(err)
40 }
41 after, err = desktopMigrationSourceRevision(files)
42 if err != nil || after == before {
43 t.Fatalf("ownership change was ignored: %v", err)
44 }
45 }
46
47 func TestMigrationCheckpointAllowsProjectionRepairButRejectsHistoryChange(t *testing.T) {
48 isolateDesktopUserDirs(t)
49 path := filepath.Join(t.TempDir(), "history.jsonl")
50 if err := os.WriteFile(path, []byte("original history"), 0600); err != nil {
51 t.Fatal(err)
52 }
53 meta := store.SessionMeta(path)
54 if err := os.WriteFile(meta, []byte(`{"id":"history","workspace_root":"original","future":{"proof":1}}`), 0600); err != nil {
55 t.Fatal(err)
56 }
57 cp, err := newDesktopMigrationCheckpoint(desktopMigrationSource{}, "projection-test", legacyMigrationSourceFiles(path))
58 if err != nil {
59 t.Fatal(err)
60 }
61 if err := os.WriteFile(path, []byte("original history"), 0600); err != nil {
62 t.Fatal(err)
63 }
64 if err := os.WriteFile(meta, []byte(`{"id":"history","workspace_root":"original","future":{"proof":1},"turns":1,"schema_version":2,"writer_id":"repair"}`), 0600); err != nil {
65 t.Fatal(err)
66 }
67 if err := cp.complete("target", "content"); err != nil {
68 t.Fatalf("projection repair rejected: %v", err)
69 }
70 if err := os.WriteFile(path, []byte("changed history"), 0600); err != nil {
71 t.Fatal(err)
72 }
73 if err := cp.complete("target", "content"); err == nil {
74 t.Fatal("changed history accepted")
75 }
76 if err := os.WriteFile(path, []byte("original history"), 0600); err != nil {
77 t.Fatal(err)
78 }
79 if err := os.WriteFile(meta, []byte(`{"id":"history","workspace_root":"other","future":{"proof":1}}`), 0600); err != nil {
80 t.Fatal(err)
81 }
82 if err := cp.complete("target", "content"); err == nil {
83 t.Fatal("changed ownership accepted")
84 }
85 }
86
87 func appendMigrationTestMessage(t *testing.T, service *session.Service, ref session.SessionRef, id string) {
88 t.Helper()
89 binding, err := service.Open(t.Context(), ref)
90 if err != nil {
91 t.Fatal(err)
92 }
93 payload, _ := json.Marshal(map[string]any{"message": provider.Message{ID: id, Role: provider.RoleUser, Content: id}})
94 if _, err := binding.Runtime().Session().AppendBatch(t.Context(), id, []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
95 t.Fatal(err)
96 }
97 if err := binding.Release(t.Context()); err != nil {
98 t.Fatal(err)
99 }
100 if err := service.Close(t.Context(), ref); err != nil {
101 t.Fatal(err)
102 }
103 }
104
105 func onlyMigrationRecord(t *testing.T) desktopMigrationRecord {
106 t.Helper()
107 ledger, err := readDesktopMigrationLedger()
108 if err != nil || len(ledger.Records) != 1 {
109 t.Fatalf("ledger = %#v, %v", ledger, err)
110 }
111 for _, record := range ledger.Records {
112 return record
113 }
114 panic("unreachable")
115 }
116
117 func TestIncrementalMigrationPreservesContinuedTargetsAndQuarantinesSourceChanges(t *testing.T) {
118 for _, kind := range []string{"v4", "legacy"} {
119 for _, oldLedger := range []bool{false, true} {
120 name := kind + "/current-ledger"
121 if oldLedger {
122 name = kind + "/old-ledger"
123 }
124 t.Run(name, func(t *testing.T) {
125 isolateDesktopUserDirs(t)
126 var migrate func(*App) error
127 var growSource func()
128 var sourceFile string
129 if kind == "v4" {
130 root := config.SessionStoreDir()
131 old := coldV4MigrationFixture(t, root, "native")
132 source := desktopMigrationSource{root: root, scope: "global"}
133 migrate = func(app *App) error { return app.migrateCanonicalStore(t.Context(), source) }
134 growSource = func() {
135 appendMigrationTestMessage(t, old, session.SessionRef{HostID: "migration-source", SessionID: "native"}, "new-source-work")
136 }
137 sourceFile = filepath.Join(root, "native", "events.frames")
138 } else {
139 root := config.SessionDir()
140 sourceFile = filepath.Join(root, "old.jsonl")
141 legacy := agent.NewSession("system")
142 legacy.Add(provider.Message{ID: "old", Role: provider.RoleUser, Content: "original work"})
143 if err := legacy.Save(sourceFile); err != nil {
144 t.Fatal(err)
145 }
146 source := desktopMigrationSource{root: root, scope: "global", exact: map[string]bool{sourceFile: true}}
147 migrate = func(app *App) error {
148 err := app.migrateLegacyDirectory(t.Context(), source)
149 // Subsequent startups no longer have an open legacy tab.
150 source.exact = nil
151 return err
152 }
153 growSource = func() {
154 legacy.Add(provider.Message{ID: "new-source-work", Role: provider.RoleUser, Content: "new-source-work"})
155 if err := legacy.Save(sourceFile); err != nil {
156 t.Fatal(err)
157 }
158 }
159 }
160 app := NewApp()
161 t.Cleanup(app.closeSessionServices)
162 if err := migrate(app); err != nil {
163 t.Fatal(err)
164 }
165 first := onlyMigrationRecord(t)
166 if first.SourceRevision == "" || first.Status != "completed" {
167 t.Fatalf("missing completed source revision: %#v", first)
168 }
169 if kind == "legacy" {
170 if err := os.Remove(store.SessionMeta(sourceFile)); err != nil && !os.IsNotExist(err) {
171 t.Fatal(err)
172 }
173 }
174 if oldLedger {
175 if err := updateDesktopMigrationLedger(first.SourceKey, first.TargetSessionID, "completed", "", first.ContentDigest); err != nil {
176 t.Fatal(err)
177 }
178 }
179 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: first.TargetSessionID}
180 appendMigrationTestMessage(t, app.desktopSessionService(""), ref, "new-v5-work")
181 if err := app.workspaceRegistry().RenameWorkspace(t.Context(), workspacestate.GlobalWorkspaceID, "user workspace name"); err != nil {
182 t.Fatal(err)
183 }
184 if err := app.workspaceRegistry().SetWorkspaceVisible(t.Context(), workspacestate.GlobalWorkspaceID, false); err != nil {
185 t.Fatal(err)
186 }
187 workspaceBefore, err := os.ReadFile(config.DesktopWorkspaceStatePath())
188 if err != nil {
189 t.Fatal(err)
190 }
191 app.closeSessionServices()
192 app = NewApp()
193 t.Cleanup(app.closeSessionServices)
194 for range 2 {
195 if err := migrate(app); err != nil {
196 t.Fatal(err)
197 }
198 current := onlyMigrationRecord(t)
199 if current.TargetSessionID != first.TargetSessionID || current.Attempts != first.Attempts || current.SourceRevision == "" {
200 t.Fatalf("continued target was remigrated: first=%#v current=%#v", first, current)
201 }
202 }
203 // A metadata-only source change must refresh its checkpoint using
204 // the recorded source digest, not compare against the continued target.
205 stamp := time.Now().Add(-time.Hour)
206 if err := os.Chtimes(sourceFile, stamp, stamp); err != nil {
207 t.Fatal(err)
208 }
209 if err := migrate(app); err != nil {
210 t.Fatal(err)
211 }
212 if got := onlyMigrationRecord(t); got.Attempts != first.Attempts || got.TargetSessionID != first.TargetSessionID {
213 t.Fatalf("metadata change remigrated content: %#v", got)
214 }
215 // A failed attempt must retain the previous adoption receipt.
216 // Restoring the source after a temporary error cannot resurrect
217 // an old copy beside the target that the user has continued.
218 if err := updateDesktopMigrationLedger(first.SourceKey, "incomplete-target", "failed", "source_changed", "incomplete-digest"); err != nil {
219 t.Fatal(err)
220 }
221 if err := migrate(app); err != nil {
222 t.Fatal(err)
223 }
224 if got := onlyMigrationRecord(t); got.Status != "completed" || got.TargetSessionID != first.TargetSessionID || got.PreviousCompletion != nil {
225 t.Fatalf("previous adoption receipt was lost: %#v", got)
226 }
227 workspaceAfter, err := os.ReadFile(config.DesktopWorkspaceStatePath())
228 if err != nil || !bytes.Equal(workspaceBefore, workspaceAfter) {
229 t.Fatalf("completed migration rewrote workspace state: %v", err)
230 }
231 growSource()
232 if err := migrate(app); err != nil {
233 t.Fatal(err)
234 }
235 changed := onlyMigrationRecord(t)
236 if changed.TargetSessionID != first.TargetSessionID || changed.ContentDigest != first.ContentDigest {
237 t.Fatalf("source change replaced adoption: %#v", changed)
238 }
239 state, err := app.workspaceRegistry().Load(t.Context())
240 if err != nil || len(state.RecoveryEntries) != 1 {
241 t.Fatalf("changed source not quarantined: %+v %v", state.RecoveryEntries, err)
242 }
243 var recoveryID string
244 for id := range state.RecoveryEntries {
245 recoveryID = id
246 }
247 // User review creates an independent target; old work remains intact.
248 result, err := app.RestoreRecoveryEntry(recoveryID, "review-changed-source")
249 if err != nil {
250 t.Fatal(err)
251 }
252 newRef := result.Session
253 if newRef == ref {
254 t.Fatal("review overwrote continued session")
255 }
256 for range 2 {
257 if err := migrate(app); err != nil {
258 t.Fatal(err)
259 }
260 }
261 infos, err := listAllCanonicalSessionInfo(t.Context(), app.desktopSessionService("").Query())
262 if err != nil || len(infos) != 2 {
263 t.Fatalf("review duplicated source: %#v %v", infos, err)
264 }
265 history, err := app.desktopSessionService("").Query().History(t.Context(), ref)
266 if err != nil || len(history) == 0 || history[len(history)-1].Content != "new-v5-work" {
267 t.Fatalf("continued work changed: %#v %v", history, err)
268 }
269 history, err = app.desktopSessionService("").Query().History(t.Context(), newRef)
270 if err != nil || len(history) == 0 || history[len(history)-1].Content != "new-source-work" {
271 t.Fatalf("source update lost: %#v %v", history, err)
272 }
273
274 before, err := os.ReadFile(desktopMigrationLedgerPath())
275 if err != nil {
276 t.Fatal(err)
277 }
278 if err := app.desktopSessionService("").Delete(t.Context(), newRef); err != nil {
279 t.Fatal(err)
280 }
281 // No temporary import is permitted for a completed revision.
282 t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
283 if err := migrate(app); err != nil {
284 t.Fatal(err)
285 }
286 after, err := os.ReadFile(desktopMigrationLedgerPath())
287 if err != nil || !bytes.Equal(before, after) {
288 t.Fatalf("unchanged migration rewrote ledger: %v", err)
289 }
290 if _, err := os.Stat(filepath.Join(app.desktopSessions.root, newRef.SessionID)); !os.IsNotExist(err) {
291 t.Fatalf("deleted target was resurrected: %v", err)
292 }
293 })
294 }
295 }
296 }
297
298 func TestMigrationCheckpointRejectsSourceChangeDuringImport(t *testing.T) {
299 isolateDesktopUserDirs(t)
300 path := filepath.Join(t.TempDir(), "source.jsonl")
301 if err := os.WriteFile(path, []byte("before"), 0o600); err != nil {
302 t.Fatal(err)
303 }
304 checkpoint, err := newDesktopMigrationCheckpoint(desktopMigrationSource{}, "source", legacyMigrationSourceFiles(path))
305 if err != nil {
306 t.Fatal(err)
307 }
308 // An event sidecar can advance while the JSONL checkpoint stays untouched.
309 if err := os.WriteFile(store.SessionEventLog(path), []byte("new durable events"), 0o600); err != nil {
310 t.Fatal(err)
311 }
312 if err := checkpoint.complete("target", "digest"); err == nil {
313 t.Fatal("must not certify a revision that was not the frozen import input")
314 }
315 record := onlyMigrationRecord(t)
316 if record.Status != "failed" || record.ErrorCode != "source_changed" || record.SourceRevision != "" {
317 t.Fatalf("source race must remain retryable: %#v", record)
318 }
319 }
320
321 func TestMigrationLedgerOptionalRevisionPreservesUnknownFields(t *testing.T) {
322 isolateDesktopUserDirs(t)
323 path := desktopMigrationLedgerPath()
324 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
325 t.Fatal(err)
326 }
327 original := []byte(`{"version":1,"futureRoot":{"keep":true},"records":{"source":{"sourceKey":"source","targetSessionId":"target","status":"completed","attempts":1,"contentDigest":"digest","futureRecord":"keep"}}}`)
328 if err := os.WriteFile(path, original, 0o600); err != nil {
329 t.Fatal(err)
330 }
331 if err := updateDesktopMigrationLedger("source", "target", "completed", "", "digest", "stat-v1-revision"); err != nil {
332 t.Fatal(err)
333 }
334 body, err := os.ReadFile(path)
335 if err != nil {
336 t.Fatal(err)
337 }
338 var preserved struct {
339 FutureRoot map[string]bool `json:"futureRoot"`
340 Records map[string]struct {
341 FutureRecord string `json:"futureRecord"`
342 SourceRevision string `json:"sourceRevision"`
343 } `json:"records"`
344 }
345 if err := json.Unmarshal(body, &preserved); err != nil || !preserved.FutureRoot["keep"] || preserved.Records["source"].FutureRecord != "keep" || preserved.Records["source"].SourceRevision != "stat-v1-revision" {
346 t.Fatalf("optional field update lost data: %s, %v", body, err)
347 }
348 // The previous reader ignores the optional field while retaining its
349 // existing diagnostics/status contract.
350 var previous struct {
351 Version int `json:"version"`
352 Records map[string]struct {
353 Status string `json:"status"`
354 ContentDigest string `json:"contentDigest"`
355 } `json:"records"`
356 }
357 if err := json.Unmarshal(body, &previous); err != nil || previous.Version != 1 || previous.Records["source"].Status != "completed" || previous.Records["source"].ContentDigest != "digest" {
358 t.Fatalf("old reader contract changed: %#v, %v", previous, err)
359 }
360 }
361
362 func TestMigrationRefusesUnreadableOrFutureLedger(t *testing.T) {
363 for _, body := range []string{`{"version":2,"records":{}}`, `{broken`} {
364 t.Run(body, func(t *testing.T) {
365 isolateDesktopUserDirs(t)
366 root := config.SessionStoreDir()
367 coldV4MigrationFixture(t, root, "native")
368 path := desktopMigrationLedgerPath()
369 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
370 t.Fatal(err)
371 }
372 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
373 t.Fatal(err)
374 }
375 app := NewApp()
376 t.Cleanup(app.closeSessionServices)
377 if err := app.migrateCanonicalStore(t.Context(), desktopMigrationSource{root: root, scope: "global"}); err == nil {
378 t.Fatal("unknown adoption state must not trigger another import")
379 }
380 after, err := os.ReadFile(path)
381 if err != nil || string(after) != body {
382 t.Fatalf("ledger was overwritten: %s, %v", after, err)
383 }
384 infos, err := listAllCanonicalSessionInfo(t.Context(), app.desktopSessionService("").Query())
385 if err != nil || len(infos) != 0 {
386 t.Fatalf("import ran despite unreadable ledger: %#v, %v", infos, err)
387 }
388 })
389 }
390 }
391
391 lines GO