返回 DeepSeek-Reasonix
session_upgrade_recovery_test.go
根目录 / desktop / session_upgrade_recovery_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/desktop/internal/workspacestate"
11 "reasonix/internal/agent"
12 "reasonix/internal/config"
13 "reasonix/internal/provider"
14 "reasonix/internal/session"
15 )
16
17 func TestCanonicalPinnedShellUsesRegistryAndArchiveLifecycle(t *testing.T) {
18 isolateDesktopUserDirs(t)
19 app := NewApp()
20 app.ctx = t.Context()
21 installNoopRuntimeEvents(app)
22 pinDesktopSessionRoot(t, app)
23 t.Cleanup(app.closeSessionServices)
24 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
25 if err != nil {
26 t.Fatal(err)
27 }
28 runtime, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{SessionID: "pin-fixture", CWD: globalWorkspaceRoot(), Origin: session.SessionOriginNew})
29 if err != nil {
30 t.Fatal(err)
31 }
32 if _, err := runtime.Session().Flush(t.Context()); err != nil {
33 t.Fatal(err)
34 }
35 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, runtime.Ref().SessionID, ""); err != nil {
36 t.Fatal(err)
37 }
38 tab := &WorkspaceTab{ID: "fixture-tab", Scope: "global", SessionID: runtime.Ref().SessionID, SessionWorkspace: desktopTabWorkspace{ID: workspaceID}}
39 app.tabs[tab.ID] = tab
40 if err := app.ensureTabTopicIndexedForUserTurn(tab); err != nil {
41 t.Fatal(err)
42 }
43 if err := app.RenameCanonicalSession(runtime.Ref(), "Canonical title"); err != nil {
44 t.Fatal(err)
45 }
46 if err := app.SetTopicPinned(tab.TopicID, true); err != nil {
47 t.Fatal(err)
48 }
49 assertPins := func(want int) {
50 t.Helper()
51 snapshot := app.GetProjectTreeSnapshot()
52 pins := []ProjectNode{}
53 for _, project := range snapshot.Projects {
54 pins = append(pins, project.Children...)
55 }
56 if len(pins) != want {
57 t.Fatalf("pins = %+v, want %d", pins, want)
58 }
59 if want == 1 && (pins[0].Label != "Canonical title" || pins[0].Session == nil || pins[0].Session.SessionID != runtime.Ref().SessionID || !pins[0].Pinned) {
60 t.Fatalf("stale pinned shell: %+v", pins[0])
61 }
62 }
63 assertPins(1)
64 if err := app.workspaceRegistry().ArchiveSession(t.Context(), runtime.Ref().SessionID); err != nil {
65 t.Fatal(err)
66 }
67 assertPins(0)
68 if err := app.workspaceRegistry().RestoreSession(t.Context(), runtime.Ref().SessionID); err != nil {
69 t.Fatal(err)
70 }
71 assertPins(1)
72 }
73
74 func TestHistoricalDAGHeadsRestoreIndependentlyWithoutChangingOriginal(t *testing.T) {
75 isolateDesktopUserDirs(t)
76 dir := config.SessionDir()
77 if err := os.MkdirAll(dir, 0700); err != nil {
78 t.Fatal(err)
79 }
80 path := filepath.Join(dir, "branches.jsonl")
81 legacy := agent.NewSession("system")
82 legacy.Add(provider.Message{Role: provider.RoleUser, Content: "shared"})
83 legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: "original answer"})
84 if err := legacy.Save(path); err != nil {
85 t.Fatal(err)
86 }
87 if _, err := legacy.ForkHead(path, legacy.Snapshot()[1].ID, agent.HeadKindFork, "alternate"); err != nil {
88 t.Fatal(err)
89 }
90 legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: "alternate answer"})
91 if err := legacy.Save(path); err != nil {
92 t.Fatal(err)
93 }
94 before, err := desktopSourceFingerprint(path)
95 if err != nil {
96 t.Fatal(err)
97 }
98 app := NewApp()
99 app.ctx = t.Context()
100 pinDesktopSessionRoot(t, app)
101 installNoopRuntimeEvents(app)
102 if err := app.discoverLegacyHeads(t.Context(), path, "legacy", "global", ""); err != nil {
103 t.Fatal(err)
104 }
105 page, err := app.ListRecoveryEntries("", "", 50)
106 if err != nil || len(page.Items) != 1 {
107 t.Fatalf("heads=%+v err=%v", page, err)
108 }
109 preview, err := app.PreviewRecoveryEntry(page.Items[0].ID)
110 if err != nil || len(preview.Messages) == 0 {
111 t.Fatalf("preview=%+v %v", preview, err)
112 }
113 result, err := app.RestoreRecoveryEntry(page.Items[0].ID, "head-restore")
114 if err != nil {
115 t.Fatal(err)
116 }
117 history, err := app.desktopSessionService("").Query().History(t.Context(), result.Session)
118 if err != nil || len(history) != 3 || history[2].Content != "original answer" {
119 t.Fatalf("wrong head: %+v %v", history, err)
120 }
121 after, err := desktopSourceFingerprint(path)
122 if err != nil || before != after {
123 t.Fatalf("DAG source changed: %v", err)
124 }
125 }
126
127 func assertLegacyLifecycle(t *testing.T, app *App, path, lifecycle string) session.SessionRef {
128 t.Helper()
129 state, err := app.workspaceRegistry().Load(t.Context())
130 if err != nil {
131 t.Fatal(err)
132 }
133 mapping, exists := state.SourceMappings[desktopSourceKey(path, "")]
134 if !exists || state.SessionStates[mapping.SessionID].Lifecycle != lifecycle {
135 t.Fatalf("source lifecycle mapping=%+v state=%+v", mapping, state.SessionStates[mapping.SessionID])
136 }
137 if _, err := os.Stat(path); err != nil {
138 t.Fatalf("legacy original was not preserved: %v", err)
139 }
140 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: mapping.SessionID}
141 if _, err := app.desktopSessionService("").Query().Snapshot(t.Context(), ref); err != nil {
142 t.Fatal(err)
143 }
144 return ref
145 }
146
147 func TestArchiveRestartRestoreRestartAndInterruptedReplay(t *testing.T) {
148 for _, phase := range []string{"prepared", "content_ready"} {
149 t.Run(phase, func(t *testing.T) {
150 isolateDesktopUserDirs(t)
151 dir := config.SessionDir()
152 if err := os.MkdirAll(dir, 0700); err != nil {
153 t.Fatal(err)
154 }
155 path := writeLegacySession(t, dir, "replay.jsonl", "durable history", time.Now())
156 app := NewApp()
157 app.ctx = t.Context()
158 pinDesktopSessionRoot(t, app)
159 installNoopRuntimeEvents(app)
160 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
161 if err != nil {
162 t.Fatal(err)
163 }
164 if err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{scope: "global"}, workspaceID); err != nil {
165 t.Fatal(err)
166 }
167 ref := assertLegacyLifecycle(t, app, path, workspacestate.Active)
168 op := workspacestate.Operation{ID: "crash-archive", Kind: "archive", SessionIDs: []string{ref.SessionID}, Lifecycle: workspacestate.Archived}
169 if err := app.workspaceRegistry().BeginOperation(t.Context(), op); err != nil {
170 t.Fatal(err)
171 }
172 if phase == "content_ready" {
173 if err := app.workspaceRegistry().PrepareOperationContent(t.Context(), op.ID, op.SessionIDs, nil, nil); err != nil {
174 t.Fatal(err)
175 }
176 }
177 root := app.desktopSessions.root
178 app.closeSessionServices()
179 restart := func() *App {
180 a := NewApp()
181 a.ctx = t.Context()
182 a.desktopSessions.root = root
183 installNoopRuntimeEvents(a)
184 t.Cleanup(a.closeSessionServices)
185 return a
186 }
187 app = restart()
188 release, err := session.NewFilesystemPersistence(root).AcquireMaintenance(ref.SessionID)
189 if err != nil {
190 t.Fatal(err)
191 }
192 if err := app.recoverDesktopSessionOperations(t.Context()); err == nil {
193 t.Fatal("replay ignored external writer")
194 }
195 assertLegacyLifecycle(t, app, path, workspacestate.Active)
196 release()
197 if err := app.recoverDesktopSessionOperations(t.Context()); err != nil {
198 t.Fatal(err)
199 }
200 assertLegacyLifecycle(t, app, path, workspacestate.Archived)
201 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
202 if err != nil || len(page.Items) != 0 {
203 t.Fatalf("archived sidebar: %+v %v", page, err)
204 }
205 if err := app.RestoreCanonicalSession(ref); err != nil {
206 t.Fatal(err)
207 }
208 app.closeSessionServices()
209 app = restart()
210 assertLegacyLifecycle(t, app, path, workspacestate.Active)
211 page, err = app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
212 if err != nil || len(page.Items) != 1 || page.Items[0].Session == nil || *page.Items[0].Session != ref {
213 t.Fatalf("restored sidebar: %+v %v", page, err)
214 }
215 })
216 }
217 }
218
219 func TestHistoricalSourceFingerprintIgnoresMutableDisplayMetadata(t *testing.T) {
220 dir := t.TempDir()
221 path := writeLegacySession(t, dir, "source.jsonl", "same content", time.Now())
222 before, err := desktopSourceFingerprint(path)
223 if err != nil {
224 t.Fatal(err)
225 }
226 if err := os.WriteFile(path+".meta", []byte(`{"topic_title":"Renamed","preview":"Indexed"}`), 0600); err != nil {
227 t.Fatal(err)
228 }
229 after, err := desktopSourceFingerprint(path)
230 if err != nil || before != after {
231 t.Fatalf("mutable projection changed source identity: %v", err)
232 }
233 }
234
235 func TestHistoricalIdentityDoesNotMergeEqualMessagesOrChangedSources(t *testing.T) {
236 isolateDesktopUserDirs(t)
237 app := NewApp()
238 app.ctx = t.Context()
239 pinDesktopSessionRoot(t, app)
240 workspace, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
241 if err != nil {
242 t.Fatal(err)
243 }
244 paths := []string{writeLegacySession(t, t.TempDir(), "same.jsonl", "identical", time.Now()), writeLegacySession(t, t.TempDir(), "same.jsonl", "identical", time.Now())}
245 refs := []session.SessionRef{}
246 for _, path := range paths {
247 if err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{scope: "global"}, workspace); err != nil {
248 t.Fatal(err)
249 }
250 refs = append(refs, assertLegacyLifecycle(t, app, path, workspacestate.Active))
251 }
252 if refs[0] == refs[1] {
253 t.Fatal("different files with equal messages were merged")
254 }
255 if err := os.WriteFile(paths[0], []byte("{\"role\":\"user\",\"content\":\"changed old source\"}\n"), 0600); err != nil {
256 t.Fatal(err)
257 }
258 if ref, found, err := app.legacyCanonicalRef(t.Context(), paths[0]); err != nil || !found || ref != refs[0] {
259 t.Fatalf("opening adopted history must remain independent of its retained source: %v %v", ref, err)
260 }
261 // An explicit source re-evaluation still quarantines changed history; an
262 // ordinary open no longer performs this expensive scan or mutates recovery.
263 if err := app.migrateLegacySession(t.Context(), paths[0], desktopMigrationSource{scope: "global"}, workspace); err != nil {
264 t.Fatal(err)
265 }
266 state, err := app.workspaceRegistry().Load(t.Context())
267 if err != nil {
268 t.Fatal(err)
269 }
270 if len(state.Workspaces[workspace].SessionIDs) != 2 || len(state.RecoveryEntries) != 1 {
271 t.Fatalf("changed source overwrote or duplicated live sessions: %+v", state)
272 }
273 history, err := app.desktopSessionService("").Query().History(t.Context(), refs[0])
274 if err != nil || len(history) != 1 || history[0].Content != "identical" {
275 t.Fatalf("import overwritten: %+v %v", history, err)
276 }
277 }
278
279 func TestHistoricalWorkspaceConflictRetainsSourceWithoutRegistration(t *testing.T) {
280 isolateDesktopUserDirs(t)
281 app := NewApp()
282 app.ctx = t.Context()
283 pinDesktopSessionRoot(t, app)
284 workspace, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
285 if err != nil {
286 t.Fatal(err)
287 }
288 path := writeLegacySession(t, t.TempDir(), "foreign.jsonl", "preserve", time.Now())
289 body, _ := json.Marshal(map[string]string{"workspace_root": t.TempDir()})
290 if err := os.WriteFile(path+".meta", body, 0600); err != nil {
291 t.Fatal(err)
292 }
293 if err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{scope: "global"}, workspace); err == nil {
294 t.Fatal("conflicting workspace imported")
295 }
296 state, err := app.workspaceRegistry().Load(t.Context())
297 if err != nil || len(state.Workspaces[workspace].SessionIDs) != 0 || len(state.RecoveryEntries) != 1 {
298 t.Fatalf("conflict was not isolated: %+v %v", state, err)
299 }
300 if _, err := os.Stat(path); err != nil {
301 t.Fatal(err)
302 }
303 }
304
305 func TestHistoricalTrashRestorePreservesSourceAndSurvivesRestart(t *testing.T) {
306 isolateDesktopUserDirs(t)
307 dir := config.SessionDir()
308 if err := os.MkdirAll(dir, 0755); err != nil {
309 t.Fatal(err)
310 }
311 path := writeLegacySession(t, dir, "old.jsonl", "keep historical content", time.Now())
312 if err := trashSessionArtifacts(dir, path, filepath.Base(path)); err != nil {
313 t.Fatal(err)
314 }
315 trashPath := filepath.Join(sessionTrashPath(dir), filepath.Base(path), filepath.Base(path))
316 // Historical versions recorded no reliable archive/delete distinction.
317 if err := os.WriteFile(filepath.Join(filepath.Dir(trashPath), sessionTrashMetaFile), []byte(`{"key":"old.jsonl","deletedAt":1}`), 0600); err != nil {
318 t.Fatal(err)
319 }
320 before, err := os.ReadFile(trashPath)
321 if err != nil {
322 t.Fatal(err)
323 }
324 app := NewApp()
325 app.ctx = t.Context()
326 pinDesktopSessionRoot(t, app)
327 installNoopRuntimeEvents(app)
328 if err := app.discoverHistoricalTrash(t.Context()); err != nil {
329 t.Fatal(err)
330 }
331 page, err := app.ListRecoveryEntries("", "", 10)
332 if err != nil || len(page.Items) != 1 {
333 t.Fatalf("discover: %+v %v", page, err)
334 }
335 result, err := app.RestoreRecoveryEntry(page.Items[0].ID, "restore-fixture")
336 if err != nil {
337 t.Fatal(err)
338 }
339 again, err := app.RestoreRecoveryEntry(page.Items[0].ID, "restore-fixture")
340 if err != nil || again != result {
341 t.Fatalf("repeat restore=%+v %v", again, err)
342 }
343 after, err := os.ReadFile(trashPath)
344 if err != nil || string(before) != string(after) {
345 t.Fatal("restore changed legacy source")
346 }
347 history, err := app.ReadSessionHistory(result.Session, "", 32)
348 if err != nil || len(history.Messages) == 0 {
349 t.Fatalf("restored history: %+v %v", history, err)
350 }
351 root := app.desktopSessions.root
352 app.closeSessionServices()
353 restarted := NewApp()
354 restarted.ctx = t.Context()
355 restarted.desktopSessions.root = root
356 t.Cleanup(restarted.closeSessionServices)
357 installNoopRuntimeEvents(restarted)
358 if err := restarted.discoverHistoricalTrash(t.Context()); err != nil {
359 t.Fatal(err)
360 }
361 hidden, err := restarted.ListRecoveryEntries("", "", 10)
362 if err != nil || len(hidden.Items) != 0 {
363 t.Fatalf("restored entry resurrected: %+v %v", hidden, err)
364 }
365 visible, err := restarted.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 10})
366 if err != nil || len(visible.Items) != 1 || visible.Items[0].Session == nil || *visible.Items[0].Session != result.Session {
367 t.Fatalf("restart sidebar=%+v %v", visible, err)
368 }
369 // A committed operation remains replayable even when removable historical
370 // media is no longer available. Its durable result owns the retry.
371 if err := os.Rename(trashPath, trashPath+".offline"); err != nil {
372 t.Fatal(err)
373 }
374 replayed, err := restarted.RestoreRecoveryEntry(page.Items[0].ID, "restore-fixture")
375 if err != nil || replayed != result {
376 t.Fatalf("offline committed retry=%+v %v", replayed, err)
377 }
378 }
379
380 func TestRepeatedLegacyMigrationDoesNotReimportOldSnapshot(t *testing.T) {
381 isolateDesktopUserDirs(t)
382 dir := config.SessionDir()
383 if err := os.MkdirAll(dir, 0755); err != nil {
384 t.Fatal(err)
385 }
386 path := writeLegacySession(t, dir, "source.jsonl", "original", time.Now())
387 app := NewApp()
388 pinDesktopSessionRoot(t, app)
389 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
390 if err != nil {
391 t.Fatal(err)
392 }
393 source := desktopMigrationSource{scope: "global"}
394 if err := app.migrateLegacySession(t.Context(), path, source, workspaceID); err != nil {
395 t.Fatal(err)
396 }
397 state, err := app.workspaceRegistry().Load(t.Context())
398 if err != nil {
399 t.Fatal(err)
400 }
401 id := state.SourceMappings[desktopSourceKey(path, "")].SessionID
402 service := app.desktopSessionService("")
403 binding, err := service.Open(t.Context(), session.SessionRef{HostID: localDesktopHostID, SessionID: id})
404 if err != nil {
405 t.Fatal(err)
406 }
407 runtime, _ := service.Runtime(session.SessionRef{HostID: localDesktopHostID, SessionID: id})
408 payload, _ := json.Marshal(map[string]any{"message": map[string]any{"id": "new-user", "role": "user", "content": "new canonical work"}})
409 if _, err := runtime.Session().AppendBatch(t.Context(), "new-work", []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
410 t.Fatal(err)
411 }
412 if _, err := runtime.Session().Flush(t.Context()); err != nil {
413 t.Fatal(err)
414 }
415 if err := binding.Release(t.Context()); err != nil {
416 t.Fatal(err)
417 }
418 if err := service.Close(t.Context(), runtime.Ref()); err != nil {
419 t.Fatal(err)
420 }
421 if err := app.workspaceRegistry().ArchiveSession(t.Context(), id); err != nil {
422 t.Fatal(err)
423 }
424 if err := app.migrateLegacySession(t.Context(), path, source, workspaceID); err != nil {
425 t.Fatal(err)
426 }
427 state, err = app.workspaceRegistry().Load(t.Context())
428 if err != nil {
429 t.Fatal(err)
430 }
431 if len(state.Workspaces[workspaceID].SessionIDs) != 1 || state.SessionStates[id].Lifecycle != workspacestate.Archived {
432 t.Fatal("rescan duplicated or unarchived the continued session")
433 }
434 messages, err := service.Query().History(t.Context(), runtime.Ref())
435 if err != nil {
436 t.Fatal(err)
437 }
438 if messages[len(messages)-1].Content != "new canonical work" {
439 t.Fatal("rescan replaced newer canonical history")
440 }
441 }
442
443 func TestRecoveryAPIsRejectUnregisteredPaths(t *testing.T) {
444 isolateDesktopUserDirs(t)
445 app := NewApp()
446 pinDesktopSessionRoot(t, app)
447 for _, id := range []string{"/etc/passwd", "../outside", "legacy-unknown"} {
448 if _, err := app.PreviewRecoveryEntry(id); err == nil {
449 t.Fatalf("preview accepted %q", id)
450 }
451 if _, err := app.RestoreRecoveryEntry(id, "op"); err == nil {
452 t.Fatalf("restore accepted %q", id)
453 }
454 }
455 }
456
456 lines GO