| 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 |