返回 DeepSeek-Reasonix
session_v5_migration_versions_test.go
根目录 / desktop / session_v5_migration_versions_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "reflect"
10 "sort"
11 "strings"
12 "testing"
13 "time"
14
15 "reasonix/desktop/internal/workspacestate"
16 "reasonix/internal/agent"
17 "reasonix/internal/config"
18 "reasonix/internal/provider"
19 "reasonix/internal/session"
20 "reasonix/internal/store"
21 )
22
23 func writeMigrationJSON(t *testing.T, path string, value any) {
24 t.Helper()
25 body, err := json.Marshal(value)
26 if err != nil {
27 t.Fatal(err)
28 }
29 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
30 t.Fatal(err)
31 }
32 if err := os.WriteFile(path, append(body, '\n'), 0o600); err != nil {
33 t.Fatal(err)
34 }
35 }
36
37 func migrationSourceSnapshot(t *testing.T, paths []string) map[string][]byte {
38 t.Helper()
39 result := map[string][]byte{}
40 for _, path := range paths {
41 body, err := os.ReadFile(path)
42 if os.IsNotExist(err) {
43 continue
44 }
45 if err != nil {
46 t.Fatal(err)
47 }
48 result[path] = body
49 }
50 return result
51 }
52
53 func assertMigrationSourceSnapshot(t *testing.T, before map[string][]byte) {
54 t.Helper()
55 for path, body := range before {
56 after, err := os.ReadFile(path)
57 if err != nil || !bytes.Equal(body, after) {
58 t.Fatalf("source changed: %s: %v", path, err)
59 }
60 }
61 }
62
63 func v5MigrationHistories(t *testing.T, app *App) map[string][]string {
64 t.Helper()
65 state, err := app.workspaceRegistry().Load(t.Context())
66 if err != nil {
67 t.Fatal(err)
68 }
69 result := map[string][]string{}
70 for _, workspace := range state.Workspaces {
71 for _, id := range workspace.SessionIDs {
72 page, err := app.ReadSessionHistory(session.SessionRef{HostID: localDesktopHostID, SessionID: id}, "", 100)
73 if err != nil {
74 t.Fatal(err)
75 }
76 var content []string
77 for _, message := range page.Messages {
78 content = append(content, message.Content)
79 }
80 result[id] = content
81 }
82 }
83 return result
84 }
85
86 // Make a real, validated commit with the public writer, then encode it using
87 // the historical wire envelope. The retired format uses JSONL, not frames.
88 func writeV3MigrationFixture(t *testing.T, root, id, codec string, messages []provider.Message) {
89 t.Helper()
90 tmp := filepath.Join(t.TempDir(), id)
91 writer, err := session.CreateStore(tmp, id)
92 if err != nil {
93 t.Fatal(err)
94 }
95 var events []session.Event
96 for _, message := range messages {
97 payload, _ := json.Marshal(map[string]any{"message": message})
98 events = append(events, session.Event{Kind: "message/complete", Payload: payload})
99 }
100 if _, err := writer.Append(t.Context(), session.Batch{OperationID: "fixture", Events: events}); err != nil {
101 t.Fatal(err)
102 }
103 if err := writer.Close(t.Context()); err != nil {
104 t.Fatal(err)
105 }
106 commits, err := session.Replay(tmp, nil)
107 if err != nil {
108 t.Fatal(err)
109 }
110 dir := filepath.Join(root, id)
111 writeMigrationJSON(t, filepath.Join(dir, "manifest.json"), session.Manifest{SchemaVersion: 3, Codec: codec, SessionID: id, WriterGeneration: 1, CreatedAt: time.Now().UTC()})
112 var log bytes.Buffer
113 for _, commit := range commits {
114 commit.SchemaVersion, commit.Codec = 3, codec
115 if err := json.NewEncoder(&log).Encode(commit); err != nil {
116 t.Fatal(err)
117 }
118 }
119 if err := os.WriteFile(filepath.Join(dir, "events.jsonl"), log.Bytes(), 0o600); err != nil {
120 t.Fatal(err)
121 }
122 }
123
124 func TestDesktopV5UpgradeV1WithoutMetadata(t *testing.T) {
125 for _, scope := range []string{"global", "project"} {
126 for _, events := range []bool{false, true} {
127 t.Run(scope+"/events="+map[bool]string{false: "no", true: "yes"}[events], func(t *testing.T) {
128 isolateDesktopUserDirs(t)
129 root := config.SessionDir()
130 if scope == "project" {
131 workspace := filepath.Join(t.TempDir(), "中文旧项目")
132 root = desktopSessionDir(workspace)
133 if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: workspace}}}); err != nil {
134 t.Fatal(err)
135 }
136 }
137 path := filepath.Join(root, "old.jsonl")
138 writeMigrationJSON(t, path, provider.Message{Role: provider.RoleUser, Content: "checkpoint"})
139 want := "checkpoint"
140 if events {
141 want = "newer-v1-event"
142 writeMigrationJSON(t, store.SessionEventLog(path), struct {
143 SchemaVersion int `json:"schema_version"`
144 Type string `json:"type"`
145 Messages []provider.Message `json:"messages"`
146 }{1, "replace", []provider.Message{{Role: provider.RoleUser, Content: want}}})
147 }
148 // These are not independent transcripts even though they end in JSONL.
149 for _, suffix := range []string{".turns.jsonl", ".guardian.jsonl", ".conflicts.jsonl"} {
150 if err := os.WriteFile(filepath.Join(root, "old"+suffix), []byte("not a conversation\n"), 0o600); err != nil {
151 t.Fatal(err)
152 }
153 }
154 before := migrationSourceSnapshot(t, legacyMigrationSourceFiles(path))
155 app := NewApp()
156 t.Cleanup(app.closeSessionServices)
157 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
158 t.Fatal(err)
159 }
160 histories := v5MigrationHistories(t, app)
161 if len(histories) != 1 {
162 t.Fatalf("histories=%v", histories)
163 }
164 for id, history := range histories {
165 if !reflect.DeepEqual(history, []string{want}) {
166 t.Fatalf("history=%v", history)
167 }
168 appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: id}, "continued")
169 }
170 app.closeSessionServices()
171 app = NewApp()
172 t.Cleanup(app.closeSessionServices)
173 // A completed source must work without staging or history replay.
174 t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
175 for range 2 {
176 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
177 t.Fatal(err)
178 }
179 }
180 after := v5MigrationHistories(t, app)
181 if len(after) != 1 {
182 t.Fatalf("duplicate after continuation: %v", after)
183 }
184 for _, history := range after {
185 if history[len(history)-1] != "continued" {
186 t.Fatalf("lost continued history: %v", history)
187 }
188 }
189 assertMigrationSourceSnapshot(t, before)
190 })
191 }
192 }
193 }
194
195 func TestDesktopV5UpgradeAllV2HeadsAndOldReceipt(t *testing.T) {
196 for _, previousMigration := range []bool{false, true} {
197 t.Run(map[bool]string{false: "fresh", true: "previous-migrator"}[previousMigration], func(t *testing.T) {
198 isolateDesktopUserDirs(t)
199 path := filepath.Join(config.SessionDir(), "branches.jsonl")
200 legacy := agent.NewSession("system")
201 legacy.Add(provider.Message{ID: "question", Role: provider.RoleUser, Content: "root question"})
202 legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "root answer"})
203 if err := legacy.Save(path); err != nil {
204 t.Fatal(err)
205 }
206 forkAt := legacy.Snapshot()[2].ID
207 child, err := legacy.ForkHead(path, forkAt, agent.HeadKindFork, "child")
208 if err != nil {
209 t.Fatal(err)
210 }
211 legacy.Add(provider.Message{ID: "child-only", Role: provider.RoleUser, Content: "child only"})
212 if err := legacy.Save(path); err != nil {
213 t.Fatal(err)
214 }
215 heads, err := agent.ListSessionHeads(path)
216 if err != nil {
217 t.Fatal(err)
218 }
219 var rootHead string
220 for _, head := range heads {
221 if head.ID != child {
222 rootHead = head.ID
223 }
224 }
225 retired, err := legacy.ForkHead(path, forkAt, agent.HeadKindFork, "deleted")
226 if err != nil {
227 t.Fatal(err)
228 }
229 legacy.Add(provider.Message{ID: "deleted-work", Role: provider.RoleUser, Content: "retired branch"})
230 if err := legacy.Save(path); err != nil {
231 t.Fatal(err)
232 }
233 if err := legacy.SwitchHead(path, child); err != nil {
234 t.Fatal(err)
235 }
236 if err := agent.RetireSessionHead(path, retired); err != nil {
237 t.Fatal(err)
238 }
239 app := NewApp()
240 t.Cleanup(app.closeSessionServices)
241 var oldTarget string
242 if previousMigration {
243 if err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{root: filepath.Dir(path), scope: "global"}, ""); err != nil {
244 t.Fatal(err)
245 }
246 oldTarget = onlyMigrationRecord(t).TargetSessionID
247 appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: oldTarget}, "continued-before-upgrade")
248 // Upgrade must recognize the receipt even if the old app selected
249 // another head after that migration.
250 if err := legacy.SwitchHead(path, rootHead); err != nil {
251 t.Fatal(err)
252 }
253 }
254 before := migrationSourceSnapshot(t, legacyMigrationSourceFiles(path))
255 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
256 t.Fatal(err)
257 }
258 histories := v5MigrationHistories(t, app)
259 if len(histories) != 2 {
260 t.Fatalf("expected both branches: %v", histories)
261 }
262 if previousMigration && histories[oldTarget][len(histories[oldTarget])-1] != "continued-before-upgrade" {
263 t.Fatalf("old adopted target lost: %v", histories)
264 }
265 for id := range histories {
266 appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: id}, "v5-continued")
267 }
268 app.closeSessionServices()
269 app = NewApp()
270 t.Cleanup(app.closeSessionServices)
271 tmp := t.TempDir()
272 t.Setenv("TMPDIR", filepath.Join(tmp, "missing"))
273 for range 2 {
274 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
275 t.Fatal(err)
276 }
277 }
278 if got := v5MigrationHistories(t, app); len(got) != 2 {
279 t.Fatalf("duplicates: %v", got)
280 }
281 assertMigrationSourceSnapshot(t, before)
282 t.Setenv("TMPDIR", tmp)
283 if err := legacy.SwitchHead(path, child); err != nil {
284 t.Fatal(err)
285 }
286 third, err := legacy.ForkHead(path, forkAt, agent.HeadKindFork, "third")
287 if err != nil || third == "" {
288 t.Fatal(err)
289 }
290 legacy.Add(provider.Message{ID: "third-work", Role: provider.RoleUser, Content: "third branch"})
291 if err := legacy.Save(path); err != nil {
292 t.Fatal(err)
293 }
294 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
295 t.Fatal(err)
296 }
297 if got := v5MigrationHistories(t, app); len(got) != 3 {
298 t.Fatalf("new head missing or original duplicated: %v", got)
299 }
300 ledger, err := readDesktopMigrationLedger()
301 if err != nil {
302 t.Fatal(err)
303 }
304 primary := ledger.Records[desktopLegacyMigrationKey(path)].LegacyPrimaryHead
305 if err := agent.RetireSessionHead(path, primary); err != nil {
306 t.Fatal(err)
307 }
308 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
309 t.Fatal(err)
310 }
311 if got := v5MigrationHistories(t, app); len(got) != 3 {
312 t.Fatalf("retiring primary changed other head identities: %v", got)
313 }
314 })
315 }
316 }
317
318 func TestDesktopV5UpgradeV3Formats(t *testing.T) {
319 for _, codec := range []string{session.PrototypeCodec, session.LegacyLinearCodec, session.FinalV31Codec} {
320 t.Run(codec, func(t *testing.T) {
321 isolateDesktopUserDirs(t)
322 root, id := filepath.Join(filepath.Dir(config.SessionStoreDir()), "sessions-v3"), "old-v3"
323 writeV3MigrationFixture(t, root, id, codec, []provider.Message{{ID: "user", Role: provider.RoleUser, Content: "v3 history"}})
324 before := migrationSourceSnapshot(t, canonicalMigrationSourceFiles(root, id))
325 app := NewApp()
326 t.Cleanup(app.closeSessionServices)
327 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
328 t.Fatal(err)
329 }
330 first := onlyMigrationRecord(t)
331 histories := v5MigrationHistories(t, app)
332 if !reflect.DeepEqual(histories[first.TargetSessionID], []string{"v3 history"}) {
333 t.Fatalf("history=%v", histories)
334 }
335 appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: first.TargetSessionID}, "v5 continued")
336 app.closeSessionServices()
337 app = NewApp()
338 t.Cleanup(app.closeSessionServices)
339 t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
340 for range 2 {
341 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
342 t.Fatal(err)
343 }
344 }
345 if record := onlyMigrationRecord(t); record.TargetSessionID != first.TargetSessionID || record.Attempts != first.Attempts {
346 t.Fatalf("remigrated: %#v", record)
347 }
348 assertMigrationSourceSnapshot(t, before)
349 })
350 }
351 }
352
353 func TestDesktopV5UpgradePairedV3AndLegacy(t *testing.T) {
354 for _, relation := range []string{"events-newer", "transcript-newer", "divergent"} {
355 t.Run(relation, func(t *testing.T) {
356 isolateDesktopUserDirs(t)
357 path := filepath.Join(config.SessionDir(), "paired.jsonl")
358 legacy := agent.NewSession("system")
359 legacy.Add(provider.Message{ID: "user", Role: provider.RoleUser, Content: "common"})
360 if err := legacy.Save(path); err != nil {
361 t.Fatal(err)
362 }
363 messages := legacy.Snapshot()
364 switch relation {
365 case "events-newer":
366 messages = append(messages, provider.Message{ID: "newer", Role: provider.RoleAssistant, Content: "event reply"})
367 case "transcript-newer":
368 legacy.Add(provider.Message{ID: "newer", Role: provider.RoleAssistant, Content: "transcript reply"})
369 if err := legacy.Save(path); err != nil {
370 t.Fatal(err)
371 }
372 case "divergent":
373 messages[len(messages)-1].Content = "conflict"
374 }
375 writeV3MigrationFixture(t, config.SessionStoreDir(), agent.BranchID(path), session.LegacyLinearCodec, messages)
376 before := migrationSourceSnapshot(t, desktopLegacySourceFiles(path, config.SessionStoreDir()))
377 app := NewApp()
378 t.Cleanup(app.closeSessionServices)
379 err := app.migrateDesktopSessionsV5(t.Context())
380 if relation == "divergent" {
381 if err == nil {
382 t.Fatal("divergent history silently selected")
383 }
384 if got := v5MigrationHistories(t, app); len(got) != 0 {
385 t.Fatalf("published conflicting source: %v", got)
386 }
387 } else {
388 if err != nil {
389 t.Fatal(err)
390 }
391 histories := v5MigrationHistories(t, app)
392 if len(histories) != 1 {
393 t.Fatalf("paired history duplicated: %v", histories)
394 }
395 for _, history := range histories {
396 if !strings.HasSuffix(history[len(history)-1], "reply") {
397 t.Fatalf("new work lost: %v", history)
398 }
399 }
400 }
401 assertMigrationSourceSnapshot(t, before)
402 })
403 }
404 }
405
406 func TestDesktopV5UpgradeLegacyFailureRetryAndSibling(t *testing.T) {
407 isolateDesktopUserDirs(t)
408 root := config.SessionDir()
409 writeMigrationJSON(t, filepath.Join(root, "healthy.jsonl"), provider.Message{Role: provider.RoleUser, Content: "healthy"})
410 bad := filepath.Join(root, "broken.jsonl")
411 if err := os.WriteFile(bad, []byte("{broken\n"), 0o600); err != nil {
412 t.Fatal(err)
413 }
414 app := NewApp()
415 t.Cleanup(app.closeSessionServices)
416 if err := app.migrateDesktopSessionsV5(t.Context()); err == nil {
417 t.Fatal("broken source silently ignored")
418 }
419 if got := v5MigrationHistories(t, app); len(got) != 1 {
420 t.Fatalf("healthy sibling missing: %v", got)
421 }
422 writeMigrationJSON(t, bad, provider.Message{Role: provider.RoleUser, Content: "repaired"})
423 app.closeSessionServices()
424 app = NewApp()
425 t.Cleanup(app.closeSessionServices)
426 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
427 t.Fatal(err)
428 }
429 state, err := app.workspaceRegistry().Load(t.Context())
430 if err != nil {
431 t.Fatal(err)
432 }
433 ids := state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs
434 sort.Strings(ids)
435 if len(ids) != 2 {
436 t.Fatalf("retry failed: %v", ids)
437 }
438 }
439
440 func TestDesktopV5UpgradePairedCanonicalKeepsPriorAdoption(t *testing.T) {
441 for _, previousMigration := range []bool{false, true} {
442 t.Run(map[bool]string{false: "fresh", true: "already-continued"}[previousMigration], func(t *testing.T) {
443 isolateDesktopUserDirs(t)
444 path := filepath.Join(config.SessionDir(), "paired-canonical.jsonl")
445 writeMigrationJSON(t, path, provider.Message{Role: provider.RoleUser, Content: "obsolete checkpoint"})
446 root, id := config.SessionStoreDir(), agent.BranchID(path)
447 coldV4MigrationFixture(t, root, id)
448 app := NewApp()
449 t.Cleanup(app.closeSessionServices)
450 if previousMigration {
451 if err := app.migrateCanonicalStore(t.Context(), desktopMigrationSource{root: root, scope: "global"}); err != nil {
452 t.Fatal(err)
453 }
454 appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: id}, "v5 continued")
455 }
456 before := migrationSourceSnapshot(t, desktopLegacySourceFiles(path, root))
457 for range 2 {
458 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
459 t.Fatal(err)
460 }
461 }
462 history := v5MigrationHistories(t, app)
463 if len(history) != 1 || history[id][0] != "恢复完整对话" {
464 t.Fatalf("canonical authority lost or duplicated: %v", history)
465 }
466 if previousMigration && history[id][len(history[id])-1] != "v5 continued" {
467 t.Fatalf("continued work lost: %v", history)
468 }
469 assertMigrationSourceSnapshot(t, before)
470 })
471 }
472 }
473
474 func TestDesktopV5UpgradeLegacyResumesWorkspacePublication(t *testing.T) {
475 isolateDesktopUserDirs(t)
476 path := filepath.Join(config.SessionDir(), "interrupted.jsonl")
477 writeMigrationJSON(t, path, provider.Message{Role: provider.RoleUser, Content: "durable history"})
478 app := NewApp()
479 t.Cleanup(app.closeSessionServices)
480 err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{root: filepath.Dir(path), scope: "global"}, "missing-workspace")
481 if !errors.Is(err, workspacestate.ErrWorkspaceNotFound) {
482 t.Fatalf("expected attach failure: %v", err)
483 }
484 first := onlyMigrationRecord(t)
485 if first.Status != "failed" || first.TargetSessionID == "" {
486 t.Fatalf("missing retry receipt: %#v", first)
487 }
488 app.closeSessionServices()
489 app = NewApp()
490 t.Cleanup(app.closeSessionServices)
491 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
492 t.Fatal(err)
493 }
494 current := onlyMigrationRecord(t)
495 if current.Status != "completed" || current.TargetSessionID != first.TargetSessionID {
496 t.Fatalf("retry changed identity: %#v", current)
497 }
498 if got := v5MigrationHistories(t, app); len(got) != 1 {
499 t.Fatalf("interrupted import duplicated: %v", got)
500 }
501 }
502
503 func TestDesktopV5UpgradeProjectV3FailureRetry(t *testing.T) {
504 isolateDesktopUserDirs(t)
505 workspace := filepath.Join(t.TempDir(), "历史项目")
506 if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: workspace}}}); err != nil {
507 t.Fatal(err)
508 }
509 root := filepath.Join(filepath.Dir(config.ProjectSessionStoreDir(workspace)), "sessions-v3")
510 writeV3MigrationFixture(t, root, "healthy", session.FinalV31Codec, []provider.Message{{ID: "user", Role: provider.RoleUser, Content: "healthy"}})
511 writeV3MigrationFixture(t, root, "broken", session.PrototypeCodec, []provider.Message{{ID: "user", Role: provider.RoleUser, Content: "repaired"}})
512 logPath := filepath.Join(root, "broken", "events.jsonl")
513 original, err := os.ReadFile(logPath)
514 if err != nil {
515 t.Fatal(err)
516 }
517 if err := os.WriteFile(logPath, []byte("{damaged}\n"), 0o600); err != nil {
518 t.Fatal(err)
519 }
520 app := NewApp()
521 t.Cleanup(app.closeSessionServices)
522 if err := app.migrateDesktopSessionsV5(t.Context()); err == nil {
523 t.Fatal("damaged preview accepted")
524 }
525 if got := v5MigrationHistories(t, app); len(got) != 1 {
526 t.Fatalf("healthy sibling missing: %v", got)
527 }
528 if err := os.WriteFile(logPath, original, 0o600); err != nil {
529 t.Fatal(err)
530 }
531 app.closeSessionServices()
532 app = NewApp()
533 t.Cleanup(app.closeSessionServices)
534 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
535 t.Fatal(err)
536 }
537 state, err := app.workspaceRegistry().Load(t.Context())
538 if err != nil {
539 t.Fatal(err)
540 }
541 if got := state.Workspaces[desktopWorkspaceID("project", workspace)].SessionIDs; len(got) != 2 {
542 t.Fatalf("project v3 recovery=%v", got)
543 }
544 }
545
545 lines GO