返回 DeepSeek-Reasonix
session_v5_migration_lineage_test.go
根目录 / desktop / session_v5_migration_lineage_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "testing"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/config"
13 "reasonix/internal/provider"
14 "reasonix/internal/session"
15 )
16
17 // Re-encode a real conversion in the retired JSONL codec, retaining Source and
18 // the immutable legacy snapshot exactly as old v2 -> v3 migration did.
19 func encodeMigrationFixtureAsV3(t *testing.T, dir string) {
20 t.Helper()
21 commits, err := session.Replay(dir, nil)
22 if err != nil {
23 t.Fatal(err)
24 }
25 manifest, err := readDesktopMigrationManifest(dir)
26 if err != nil {
27 t.Fatal(err)
28 }
29 manifest.SchemaVersion, manifest.Codec, manifest.StorageRevision, manifest.ContentRoot = 3, session.FinalV31Codec, 0, ""
30 writeMigrationJSON(t, filepath.Join(dir, "manifest.json"), manifest)
31 var log bytes.Buffer
32 for _, commit := range commits {
33 commit.SchemaVersion, commit.Codec = 3, session.FinalV31Codec
34 if err := json.NewEncoder(&log).Encode(commit); err != nil {
35 t.Fatal(err)
36 }
37 }
38 if err := os.WriteFile(filepath.Join(dir, "events.jsonl"), log.Bytes(), 0o600); err != nil {
39 t.Fatal(err)
40 }
41 if err := os.Remove(filepath.Join(dir, "events.frames")); err != nil {
42 t.Fatal(err)
43 }
44 }
45
46 func convertedV3Fixture(t *testing.T, path, root, head string, extra ...string) string {
47 t.Helper()
48 converted, err := session.MigrateLegacyHead(t.Context(), path, root, head)
49 if err != nil {
50 t.Fatal(err)
51 }
52 service, err := session.NewService("fixture", session.NewFilesystemPersistence(root))
53 if err != nil {
54 t.Fatal(err)
55 }
56 for _, id := range extra {
57 appendMigrationTestMessage(t, service, session.SessionRef{HostID: "fixture", SessionID: converted.TargetID}, id)
58 }
59 if err := service.Shutdown(t.Context()); err != nil {
60 t.Fatal(err)
61 }
62 encodeMigrationFixtureAsV3(t, converted.TargetDir)
63 return converted.TargetID
64 }
65
66 func migrationSingleDAGFixture(t *testing.T) (string, *agent.Session, string) {
67 t.Helper()
68 path := filepath.Join(config.SessionDir(), "original-v2.jsonl")
69 legacy := agent.NewSession("system")
70 legacy.Add(provider.Message{ID: "question", Role: provider.RoleUser, Content: "original question"})
71 if err := legacy.Save(path); err != nil {
72 t.Fatal(err)
73 }
74 heads, err := agent.ListSessionHeads(path)
75 if err != nil || len(heads) != 1 {
76 t.Fatalf("DAG fixture: %v %v", heads, err)
77 }
78 return path, legacy, heads[0].ID
79 }
80
81 func assertLineageRestart(t *testing.T, app *App, count int) {
82 t.Helper()
83 histories := v5MigrationHistories(t, app)
84 if len(histories) != count {
85 t.Fatalf("expected %d independent histories: %v", count, histories)
86 }
87 for id := range histories {
88 appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: id}, "continued-in-v5")
89 }
90 app.closeSessionServices()
91 ledgerBefore, err := os.ReadFile(desktopMigrationLedgerPath())
92 if err != nil {
93 t.Fatal(err)
94 }
95 restarted := NewApp()
96 t.Cleanup(restarted.closeSessionServices)
97 t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
98 for range 2 {
99 if err := restarted.migrateDesktopSessionsV5(t.Context()); err != nil {
100 t.Fatal(err)
101 }
102 }
103 ledgerAfter, err := os.ReadFile(desktopMigrationLedgerPath())
104 if err != nil || !bytes.Equal(ledgerBefore, ledgerAfter) {
105 t.Fatalf("unchanged lineage rewrote ledger: %v", err)
106 }
107 after := v5MigrationHistories(t, restarted)
108 if len(after) != count {
109 t.Fatalf("restart duplicated histories: %v", after)
110 }
111 for id := range histories {
112 if got := after[id]; len(got) == 0 || got[len(got)-1] != "continued-in-v5" {
113 t.Fatalf("continued target lost: %s %v", id, got)
114 }
115 }
116 // Completed aliases must not resurrect a deliberately deleted v5 target.
117 for id := range histories {
118 if err := restarted.desktopSessionService("").Delete(t.Context(), session.SessionRef{HostID: localDesktopHostID, SessionID: id}); err != nil {
119 t.Fatal(err)
120 }
121 if err := restarted.migrateDesktopSessionsV5(t.Context()); err != nil {
122 t.Fatal(err)
123 }
124 if _, err := os.Stat(filepath.Join(restarted.desktopSessions.root, id)); !os.IsNotExist(err) {
125 t.Fatalf("deleted target resurrected: %v", err)
126 }
127 break
128 }
129 }
130
131 func TestDesktopV5LineageV2ConvertedToV3(t *testing.T) {
132 for _, explicitHead := range []bool{false, true} {
133 for _, scenario := range []string{"equal", "v3-newer", "v2-newer", "diverged", "v2-already-in-v5", "v3-already-in-v5"} {
134 t.Run(scenario+map[bool]string{false: "/selected", true: "/explicit"}[explicitHead], func(t *testing.T) {
135 isolateDesktopUserDirs(t)
136 path, legacy, head := migrationSingleDAGFixture(t)
137 selected := ""
138 if explicitHead {
139 selected = head
140 }
141 root := filepath.Join(filepath.Dir(config.SessionStoreDir()), "sessions-v3")
142 var extra []string
143 if scenario == "v3-newer" || scenario == "diverged" || scenario == "v3-already-in-v5" {
144 extra = []string{"continued-in-v3"}
145 }
146 id := convertedV3Fixture(t, path, root, selected, extra...)
147 if scenario == "v2-newer" || scenario == "diverged" {
148 legacy.Add(provider.Message{ID: "v2-added", Role: provider.RoleUser, Content: "continued-in-v2"})
149 if err := legacy.Save(path); err != nil {
150 t.Fatal(err)
151 }
152 }
153 before := migrationSourceSnapshot(t, append(legacyMigrationSourceFiles(path), canonicalMigrationSourceFiles(root, id)...))
154 app := NewApp()
155 t.Cleanup(app.closeSessionServices)
156 if scenario == "v2-already-in-v5" {
157 if err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{root: filepath.Dir(path), scope: "global"}, ""); err != nil {
158 t.Fatal(err)
159 }
160 old := onlyMigrationRecord(t)
161 appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: old.TargetSessionID}, "pre-existing-v5-work")
162 }
163 if scenario == "v3-already-in-v5" {
164 if err := app.migratePreviewSession(t.Context(), desktopMigrationSource{root: root, scope: "global"}, id); err != nil {
165 t.Fatal(err)
166 }
167 old := onlyMigrationRecord(t)
168 appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: old.TargetSessionID}, "pre-existing-v5-work")
169 }
170 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
171 t.Fatal(err)
172 }
173 count := 1
174 if scenario == "diverged" {
175 count = 2
176 }
177 histories := v5MigrationHistories(t, app)
178 if len(histories) != count {
179 t.Fatalf("converted source duplicated: %v", histories)
180 }
181 want := "original question"
182 if scenario == "v3-newer" {
183 want = "continued-in-v3"
184 }
185 if scenario == "v2-newer" {
186 want = "continued-in-v2"
187 }
188 if scenario == "v3-already-in-v5" || scenario == "v2-already-in-v5" {
189 want = "pre-existing-v5-work"
190 }
191 if scenario != "diverged" {
192 for _, history := range histories {
193 if history[len(history)-1] != want {
194 t.Fatalf("newer history lost: %v", history)
195 }
196 }
197 }
198 assertLineageRestart(t, app, count)
199 assertMigrationSourceSnapshot(t, before)
200 })
201 }
202 }
203 }
204
205 func TestDesktopV5LineageResumesAfterTargetPublication(t *testing.T) {
206 isolateDesktopUserDirs(t)
207 path, _, head := migrationSingleDAGFixture(t)
208 root := filepath.Join(filepath.Dir(config.SessionStoreDir()), "sessions-v3")
209 id := convertedV3Fixture(t, path, root, head, "converted continuation")
210 converted := desktopMigrationConversion{Root: root, SessionID: id, HeadID: head, Codec: session.FinalV31Codec, Depth: 1, LegacyDir: filepath.Join(root, id, "legacy")}
211 source := desktopMigrationSource{root: filepath.Dir(path), scope: "global", headConversions: []desktopMigrationConversion{converted}, conversions: map[string][]desktopMigrationConversion{canonicalRuntimeRoot(path): {converted}}}
212 cp, err := newDesktopMigrationCheckpoint(source, desktopLegacyMigrationKey(path), desktopLegacyMigrationFiles(path, source))
213 if err != nil {
214 t.Fatal(err)
215 }
216 app := NewApp()
217 t.Cleanup(app.closeSessionServices)
218 workspace, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
219 if err != nil {
220 t.Fatal(err)
221 }
222 app.desktopSessions.beforeMigrationRegistryCommit = func() error { return fmt.Errorf("injected registry failure") }
223 if err := app.migrateConversionLineage(t.Context(), path, head, source, &cp, workspace); err == nil {
224 t.Fatal("expected interrupted workspace publication")
225 }
226 infos, err := listAllCanonicalSessionInfo(t.Context(), app.desktopSessionService("").Query())
227 if err != nil || len(infos) != 1 {
228 t.Fatalf("expected one durable published target: %v %v", infos, err)
229 }
230 var first string
231 for id := range infos {
232 first = id
233 }
234 app.closeSessionServices()
235 app = NewApp()
236 t.Cleanup(app.closeSessionServices)
237 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
238 t.Fatal(err)
239 }
240 got := v5MigrationHistories(t, app)
241 if len(got) != 1 || len(got[first]) == 0 {
242 t.Fatalf("retry changed or duplicated target: %v", got)
243 }
244 assertLineageRestart(t, app, 1)
245 }
246
247 func TestDesktopV5LineageConvertedHeadDoesNotHideOtherHeads(t *testing.T) {
248 isolateDesktopUserDirs(t)
249 path, legacy, originalHead := migrationSingleDAGFixture(t)
250 child, err := legacy.ForkHead(path, legacy.Snapshot()[1].ID, agent.HeadKindFork, "other")
251 if err != nil {
252 t.Fatal(err)
253 }
254 legacy.Add(provider.Message{ID: "child", Role: provider.RoleUser, Content: "child history"})
255 if err := legacy.Save(path); err != nil {
256 t.Fatal(err)
257 }
258 root := filepath.Join(filepath.Dir(config.SessionStoreDir()), "sessions-v3")
259 convertedV3Fixture(t, path, root, "", "v3 child continuation")
260 // The manifest's omitted head refers to child at conversion time.
261 if err := legacy.SwitchHead(path, originalHead); err != nil {
262 t.Fatal(err)
263 }
264 app := NewApp()
265 t.Cleanup(app.closeSessionServices)
266 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
267 t.Fatal(err)
268 }
269 histories := v5MigrationHistories(t, app)
270 if len(histories) != 2 {
271 t.Fatalf("converted %s hid another head or duplicated itself: %v", child, histories)
272 }
273 assertLineageRestart(t, app, 2)
274 }
275
276 func TestDesktopV5LineageMultipleConversions(t *testing.T) {
277 for _, diverged := range []bool{false, true} {
278 t.Run(map[bool]string{false: "same-history", true: "independent-continuations"}[diverged], func(t *testing.T) {
279 isolateDesktopUserDirs(t)
280 path, _, head := migrationSingleDAGFixture(t)
281 v3root := filepath.Join(filepath.Dir(config.SessionStoreDir()), "sessions-v3")
282 convertedV3Fixture(t, path, v3root, head, "first conversion work")
283 extra := "first conversion work"
284 if diverged {
285 extra = "independent second conversion"
286 }
287 convertedV3Fixture(t, path, config.SessionStoreDir(), head, extra)
288 app := NewApp()
289 t.Cleanup(app.closeSessionServices)
290 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
291 t.Fatal(err)
292 }
293 want := 1
294 if diverged {
295 want = 2
296 }
297 assertLineageRestart(t, app, want)
298 })
299 }
300 }
301
302 func TestDesktopV5LineageMultipleGenerations(t *testing.T) {
303 for _, legacyPresent := range []bool{false, true} {
304 t.Run(map[bool]string{false: "stored-only", true: "v2-v3-v4"}[legacyPresent], func(t *testing.T) {
305 isolateDesktopUserDirs(t)
306 path, _, _ := migrationSingleDAGFixture(t)
307 v3root := filepath.Join(filepath.Dir(config.SessionStoreDir()), "sessions-v3")
308 id := convertedV3Fixture(t, path, v3root, "", "v3 continuation")
309 v4, err := session.NewService("fixture", session.NewFilesystemPersistence(config.SessionStoreDir()))
310 if err != nil {
311 t.Fatal(err)
312 }
313 runtime, _, err := v4.ContinuePrototype(t.Context(), filepath.Join(v3root, id))
314 if err != nil {
315 t.Fatal(err)
316 }
317 appendMigrationTestMessage(t, v4, runtime.Ref(), "v4 continuation")
318 if err := v4.Shutdown(t.Context()); err != nil {
319 t.Fatal(err)
320 }
321 if !legacyPresent {
322 if err := os.Remove(path); err != nil {
323 t.Fatal(err)
324 }
325 }
326 app := NewApp()
327 t.Cleanup(app.closeSessionServices)
328 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
329 t.Fatal(err)
330 }
331 for _, history := range v5MigrationHistories(t, app) {
332 if history[len(history)-1] != "v4 continuation" {
333 t.Fatalf("latest generation lost: %v", history)
334 }
335 }
336 ledger, err := readDesktopMigrationLedger()
337 if err != nil {
338 t.Fatal(err)
339 }
340 ancestor := ledger.Records[desktopCanonicalMigrationKey(v3root, id)]
341 descendant := ledger.Records[desktopCanonicalMigrationKey(config.SessionStoreDir(), runtime.Ref().SessionID)]
342 if ancestor.ContentDigest == "" || ancestor.ContentDigest == descendant.ContentDigest {
343 t.Fatal("ancestor digest was replaced by the continued descendant during staging")
344 }
345 assertLineageRestart(t, app, 1)
346 })
347 }
348 }
349
350 func TestDesktopV5LineageNativeV3AndUnrelatedEqualSessions(t *testing.T) {
351 isolateDesktopUserDirs(t)
352 root := filepath.Join(filepath.Dir(config.SessionStoreDir()), "sessions-v3")
353 messages := []provider.Message{{ID: "same", Role: provider.RoleUser, Content: "same content"}}
354 writeV3MigrationFixture(t, root, "native", session.FinalV31Codec, messages)
355 writeV3MigrationFixture(t, root, "unrelated", session.FinalV31Codec, messages)
356 v4, err := session.NewService("fixture", session.NewFilesystemPersistence(config.SessionStoreDir()))
357 if err != nil {
358 t.Fatal(err)
359 }
360 runtime, _, err := v4.ContinuePrototype(t.Context(), filepath.Join(root, "native"))
361 if err != nil {
362 t.Fatal(err)
363 }
364 appendMigrationTestMessage(t, v4, runtime.Ref(), "native v4 work")
365 if err := v4.Shutdown(t.Context()); err != nil {
366 t.Fatal(err)
367 }
368 app := NewApp()
369 t.Cleanup(app.closeSessionServices)
370 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
371 t.Fatal(err)
372 }
373 assertLineageRestart(t, app, 2)
374 }
375
376 func TestDesktopV5LineageDeletedNativeV3WithMultipleConversions(t *testing.T) {
377 for _, scenario := range []string{"equal", "prefix", "diverged"} {
378 t.Run(scenario, func(t *testing.T) {
379 isolateDesktopUserDirs(t)
380 root := filepath.Join(filepath.Dir(config.SessionStoreDir()), "sessions-v3")
381 writeV3MigrationFixture(t, root, "native", session.FinalV31Codec, []provider.Message{{ID: "original", Role: provider.RoleUser, Content: "original"}})
382 for index, targetRoot := range []string{config.SessionStoreDir(), config.ProjectSessionStoreDir(globalWorkspaceRoot())} {
383 v4, err := session.NewService("fixture", session.NewFilesystemPersistence(targetRoot))
384 if err != nil {
385 t.Fatal(err)
386 }
387 runtime, _, err := v4.ContinuePrototype(t.Context(), filepath.Join(root, "native"))
388 if err != nil {
389 t.Fatal(err)
390 }
391 work := "shared continuation"
392 if index == 1 && scenario == "diverged" {
393 work = "independent continuation"
394 }
395 appendMigrationTestMessage(t, v4, runtime.Ref(), work)
396 if index == 1 && scenario == "prefix" {
397 appendMigrationTestMessage(t, v4, runtime.Ref(), "later work")
398 }
399 if err := v4.Shutdown(t.Context()); err != nil {
400 t.Fatal(err)
401 }
402 }
403 if err := os.RemoveAll(filepath.Join(root, "native")); err != nil {
404 t.Fatal(err)
405 }
406 app := NewApp()
407 t.Cleanup(app.closeSessionServices)
408 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
409 t.Fatal(err)
410 }
411 want := 1
412 if scenario == "diverged" {
413 want = 2
414 }
415 assertLineageRestart(t, app, want)
416 })
417 }
418 }
419
420 func TestDesktopV5LineageIncludesUnstampedPairedCanonical(t *testing.T) {
421 isolateDesktopUserDirs(t)
422 path, legacy, head := migrationSingleDAGFixture(t)
423 root := config.SessionStoreDir()
424 convertedV3Fixture(t, path, filepath.Join(filepath.Dir(root), "sessions-v3"), head, "converted work")
425 canonical, err := session.NewService("fixture", session.NewFilesystemPersistence(root))
426 if err != nil {
427 t.Fatal(err)
428 }
429 runtime, err := canonical.Create(t.Context(), session.CreateOptions{SessionID: agent.BranchID(path)})
430 if err != nil {
431 t.Fatal(err)
432 }
433 for index, message := range legacy.Snapshot() {
434 payload, _ := json.Marshal(map[string]any{"message": message})
435 if _, err := runtime.Session().AppendBatch(t.Context(), fmt.Sprintf("seed-%d", index), []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
436 t.Fatal(err)
437 }
438 }
439 appendMigrationTestMessage(t, canonical, runtime.Ref(), "converted work")
440 appendMigrationTestMessage(t, canonical, runtime.Ref(), "paired canonical work")
441 if err := canonical.Shutdown(t.Context()); err != nil {
442 t.Fatal(err)
443 }
444 app := NewApp()
445 t.Cleanup(app.closeSessionServices)
446 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
447 t.Fatal(err)
448 }
449 for _, history := range v5MigrationHistories(t, app) {
450 if history[len(history)-1] != "paired canonical work" {
451 t.Fatalf("paired canonical history omitted: %v", history)
452 }
453 }
454 assertLineageRestart(t, app, 1)
455 }
456
456 lines GO