返回 DeepSeek-Reasonix
session_v5_migration_test.go
根目录 / desktop / session_v5_migration_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "testing"
10
11 "reasonix/desktop/internal/workspacestate"
12 "reasonix/internal/agent"
13 "reasonix/internal/config"
14 "reasonix/internal/provider"
15 "reasonix/internal/session"
16 )
17
18 // These fixtures have no Desktop header or open tab, just like v4-only
19 // conversations left behind after a downgrade. Their catalog cache is absent.
20 func coldV4MigrationFixture(t *testing.T, root, id string) *session.Service {
21 t.Helper()
22 service, err := session.NewService("migration-source", session.NewFilesystemPersistence(root))
23 if err != nil {
24 t.Fatal(err)
25 }
26 t.Cleanup(func() { _ = service.Shutdown(t.Context()) })
27 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: id})
28 if err != nil {
29 t.Fatal(err)
30 }
31 payload, _ := json.Marshal(map[string]any{"message": provider.Message{ID: "user", Role: provider.RoleUser, Content: "恢复完整对话"}})
32 if _, err := runtime.Session().AppendBatch(t.Context(), "message", []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
33 t.Fatal(err)
34 }
35 if err := service.Close(t.Context(), runtime.Ref()); err != nil {
36 t.Fatal(err)
37 }
38 if err := os.RemoveAll(filepath.Join(root, ".query-cache")); err != nil {
39 t.Fatal(err)
40 }
41 info, err := session.NewFilesystemPersistence(root).Stat(t.Context(), id)
42 if err != nil || info.MetadataStatus != session.MetadataPending || info.Turns != 0 || info.Title != "" || info.Preview != "" {
43 t.Fatalf("fixture must reproduce cold, empty display metadata: %#v, %v", info, err)
44 }
45 return service
46 }
47
48 func TestDesktopV5ExplicitImportMigratesColdV4WithoutLegacyOrOpenTab(t *testing.T) {
49 for _, scope := range []string{"project", "global"} {
50 t.Run(scope, func(t *testing.T) {
51 isolateDesktopUserDirs(t)
52 workspace := filepath.Join(t.TempDir(), "中文项目")
53 if err := os.MkdirAll(workspace, 0o700); err != nil {
54 t.Fatal(err)
55 }
56 sourceRoot := config.SessionStoreDir()
57 if scope == "project" {
58 sourceRoot = config.ProjectSessionStoreDir(workspace)
59 if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: workspace}}}); err != nil {
60 t.Fatal(err)
61 }
62 }
63 const id = "v4-native"
64 coldV4MigrationFixture(t, sourceRoot, id)
65 original := map[string][]byte{}
66 for _, name := range []string{"manifest.json", "events.frames"} {
67 body, err := os.ReadFile(filepath.Join(sourceRoot, id, name))
68 if err != nil {
69 t.Fatal(err)
70 }
71 original[name] = body
72 }
73 for attempt := range 2 {
74 app := NewApp()
75 t.Cleanup(app.closeSessionServices)
76 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
77 t.Fatal(err)
78 }
79 workspaceID := workspacestate.GlobalWorkspaceID
80 if scope == "project" {
81 workspaceID = desktopWorkspaceID(scope, workspace)
82 }
83 state, err := app.workspaceRegistry().Load(t.Context())
84 if err != nil {
85 t.Fatal(err)
86 }
87 if ids := state.Workspaces[workspaceID].SessionIDs; len(ids) != 1 || ids[0] != id {
88 t.Fatalf("attempt %d: cold v4 membership = %v", attempt, ids)
89 }
90 page, err := app.ReadSessionHistory(session.SessionRef{HostID: localDesktopHostID, SessionID: id}, "", 10)
91 if err != nil || len(page.Messages) != 1 || page.Messages[0].Content != "恢复完整对话" {
92 t.Fatalf("restored history = %#v, %v", page, err)
93 }
94 diagnostics, err := app.GetSessionArchitectureDiagnostics()
95 if err != nil || diagnostics.MigrationCompleted != 1 || diagnostics.SessionHeadersTotal != 1 {
96 t.Fatalf("migration diagnostics = %#v, %v", diagnostics, err)
97 }
98 app.closeSessionServices()
99 }
100 for name, before := range original {
101 after, err := os.ReadFile(filepath.Join(sourceRoot, id, name))
102 if err != nil || !bytes.Equal(before, after) {
103 t.Fatalf("source %s changed: %v", name, err)
104 }
105 }
106 info, err := session.NewFilesystemPersistence(sourceRoot).Stat(t.Context(), id)
107 if err != nil || info.MetadataStatus != session.MetadataPending {
108 t.Fatalf("migration must not rebuild source display metadata: %#v, %v", info, err)
109 }
110 })
111 }
112 }
113
114 func TestCanonicalV4MigrationReportsSourceFailureAndRetriesAfterRepair(t *testing.T) {
115 isolateDesktopUserDirs(t)
116 root := config.SessionStoreDir()
117 coldV4MigrationFixture(t, root, "healthy")
118 coldV4MigrationFixture(t, root, "damaged")
119 manifestPath := filepath.Join(root, "damaged", "manifest.json")
120 original, err := os.ReadFile(manifestPath)
121 if err != nil {
122 t.Fatal(err)
123 }
124 if err := os.WriteFile(manifestPath, []byte("broken"), 0o600); err != nil {
125 t.Fatal(err)
126 }
127 app := NewApp()
128 t.Cleanup(app.closeSessionServices)
129 if err := app.migrateDesktopSessionsV5(t.Context()); err == nil {
130 t.Fatal("damaged source must not silently disappear")
131 }
132 diagnostics, err := app.GetSessionArchitectureDiagnostics()
133 if err != nil || diagnostics.MigrationFailed != 1 || diagnostics.MigrationCompleted != 1 || diagnostics.WorkspaceMembersTotal != 1 {
134 t.Fatalf("failure must be reported while healthy source migrates: %#v, %v", diagnostics, err)
135 }
136 if err := os.WriteFile(manifestPath, original, 0o600); err != nil {
137 t.Fatal(err)
138 }
139 app.closeSessionServices()
140 app = NewApp()
141 t.Cleanup(app.closeSessionServices)
142 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
143 t.Fatal(err)
144 }
145 diagnostics, err = app.GetSessionArchitectureDiagnostics()
146 if err != nil || diagnostics.MigrationFailed != 0 || diagnostics.MigrationCompleted != 2 || diagnostics.WorkspaceMembersTotal != 2 {
147 t.Fatalf("repaired source must retry: %#v, %v", diagnostics, err)
148 }
149 }
150
151 func TestCanonicalV4MigrationRejectsMissingWorkspaceBeforePublication(t *testing.T) {
152 isolateDesktopUserDirs(t)
153 root := config.SessionStoreDir()
154 old := coldV4MigrationFixture(t, root, "published")
155 app := NewApp()
156 t.Cleanup(app.closeSessionServices)
157 source := desktopMigrationSource{root: root, scope: "global"}
158 err := app.migrateCanonicalSession(t.Context(), old, source, "missing-workspace", "published")
159 if !errors.Is(err, workspacestate.ErrWorkspaceNotFound) {
160 t.Fatalf("expected interruption at registry publication: %v", err)
161 }
162 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: "published"}
163 if _, err := app.desktopSessionService("").Query().Snapshot(t.Context(), ref); !errors.Is(err, session.ErrSessionNotFound) {
164 t.Fatalf("invalid workspace must not publish content: %v", err)
165 }
166 app.closeSessionServices()
167 app = NewApp()
168 t.Cleanup(app.closeSessionServices)
169 if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
170 t.Fatal(err)
171 }
172 diagnostics, err := app.GetSessionArchitectureDiagnostics()
173 if err != nil || diagnostics.MigrationFailed != 0 || diagnostics.MigrationCompleted != 1 || diagnostics.SessionHeadersTotal != 1 || diagnostics.WorkspaceMembersTotal != 1 {
174 t.Fatalf("restart must attach existing target exactly once: %#v, %v", diagnostics, err)
175 }
176 }
177
178 func TestCanonicalV4MigrationPublishesHeaderThenWorkspaceMembershipIdempotently(t *testing.T) {
179 isolateDesktopUserDirs(t)
180 root := t.TempDir()
181 sourceRoot := filepath.Join(root, "project", "sessions-v4")
182 sourceService, err := session.NewService("source", session.NewFilesystemPersistence(sourceRoot))
183 if err != nil {
184 t.Fatal(err)
185 }
186 runtime, err := sourceService.Create(t.Context(), session.CreateOptions{SessionID: "legacy-canonical"})
187 if err != nil {
188 t.Fatal(err)
189 }
190 payload, _ := json.Marshal(map[string]any{"message": map[string]any{"id": "user", "role": "user", "content": "migrate me"}})
191 if _, err := runtime.Session().AppendBatch(t.Context(), "content", []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
192 t.Fatal(err)
193 }
194 if _, err := runtime.Session().Flush(t.Context()); err != nil {
195 t.Fatal(err)
196 }
197 if err := sourceService.Close(t.Context(), runtime.Ref()); err != nil {
198 t.Fatal(err)
199 }
200
201 app := NewApp()
202 t.Cleanup(app.closeSessionServices)
203 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
204 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
205 source := desktopMigrationSource{
206 root: sourceRoot, scope: "project", workspaceRoot: filepath.Join(root, "workspace"),
207 exact: map[string]bool{"legacy-canonical": true},
208 }
209 if err := app.migrateCanonicalStore(t.Context(), source); err != nil {
210 t.Fatal(err)
211 }
212 if err := app.migrateCanonicalStore(t.Context(), source); err != nil {
213 t.Fatalf("repeat migration: %v", err)
214 }
215 info, err := app.desktopSessionService("").Query().List(t.Context(), "", 10)
216 if err != nil {
217 t.Fatal(err)
218 }
219 if len(info.Sessions) != 1 || info.Sessions[0].SessionID != "legacy-canonical" || info.Sessions[0].Origin != session.SessionOriginCanonicalImport {
220 t.Fatalf("migrated sessions = %#v", info.Sessions)
221 }
222 state, err := app.desktopSessions.workspaceState.Load(t.Context())
223 if err != nil {
224 t.Fatal(err)
225 }
226 workspaceID := desktopWorkspaceID("project", source.workspaceRoot)
227 if got := state.Workspaces[workspaceID].SessionIDs; len(got) != 1 || got[0] != "legacy-canonical" {
228 t.Fatalf("workspace sessions = %#v", got)
229 }
230 }
231
232 func TestExactLegacyTabMigrationFreezesIntoHeaderBackedSession(t *testing.T) {
233 isolateDesktopUserDirs(t)
234 root := t.TempDir()
235 legacyDir := filepath.Join(root, "sessions")
236 legacyPath := filepath.Join(legacyDir, "open-tab.jsonl")
237 legacy := agent.NewSession("system")
238 legacy.Add(provider.Message{ID: "user", Role: provider.RoleUser, Content: "legacy content"})
239 if err := legacy.Save(legacyPath); err != nil {
240 t.Fatal(err)
241 }
242 app := NewApp()
243 t.Cleanup(app.closeSessionServices)
244 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
245 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
246 source := desktopMigrationSource{root: legacyDir, scope: "global", exact: map[string]bool{legacyPath: true}}
247 if err := app.migrateLegacyDirectory(t.Context(), source); err != nil {
248 t.Fatal(err)
249 }
250 state, err := app.desktopSessions.workspaceState.Load(t.Context())
251 if err != nil {
252 t.Fatal(err)
253 }
254 ids := state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs
255 if len(ids) != 1 {
256 t.Fatalf("migrated ids = %#v", ids)
257 }
258 page, err := app.ReadSessionHistory(session.SessionRef{HostID: "local", SessionID: ids[0]}, "", 10)
259 if err != nil {
260 t.Fatal(err)
261 }
262 if len(page.Messages) != 2 || page.Messages[1].Content != "legacy content" {
263 t.Fatalf("legacy history = %#v", page.Messages)
264 }
265 info, err := app.desktopSessionService("").Query().List(t.Context(), "", 10)
266 if err != nil || len(info.Sessions) != 1 || info.Sessions[0].Origin != session.SessionOriginLegacyImport {
267 t.Fatalf("legacy header list = %#v, err=%v", info.Sessions, err)
268 }
269 }
270
271 func TestPendingCreateRecoveryAttachesDurableSessionAndDropsMissingReservation(t *testing.T) {
272 root := t.TempDir()
273 app := NewApp()
274 t.Cleanup(app.closeSessionServices)
275 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
276 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
277 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", filepath.Join(root, "project"))
278 if err != nil {
279 t.Fatal(err)
280 }
281 for _, pending := range []workspacestate.PendingCreate{
282 {OperationID: "durable-op", WorkspaceID: workspaceID, SessionID: "durable"},
283 {OperationID: "missing-op", WorkspaceID: workspaceID, SessionID: "missing"},
284 } {
285 if err := app.desktopSessions.workspaceState.BeginCreate(t.Context(), pending); err != nil {
286 t.Fatal(err)
287 }
288 }
289 runtime, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{
290 SessionID: "durable", CWD: root, Origin: session.SessionOriginNew,
291 })
292 if err != nil {
293 t.Fatal(err)
294 }
295 if _, err := runtime.Session().Flush(t.Context()); err != nil {
296 t.Fatal(err)
297 }
298 if err := app.recoverDesktopPendingCreates(t.Context()); err != nil {
299 t.Fatal(err)
300 }
301 state, err := app.desktopSessions.workspaceState.Load(t.Context())
302 if err != nil {
303 t.Fatal(err)
304 }
305 if got := state.Workspaces[workspaceID].SessionIDs; len(got) != 1 || got[0] != "durable" {
306 t.Fatalf("workspace sessions = %#v", got)
307 }
308 if len(state.PendingCreates) != 0 {
309 t.Fatalf("pending creates = %#v", state.PendingCreates)
310 }
311 }
312
313 func TestStartupRecoveryDoesNotAbortNewInFlightCreate(t *testing.T) {
314 app := NewApp()
315 t.Cleanup(app.closeSessionServices)
316 root := t.TempDir()
317 app.desktopSessions.root = filepath.Join(root, "sessions")
318 store := workspacestate.NewStore(filepath.Join(root, "state.json"))
319 app.desktopSessions.workspaceState = store
320 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
321 if err != nil {
322 t.Fatal(err)
323 }
324 startup, err := store.Load(t.Context())
325 if err != nil {
326 t.Fatal(err)
327 }
328 if err := store.BeginCreate(t.Context(), workspacestate.PendingCreate{OperationID: "live", WorkspaceID: workspaceID, SessionID: "live"}); err != nil {
329 t.Fatal(err)
330 }
331 if err := app.recoverDesktopPendingCreateSnapshot(t.Context(), startup.PendingCreates); err != nil {
332 t.Fatal(err)
333 }
334 after, err := store.Load(t.Context())
335 if err != nil {
336 t.Fatal(err)
337 }
338 if after.PendingCreates["live"].OperationID != "live" {
339 t.Fatal("startup replay removed the current create reservation")
340 }
341 }
342
343 func TestCanonicalMigrationRemapsConflictingSessionIDDeterministically(t *testing.T) {
344 root := t.TempDir()
345 sourceRoot := filepath.Join(root, "old")
346 sourceService, err := session.NewService("migration-source", session.NewFilesystemPersistence(sourceRoot))
347 if err != nil {
348 t.Fatal(err)
349 }
350 sourceRuntime, err := sourceService.Create(t.Context(), session.CreateOptions{SessionID: "same-id"})
351 if err != nil {
352 t.Fatal(err)
353 }
354 appendMessage := func(runtime *session.Runtime, id, content string) {
355 payload, _ := json.Marshal(map[string]any{"message": map[string]any{"id": id, "role": "user", "content": content}})
356 if _, err := runtime.Session().AppendBatch(t.Context(), id, []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
357 t.Fatal(err)
358 }
359 if _, err := runtime.Session().Flush(t.Context()); err != nil {
360 t.Fatal(err)
361 }
362 }
363 appendMessage(sourceRuntime, "source", "source content")
364 if err := sourceService.Close(t.Context(), sourceRuntime.Ref()); err != nil {
365 t.Fatal(err)
366 }
367
368 app := NewApp()
369 t.Cleanup(app.closeSessionServices)
370 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
371 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
372 targetRuntime, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{SessionID: "same-id", CWD: root, Origin: session.SessionOriginNew})
373 if err != nil {
374 t.Fatal(err)
375 }
376 appendMessage(targetRuntime, "target", "different target content")
377
378 source := desktopMigrationSource{root: sourceRoot, scope: "global", exact: map[string]bool{"same-id": true}}
379 if err := app.migrateCanonicalStore(t.Context(), source); err != nil {
380 t.Fatal(err)
381 }
382 if err := app.migrateCanonicalStore(t.Context(), source); err != nil {
383 t.Fatalf("repeat conflict migration: %v", err)
384 }
385 state, err := app.desktopSessions.workspaceState.Load(t.Context())
386 if err != nil {
387 t.Fatal(err)
388 }
389 ids := state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs
390 if len(ids) != 1 || len(ids[0]) < len("migr-") || ids[0][:len("migr-")] != "migr-" {
391 t.Fatalf("conflict ids = %#v", ids)
392 }
393 page, err := app.ReadSessionHistory(session.SessionRef{HostID: localDesktopHostID, SessionID: ids[0]}, "", 10)
394 if err != nil {
395 t.Fatal(err)
396 }
397 if len(page.Messages) != 1 || page.Messages[0].Content != "source content" {
398 t.Fatalf("remapped history = %#v", page.Messages)
399 }
400 }
401
401 lines GO