返回 DeepSeek-Reasonix
session_lifecycle_api_test.go
根目录 / desktop / session_lifecycle_api_test.go
1 package main
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "errors"
8 "os"
9 "path/filepath"
10 "testing"
11 "time"
12
13 "reasonix/desktop/internal/workspacestate"
14 "reasonix/internal/config"
15 "reasonix/internal/provider"
16 "reasonix/internal/session"
17 )
18
19 func TestPublicDeleteArchivesLegacyWithoutMovingOriginal(t *testing.T) {
20 isolateDesktopUserDirs(t)
21 a := NewApp()
22 a.ctx = t.Context()
23 pinDesktopSessionRoot(t, a)
24 installNoopRuntimeEvents(a)
25 t.Cleanup(a.closeSessionServices)
26 dir := config.SessionDir()
27 if err := os.MkdirAll(dir, 0700); err != nil {
28 t.Fatal(err)
29 }
30 path := writeLegacySession(t, dir, "public-archive.jsonl", "preserved original", time.Now())
31 before, err := desktopSourceFingerprint(path)
32 if err != nil {
33 t.Fatal(err)
34 }
35 if err := a.DeleteSession(path); err != nil {
36 t.Fatal(err)
37 }
38 after, err := desktopSourceFingerprint(path)
39 if err != nil || before != after {
40 t.Fatalf("archive modified legacy original: %v", err)
41 }
42 page, err := a.ListTrashEntries("", "", 50)
43 if err != nil || len(page.Items) != 1 {
44 t.Fatalf("archive not in trash: %+v %v", page, err)
45 }
46 ref := page.Items[0].Ref
47 if ref == nil {
48 t.Fatal("canonical trash entry is missing its session reference")
49 }
50 if result, err := a.ApplySessionLifecycle(lifecycleRequest(t, a, *ref, "restore-public-archive", "restore")); err != nil || !result.Committed {
51 t.Fatalf("restore: %+v %v", result, err)
52 }
53 if err := a.ArchiveCanonicalSession(*ref); err != nil {
54 t.Fatal(err)
55 }
56 if err := a.PurgeCanonicalSession(*ref); err != nil {
57 t.Fatal(err)
58 }
59 if err := a.migrateDesktopSessionsV5(t.Context()); err != nil {
60 t.Fatal(err)
61 }
62 state, err := a.workspaceRegistry().Load(t.Context())
63 if err != nil {
64 t.Fatal(err)
65 }
66 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Deleted {
67 t.Fatal("rescan revived purged identity")
68 }
69 for _, w := range state.Workspaces {
70 if len(w.SessionIDs) != 0 {
71 t.Fatalf("rescan recreated source under a new ID: %v", w.SessionIDs)
72 }
73 }
74 if _, err := os.Stat(path); err != nil {
75 t.Fatal("upgrade original removed by purge")
76 }
77 }
78
79 func TestPublicLegacyTrashPurgeRetainsUpgradeOriginal(t *testing.T) {
80 isolateDesktopUserDirs(t)
81 a := NewApp()
82 a.ctx = t.Context()
83 pinDesktopSessionRoot(t, a)
84 installNoopRuntimeEvents(a)
85 t.Cleanup(a.closeSessionServices)
86 dir := config.SessionDir()
87 if err := os.MkdirAll(dir, 0700); err != nil {
88 t.Fatal(err)
89 }
90 path := writeLegacySession(t, dir, "old-trash.jsonl", "retained upgrade evidence", time.Now())
91 if err := deleteSessionFile(dir, path); err != nil {
92 t.Fatal(err)
93 }
94 trash := filepath.Join(dir, sessionTrashDir, "old-trash.jsonl", "old-trash.jsonl")
95 before, err := desktopSourceFingerprint(trash)
96 if err != nil {
97 t.Fatal(err)
98 }
99 if err := a.discoverHistoricalTrash(t.Context()); err != nil {
100 t.Fatal(err)
101 }
102 page, err := a.ListTrashEntries("", "", 50)
103 if err != nil || len(page.Items) != 1 {
104 t.Fatalf("legacy trash missing: %+v %v", page, err)
105 }
106 if page.Items[0].ArchivedAt != trashedSessionDeletedAt(trash) {
107 t.Fatal("legacy archive time changed")
108 }
109 if err := a.PurgeTrashedSession(trash); err != nil {
110 t.Fatal(err)
111 }
112 after, err := desktopSourceFingerprint(trash)
113 if err != nil || before != after {
114 t.Fatalf("original removed: %v", err)
115 }
116 if err := a.discoverHistoricalTrash(t.Context()); err != nil {
117 t.Fatal(err)
118 }
119 page, err = a.ListTrashEntries("", "", 50)
120 if err != nil || len(page.Items) != 0 {
121 t.Fatalf("purged history revived: %+v %v", page, err)
122 }
123 }
124
125 func lifecycleFixture(t *testing.T) (*App, session.SessionRef) {
126 t.Helper()
127 isolateDesktopUserDirs(t)
128 a := NewApp()
129 a.ctx = t.Context()
130 pinDesktopSessionRoot(t, a)
131 installNoopRuntimeEvents(a)
132 t.Cleanup(a.closeSessionServices)
133 w, err := a.ensureDesktopWorkspace(t.Context(), "global", "")
134 if err != nil {
135 t.Fatal(err)
136 }
137 s := a.desktopSessionService("")
138 runtime, err := s.Create(t.Context(), session.CreateOptions{SessionID: "lifecycle-fixture", CWD: globalWorkspaceRoot(), Origin: session.SessionOriginNew})
139 if err != nil {
140 t.Fatal(err)
141 }
142 appendSessionTestMessage(t, runtime, "user", provider.Message{ID: "user", Role: provider.RoleUser, Content: "retained history"})
143 ref := runtime.Ref()
144 if err := s.Close(t.Context(), ref); err != nil {
145 t.Fatal(err)
146 }
147 if err := a.workspaceRegistry().AttachSession(t.Context(), "", w, ref.SessionID, ""); err != nil {
148 t.Fatal(err)
149 }
150 return a, ref
151 }
152
153 func addLifecycleFixtureSession(t *testing.T, a *App, id string) session.SessionRef {
154 t.Helper()
155 service := a.desktopSessionService("")
156 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: id, CWD: globalWorkspaceRoot(), Origin: session.SessionOriginNew})
157 if err != nil {
158 t.Fatal(err)
159 }
160 appendSessionTestMessage(t, runtime, "user", provider.Message{ID: "user-" + id, Role: provider.RoleUser, Content: "retained " + id})
161 ref := runtime.Ref()
162 if err := service.Close(t.Context(), ref); err != nil {
163 t.Fatal(err)
164 }
165 if err := a.workspaceRegistry().AttachSession(t.Context(), "", workspacestate.GlobalWorkspaceID, ref.SessionID, ""); err != nil {
166 t.Fatal(err)
167 }
168 return ref
169 }
170
171 func rewriteLifecycleRegistry(t *testing.T, store *workspacestate.Store, change func(*workspacestate.State)) {
172 t.Helper()
173 state, err := store.Load(t.Context())
174 if err != nil {
175 t.Fatal(err)
176 }
177 change(&state)
178 state.Generation++
179 state.Initialized = true
180 body, err := json.Marshal(state)
181 if err != nil {
182 t.Fatal(err)
183 }
184 if err := os.WriteFile(store.Path(), append(body, '\n'), 0o600); err != nil {
185 t.Fatal(err)
186 }
187 }
188
189 func beginLifecycleCommandForTest(t *testing.T, store *workspacestate.Store, req SessionLifecycleRequest) {
190 t.Helper()
191 body, err := json.Marshal(req)
192 if err != nil {
193 t.Fatal(err)
194 }
195 sum := sha256.Sum256(body)
196 if err := store.BeginCommand(t.Context(), "command-"+req.OperationID, hex.EncodeToString(sum[:]), body, req.ExpectedGeneration); err != nil {
197 t.Fatal(err)
198 }
199 }
200
201 func lifecycleRequest(t *testing.T, a *App, ref session.SessionRef, id, action string) SessionLifecycleRequest {
202 t.Helper()
203 state, err := a.workspaceRegistry().Load(t.Context())
204 if err != nil {
205 t.Fatal(err)
206 }
207 return SessionLifecycleRequest{OperationID: id, Action: action, Targets: []SessionLifecycleTarget{{Ref: &ref}}, ExpectedGeneration: state.Generation}
208 }
209
210 func TestExplicitTargetCanonicalLifecycleDoesNotNavigate(t *testing.T) {
211 a, ref := lifecycleFixture(t)
212 a.tabs = map[string]*WorkspaceTab{
213 "active": {ID: "active", TopicID: "unrelated", SessionID: "unrelated", Ready: true},
214 }
215 a.tabOrder = []string{"active"}
216 a.activeTabID = "active"
217 selector := SessionSelector{Ref: &ref}
218
219 archived, err := a.ArchiveSessionTarget(selector)
220 if err != nil || !archived.Committed || archived.TargetKey == "" || archived.OperationID == "" {
221 t.Fatalf("ArchiveSessionTarget = %+v, %v", archived, err)
222 }
223 if a.activeTabID != "active" || a.tabs["active"].TopicID != "unrelated" {
224 t.Fatal("archiving a cold target changed the active tab")
225 }
226 restored, err := a.RestoreSessionTarget(selector)
227 if err != nil || !restored.Committed || restored.LifecycleGeneration <= archived.LifecycleGeneration {
228 t.Fatalf("RestoreSessionTarget = %+v, %v", restored, err)
229 }
230 if a.activeTabID != "active" || a.tabs["active"].TopicID != "unrelated" {
231 t.Fatal("restoring a cold target changed the active tab")
232 }
233 if _, err := a.MoveSessionTarget(selector, workspacestate.GlobalWorkspaceID, ""); err != nil {
234 t.Fatalf("MoveSessionTarget: %v", err)
235 }
236 if _, err := a.ArchiveSessionTarget(selector); err != nil {
237 t.Fatalf("archive before delete: %v", err)
238 }
239 deleted, err := a.DeleteSessionTarget(selector)
240 if err != nil || !deleted.Committed {
241 t.Fatalf("DeleteSessionTarget = %+v, %v", deleted, err)
242 }
243 state, loadErr := a.workspaceRegistry().Load(t.Context())
244 if loadErr != nil || state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Deleted {
245 t.Fatalf("deleted lifecycle = %+v, %v", state.SessionStates[ref.SessionID], loadErr)
246 }
247 }
248
249 func TestManualCanonicalRenameRejectsStaleLifecycleSnapshot(t *testing.T) {
250 a, ref := lifecycleFixture(t)
251 target, err := a.resolveSessionTarget(SessionSelector{Ref: &ref})
252 if err != nil {
253 t.Fatal(err)
254 }
255 if _, err := a.ArchiveSessionTarget(SessionSelector{Ref: &ref}); err != nil {
256 t.Fatal(err)
257 }
258 if _, err := a.RestoreSessionTarget(SessionSelector{Ref: &ref}); err != nil {
259 t.Fatal(err)
260 }
261 if err := a.renameCanonicalSessionTarget(target, "stale title"); !errors.Is(err, workspacestate.ErrMutationConflict) {
262 t.Fatalf("stale lifecycle rename error = %v, want mutation conflict", err)
263 }
264 info, err := a.desktopSessionService("").Query().Stat(t.Context(), ref)
265 if err != nil {
266 t.Fatal(err)
267 }
268 if info.Title == "stale title" {
269 t.Fatal("stale lifecycle rename committed")
270 }
271 }
272
273 func TestExplicitTargetLegacyArchiveAndRestoreUseCanonicalLifecycle(t *testing.T) {
274 isolateDesktopUserDirs(t)
275 a := NewApp()
276 a.ctx = t.Context()
277 pinDesktopSessionRoot(t, a)
278 installNoopRuntimeEvents(a)
279 t.Cleanup(a.closeSessionServices)
280 dir := config.SessionDir()
281 if err := os.MkdirAll(dir, 0o700); err != nil {
282 t.Fatal(err)
283 }
284 path := writeLegacySession(t, dir, "target-lifecycle.jsonl", "preserve legacy source", time.Now())
285 before, err := desktopSourceFingerprint(path)
286 if err != nil {
287 t.Fatal(err)
288 }
289 selector := SessionSelector{SessionPath: path}
290 archived, err := a.ArchiveSessionTarget(selector)
291 if err != nil || !archived.Committed {
292 t.Fatalf("ArchiveSessionTarget legacy = %+v, %v", archived, err)
293 }
294 after, err := desktopSourceFingerprint(path)
295 if err != nil || before != after {
296 t.Fatalf("archive changed legacy source: %v", err)
297 }
298 restored, err := a.RestoreSessionTarget(selector)
299 if err != nil || !restored.Committed {
300 t.Fatalf("RestoreSessionTarget legacy = %+v, %v", restored, err)
301 }
302 target, err := a.resolveSessionTarget(selector)
303 if err != nil || target.SessionRef.SessionID == "" || target.Lifecycle != workspacestate.Active {
304 t.Fatalf("restored legacy target = %+v, %v", target, err)
305 }
306 }
307
308 func TestExplicitTargetLegacyMoveAdoptsWithoutNavigating(t *testing.T) {
309 isolateDesktopUserDirs(t)
310 a := NewApp()
311 a.ctx = t.Context()
312 pinDesktopSessionRoot(t, a)
313 installNoopRuntimeEvents(a)
314 t.Cleanup(a.closeSessionServices)
315 dir := config.SessionDir()
316 if err := os.MkdirAll(dir, 0o700); err != nil {
317 t.Fatal(err)
318 }
319 path := writeLegacySession(t, dir, "target-move.jsonl", "move legacy source", time.Now())
320 before, err := desktopSourceFingerprint(path)
321 if err != nil {
322 t.Fatal(err)
323 }
324 a.tabs = map[string]*WorkspaceTab{"active": {ID: "active", SessionID: "unrelated"}}
325 a.tabOrder = []string{"active"}
326 a.activeTabID = "active"
327
328 result, err := a.MoveSessionTarget(
329 SessionSelector{SessionPath: path},
330 workspacestate.GlobalWorkspaceID,
331 "",
332 )
333 if err != nil || !result.Committed {
334 t.Fatalf("MoveSessionTarget legacy = %+v, %v", result, err)
335 }
336 target, err := a.resolveSessionTarget(SessionSelector{SessionPath: path})
337 if err != nil || target.SessionRef.SessionID == "" || target.Lifecycle != workspacestate.Active {
338 t.Fatalf("moved legacy target = %+v, %v", target, err)
339 }
340 after, err := desktopSourceFingerprint(path)
341 if err != nil || before != after {
342 t.Fatalf("move changed legacy source: %v", err)
343 }
344 if a.activeTabID != "active" || len(a.tabs) != 1 {
345 t.Fatalf("legacy move changed navigation: active=%q tabs=%d", a.activeTabID, len(a.tabs))
346 }
347 }
348
349 func TestLifecycleCommandReceiptDoesNotReplayAfterRestore(t *testing.T) {
350 a, ref := lifecycleFixture(t)
351 req := lifecycleRequest(t, a, ref, "archive", "archive")
352 archived, err := a.ApplySessionLifecycle(req)
353 if err != nil || !archived.Committed {
354 t.Fatalf("archive: %+v %v", archived, err)
355 }
356 page, err := a.ListTrashEntries("", "", 50)
357 if err != nil || len(page.Items) != 1 || page.Items[0].ArchivedAt == 0 {
358 t.Fatalf("trash: %+v %v", page, err)
359 }
360 if result, err := a.ApplySessionLifecycle(lifecycleRequest(t, a, ref, "restore", "restore")); err != nil || !result.Committed {
361 t.Fatalf("restore: %+v %v", result, err)
362 }
363 // New store instance forces a durable receipt read, rather than an RPC cache.
364 a.desktopSessions.workspaceState = workspacestate.NewStore(a.workspaceRegistry().Path())
365 again, err := a.ApplySessionLifecycle(req)
366 if err != nil || again.Generation != archived.Generation {
367 t.Fatalf("replay: %+v %v", again, err)
368 }
369 state, _ := a.workspaceRegistry().Load(t.Context())
370 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Active {
371 t.Fatal("old archive request archived restored session")
372 }
373 req.Action = "purge"
374 if _, err := a.ApplySessionLifecycle(req); err == nil {
375 t.Fatal("reused operation accepted different request")
376 }
377 history, err := a.desktopSessionService("").Query().History(t.Context(), ref)
378 if err != nil || len(history) == 0 {
379 t.Fatalf("history lost: %v", err)
380 }
381 }
382
383 func TestLifecycleCommandPersistsTerminalConflict(t *testing.T) {
384 a, ref := lifecycleFixture(t)
385 store := a.workspaceRegistry()
386 if err := a.ArchiveCanonicalSession(ref); err != nil {
387 t.Fatal(err)
388 }
389 state, err := store.Load(t.Context())
390 if err != nil {
391 t.Fatal(err)
392 }
393 req := SessionLifecycleRequest{
394 OperationID: "terminal-conflict",
395 Action: "purge",
396 Targets: []SessionLifecycleTarget{{Ref: &ref}},
397 ExpectedGeneration: state.Generation,
398 }
399 beginLifecycleCommandForTest(t, store, req)
400 if err := store.RestoreSession(t.Context(), ref.SessionID); err != nil {
401 t.Fatal(err)
402 }
403 if err := store.ArchiveSession(t.Context(), ref.SessionID); err != nil {
404 t.Fatal(err)
405 }
406
407 first, err := a.ApplySessionLifecycle(req)
408 if err != nil {
409 t.Fatal(err)
410 }
411 if first.Committed || len(first.Items) != 1 || first.Items[0].ErrorCode != "state_conflict" || first.Items[0].Retryable {
412 t.Fatalf("terminal conflict result = %+v", first)
413 }
414 state, err = store.Load(t.Context())
415 if err != nil {
416 t.Fatal(err)
417 }
418 if state.PendingOperations["command-terminal-conflict"].Phase != "committed" {
419 t.Fatalf("terminal failure was not finalized: %+v", state.PendingOperations["command-terminal-conflict"])
420 }
421
422 second, err := a.ApplySessionLifecycle(req)
423 if err != nil {
424 t.Fatal(err)
425 }
426 firstBody, _ := json.Marshal(first)
427 secondBody, _ := json.Marshal(second)
428 if string(firstBody) != string(secondBody) {
429 t.Fatalf("terminal retry changed result: first=%s second=%s", firstBody, secondBody)
430 }
431 history, err := a.desktopSessionService("").Query().History(t.Context(), ref)
432 if err != nil || len(history) == 0 {
433 t.Fatalf("terminal conflict changed history: messages=%d err=%v", len(history), err)
434 }
435 }
436
437 func TestLifecycleCommandRetriesOnlyRetryableTargets(t *testing.T) {
438 a, succeededRef := lifecycleFixture(t)
439 conflictRef := addLifecycleFixtureSession(t, a, "lifecycle-conflict")
440 missingRef := session.SessionRef{HostID: localDesktopHostID, SessionID: "lifecycle-missing"}
441 store := a.workspaceRegistry()
442 for _, ref := range []session.SessionRef{succeededRef, conflictRef} {
443 if err := a.ArchiveCanonicalSession(ref); err != nil {
444 t.Fatal(err)
445 }
446 }
447 state, err := store.Load(t.Context())
448 if err != nil {
449 t.Fatal(err)
450 }
451 req := SessionLifecycleRequest{
452 OperationID: "mixed-retry",
453 Action: "purge",
454 Targets: []SessionLifecycleTarget{
455 {Ref: &succeededRef},
456 {Ref: &conflictRef},
457 {Ref: &missingRef},
458 },
459 ExpectedGeneration: state.Generation,
460 }
461 beginLifecycleCommandForTest(t, store, req)
462 if err := store.RestoreSession(t.Context(), conflictRef.SessionID); err != nil {
463 t.Fatal(err)
464 }
465 if err := store.ArchiveSession(t.Context(), conflictRef.SessionID); err != nil {
466 t.Fatal(err)
467 }
468
469 first, err := a.ApplySessionLifecycle(req)
470 if err != nil {
471 t.Fatal(err)
472 }
473 if first.Committed || len(first.Items) != 3 || !first.Items[0].Committed || first.Items[1].ErrorCode != "state_conflict" || first.Items[1].Retryable || !first.Items[2].Retryable {
474 t.Fatalf("mixed first result = %+v", first)
475 }
476 state, err = store.Load(t.Context())
477 if err != nil {
478 t.Fatal(err)
479 }
480 if state.PendingOperations["command-mixed-retry"].Phase == "committed" {
481 t.Fatal("retryable mixed command finalized early")
482 }
483 // Make rerunning the successful child observable: without the saved child
484 // result, the deleted identity no longer has a purge operation to resume.
485 rewriteLifecycleRegistry(t, store, func(state *workspacestate.State) {
486 delete(state.PendingOperations, "purge-"+succeededRef.SessionID)
487 })
488
489 second, err := a.ApplySessionLifecycle(req)
490 if err != nil {
491 t.Fatal(err)
492 }
493 if !second.Items[0].Committed || second.Items[1].ErrorCode != "state_conflict" || !second.Items[2].Retryable {
494 t.Fatalf("mixed retry result = %+v", second)
495 }
496 state, err = store.Load(t.Context())
497 if err != nil {
498 t.Fatal(err)
499 }
500 if _, recreated := state.PendingOperations["purge-"+succeededRef.SessionID]; recreated {
501 t.Fatal("successful target was executed again")
502 }
503 }
504
505 func TestLegacyPreparedPurgeCannotDeleteRestoredSession(t *testing.T) {
506 a, ref := lifecycleFixture(t)
507 store := a.workspaceRegistry()
508 if err := a.ArchiveCanonicalSession(ref); err != nil {
509 t.Fatal(err)
510 }
511 state, err := store.Load(t.Context())
512 if err != nil {
513 t.Fatal(err)
514 }
515 status := state.SessionStates[ref.SessionID]
516 legacy := workspacestate.Operation{
517 ID: "purge-" + ref.SessionID,
518 Kind: "purge",
519 Phase: "prepared",
520 Lifecycle: workspacestate.Deleted,
521 SessionIDs: []string{ref.SessionID},
522 ExpectedGeneration: status.Generation,
523 }
524 rewriteLifecycleRegistry(t, store, func(state *workspacestate.State) {
525 state.PendingOperations[legacy.ID] = legacy
526 })
527
528 restored, err := a.ApplySessionLifecycle(lifecycleRequest(t, a, ref, "restore-legacy-prepared", "restore"))
529 if err != nil || !restored.Committed {
530 t.Fatalf("restore legacy prepared: %+v %v", restored, err)
531 }
532 if err := a.resumeCanonicalPurge(t.Context(), ref, legacy); !errors.Is(err, workspacestate.ErrMutationConflict) {
533 t.Fatalf("obsolete purge replay = %v, want conflict", err)
534 }
535 state, err = store.Load(t.Context())
536 if err != nil {
537 t.Fatal(err)
538 }
539 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Active {
540 t.Fatalf("obsolete purge changed lifecycle: %+v", state.SessionStates[ref.SessionID])
541 }
542 if _, exists := state.PendingOperations[legacy.ID]; exists {
543 t.Fatal("restore left obsolete prepared purge")
544 }
545 history, err := a.desktopSessionService("").Query().History(t.Context(), ref)
546 if err != nil || len(history) == 0 {
547 t.Fatalf("restored history lost: messages=%d err=%v", len(history), err)
548 }
549 if err := a.ArchiveCanonicalSession(ref); err != nil {
550 t.Fatal(err)
551 }
552 if err := a.PurgeCanonicalSession(ref); err != nil {
553 t.Fatalf("new explicit purge after restore: %v", err)
554 }
555 }
556
557 func TestStartupRecoveryCleansStalePreparedWithoutDeletingContent(t *testing.T) {
558 a, ref := lifecycleFixture(t)
559 store := a.workspaceRegistry()
560 if err := a.ArchiveCanonicalSession(ref); err != nil {
561 t.Fatal(err)
562 }
563 state, err := store.Load(t.Context())
564 if err != nil {
565 t.Fatal(err)
566 }
567 legacy := workspacestate.Operation{
568 ID: "purge-" + ref.SessionID,
569 Kind: "purge",
570 Phase: "prepared",
571 Lifecycle: workspacestate.Deleted,
572 SessionIDs: []string{ref.SessionID},
573 ExpectedGeneration: state.SessionStates[ref.SessionID].Generation,
574 }
575 if err := store.RestoreSession(t.Context(), ref.SessionID); err != nil {
576 t.Fatal(err)
577 }
578 rewriteLifecycleRegistry(t, store, func(state *workspacestate.State) {
579 state.PendingOperations[legacy.ID] = legacy
580 })
581
582 if err := a.recoverDesktopSessionOperations(t.Context()); !errors.Is(err, workspacestate.ErrMutationConflict) {
583 t.Fatalf("stale startup recovery = %v, want recorded conflict", err)
584 }
585 state, err = store.Load(t.Context())
586 if err != nil {
587 t.Fatal(err)
588 }
589 if _, exists := state.PendingOperations[legacy.ID]; exists {
590 t.Fatal("startup recovery left stale prepared purge")
591 }
592 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Active {
593 t.Fatalf("startup recovery changed lifecycle: %+v", state.SessionStates[ref.SessionID])
594 }
595 history, err := a.desktopSessionService("").Query().History(t.Context(), ref)
596 if err != nil || len(history) == 0 {
597 t.Fatalf("startup recovery deleted content: messages=%d err=%v", len(history), err)
598 }
599 }
600
601 func TestPurgeInterruptedTombstoneRemainsActionableAndResumes(t *testing.T) {
602 a, ref := lifecycleFixture(t)
603 ctx := t.Context()
604 store := a.workspaceRegistry()
605 if err := a.ArchiveCanonicalSession(ref); err != nil {
606 t.Fatal(err)
607 }
608 state, err := store.Load(ctx)
609 if err != nil {
610 t.Fatal(err)
611 }
612 if err := store.BeginPurge(ctx, ref.SessionID, state.Generation); err != nil {
613 t.Fatal(err)
614 }
615 page, err := a.ListTrashEntries("", "", 50)
616 if err != nil || len(page.Items) != 1 || page.Items[0].CanRestore || page.Items[0].OperationPhase != "tombstoned" {
617 t.Fatalf("pending purge disappeared: %+v %v", page, err)
618 }
619 if _, err := a.OpenSession(ref); err == nil {
620 t.Fatal("tombstoned session opened")
621 }
622 if err := a.recoverDesktopSessionOperations(ctx); err != nil {
623 t.Fatal(err)
624 }
625 deleted, err := a.DeleteSessionTarget(SessionSelector{Ref: &ref})
626 if err != nil || !deleted.Committed {
627 t.Fatalf("resume explicit delete: %+v %v", deleted, err)
628 }
629 page, err = a.ListTrashEntries("", "", 50)
630 if err != nil || len(page.Items) != 0 {
631 t.Fatalf("purge not completed: %+v %v", page, err)
632 }
633 state, _ = store.Load(ctx)
634 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Deleted {
635 t.Fatal("tombstone lost")
636 }
637 if deleted.LifecycleGeneration != state.SessionStates[ref.SessionID].Generation {
638 t.Fatalf("deleted generation = %d, want %d", deleted.LifecycleGeneration, state.SessionStates[ref.SessionID].Generation)
639 }
640 if _, err := os.Stat(filepath.Join(a.desktopSessions.root, ref.SessionID)); !os.IsNotExist(err) {
641 t.Fatalf("body remains: %v", err)
642 }
643 if err := store.AttachSession(ctx, "", "global", ref.SessionID, ""); err == nil {
644 t.Fatal("deleted session revived")
645 }
646 }
647
648 func TestMigrationNestedEvidenceUnknownFields(t *testing.T) {
649 var receipt desktopMigrationReceipt
650 var conversion desktopMigrationConversion
651 if err := json.Unmarshal([]byte(`{"targetSessionId":"s","future":{"x":1}}`), &receipt); err != nil {
652 t.Fatal(err)
653 }
654 if err := json.Unmarshal([]byte(`{"root":"r","sessionId":"s","codec":"c","future":{"x":1}}`), &conversion); err != nil {
655 t.Fatal(err)
656 }
657 for _, value := range []any{receipt, conversion} {
658 body, err := json.Marshal(value)
659 if err != nil {
660 t.Fatal(err)
661 }
662 var fields map[string]json.RawMessage
663 if err := json.Unmarshal(body, &fields); err != nil {
664 t.Fatal(err)
665 }
666 if string(fields["future"]) != `{"x":1}` {
667 t.Fatalf("lost evidence: %s", body)
668 }
669 }
670 }
671
671 lines GO