返回 DeepSeek-Reasonix
legacy_empty_session_cleanup_test.go
根目录 / desktop / legacy_empty_session_cleanup_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "slices"
8 "testing"
9 "time"
10
11 "reasonix/desktop/internal/draftstate"
12 "reasonix/desktop/internal/legacycleanup"
13 "reasonix/desktop/internal/workspacestate"
14 "reasonix/internal/agent"
15 "reasonix/internal/config"
16 "reasonix/internal/session"
17 "reasonix/internal/store"
18 )
19
20 func newLegacyCleanupTestApp(t *testing.T) (*App, string) {
21 t.Helper()
22 isolateDesktopUserDirs(t)
23 app := NewApp()
24 app.ctx = t.Context()
25 pinDesktopSessionRoot(t, app)
26 installNoopRuntimeEvents(app)
27 app.legacyCleanup = legacycleanup.New(filepath.Join(t.TempDir(), "legacy-empty-session-cleanup-v1.json"))
28 t.Cleanup(app.closeSessionServices)
29 return app, t.TempDir()
30 }
31
32 func createLegacyCleanupSession(t *testing.T, app *App, workspaceRoot, id string, withUserMessage bool) (session.SessionRef, string) {
33 t.Helper()
34 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", workspaceRoot)
35 if err != nil {
36 t.Fatal(err)
37 }
38 service := app.desktopSessionService("")
39 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: id, CWD: workspaceRoot, Origin: session.SessionOriginNew})
40 if err != nil {
41 t.Fatal(err)
42 }
43 if err := service.SetTitle(t.Context(), runtime.Ref(), defaultTopicTitle); err != nil {
44 t.Fatal(err)
45 }
46 if withUserMessage {
47 payload, _ := json.Marshal(map[string]any{"message": map[string]any{"id": "user-empty", "role": "user", "content": ""}})
48 if _, err := runtime.Session().AppendBatch(t.Context(), "used", []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
49 t.Fatal(err)
50 }
51 }
52 if _, err := runtime.Session().Flush(t.Context()); err != nil {
53 t.Fatal(err)
54 }
55 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, id, ""); err != nil {
56 t.Fatal(err)
57 }
58 if err := service.Close(t.Context(), runtime.Ref()); err != nil {
59 t.Fatal(err)
60 }
61 return runtime.Ref(), workspaceID
62 }
63
64 func cleanupCandidate(t *testing.T, app *App, id string) legacycleanup.Candidate {
65 t.Helper()
66 state, err := app.legacyCleanup.Load(t.Context())
67 if err != nil {
68 t.Fatal(err)
69 }
70 item, ok := state.Items[id]
71 if !ok {
72 t.Fatalf("candidate %q missing from %+v", id, state.Items)
73 }
74 return item
75 }
76
77 func TestLegacyCleanupArchivesOnlyConclusiveEmptyDefaultSessionsWithoutFallback(t *testing.T) {
78 app, workspaceRoot := newLegacyCleanupTestApp(t)
79 empty, _ := createLegacyCleanupSession(t, app, workspaceRoot, "legacy-empty", false)
80 used, _ := createLegacyCleanupSession(t, app, workspaceRoot, "legacy-used", true)
81 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
82 t.Fatal(err)
83 }
84 app.processLegacyCleanupSession(cleanupCandidate(t, app, "session:"+empty.SessionID))
85 app.processLegacyCleanupSession(cleanupCandidate(t, app, "session:"+used.SessionID))
86 state, err := app.workspaceRegistry().Load(t.Context())
87 if err != nil {
88 t.Fatal(err)
89 }
90 if state.SessionStates[empty.SessionID].Lifecycle != workspacestate.Archived {
91 t.Fatalf("empty lifecycle = %q", state.SessionStates[empty.SessionID].Lifecycle)
92 }
93 if state.SessionStates[used.SessionID].Lifecycle != workspacestate.Active {
94 t.Fatalf("used lifecycle = %q", state.SessionStates[used.SessionID].Lifecycle)
95 }
96 if got := cleanupCandidate(t, app, "session:"+used.SessionID); got.Phase != "has_content" {
97 t.Fatalf("used candidate = %+v", got)
98 }
99 app.mu.RLock()
100 defer app.mu.RUnlock()
101 if len(app.tabs) != 0 {
102 t.Fatalf("cleanup opened fallback tabs: %+v", app.tabs)
103 }
104 }
105
106 func TestLegacyCleanupCanonicalRestoreMarksCandidateProtected(t *testing.T) {
107 app, workspaceRoot := newLegacyCleanupTestApp(t)
108 ref, _ := createLegacyCleanupSession(t, app, workspaceRoot, "legacy-restore", false)
109 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
110 t.Fatal(err)
111 }
112 app.processLegacyCleanupSession(cleanupCandidate(t, app, "session:"+ref.SessionID))
113 page, err := app.ListTrashEntries("", "", 50)
114 if err != nil {
115 t.Fatal(err)
116 }
117 request := SessionLifecycleRequest{
118 OperationID: "restore-cleaned-session", Action: "restore", ExpectedGeneration: page.Generation,
119 Targets: []SessionLifecycleTarget{{Ref: &ref}},
120 }
121 result, err := app.ApplySessionLifecycle(request)
122 if err != nil || !result.Committed {
123 t.Fatalf("restore cleaned session = %+v, %v", result, err)
124 }
125 if got := cleanupCandidate(t, app, "session:"+ref.SessionID); !got.Restored || got.Phase != "restored" || got.Reason != "restored_by_user" {
126 t.Fatalf("restored candidate = %+v", got)
127 }
128 }
129
130 func TestLegacyCleanupFinalFenceRejectsSameTitleRewrite(t *testing.T) {
131 app, workspaceRoot := newLegacyCleanupTestApp(t)
132 ref, _ := createLegacyCleanupSession(t, app, workspaceRoot, "legacy-title-race", false)
133 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
134 t.Fatal(err)
135 }
136 app.legacyCleanupWorker.beforeArchive = func() {
137 if err := app.desktopSessionService("").SetTitle(t.Context(), ref, defaultTopicTitle); err != nil {
138 t.Fatal(err)
139 }
140 }
141 app.processLegacyCleanupSession(cleanupCandidate(t, app, "session:"+ref.SessionID))
142 state, err := app.workspaceRegistry().Load(t.Context())
143 if err != nil {
144 t.Fatal(err)
145 }
146 if state.SessionStates[ref.SessionID].Lifecycle == workspacestate.Archived {
147 t.Fatal("same-value title rewrite was archived using a stale decision")
148 }
149 if got := cleanupCandidate(t, app, "session:"+ref.SessionID); got.Phase != "protected" {
150 t.Fatalf("race candidate = %+v", got)
151 }
152 }
153
154 func TestLegacyCleanupProtectsDraftReservedSession(t *testing.T) {
155 app, workspaceRoot := newLegacyCleanupTestApp(t)
156 ref, workspaceID := createLegacyCleanupSession(t, app, workspaceRoot, "legacy-draft-reserved", false)
157 draft, _, err := app.draftStore().Open(t.Context(), workspaceID, "project", workspaceRoot, "draft-protect", `{}`)
158 if err != nil {
159 t.Fatal(err)
160 }
161 if _, _, err := app.draftStore().BeginOperation(t.Context(), draftstate.Operation{
162 ID: "draft-operation", DraftID: draft.ID, WorkspaceID: workspaceID, DraftRevision: draft.Revision,
163 SessionID: ref.SessionID, SubmissionID: "submission", Fingerprint: "fingerprint", RequestJSON: `{}`, Phase: "reserved",
164 }); err != nil {
165 t.Fatal(err)
166 }
167 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
168 t.Fatal(err)
169 }
170 app.processLegacyCleanupSession(cleanupCandidate(t, app, "session:"+ref.SessionID))
171 if got := cleanupCandidate(t, app, "session:"+ref.SessionID); got.Phase != "protected" || got.Reason != "draft_operation" {
172 t.Fatalf("draft-associated candidate = %+v", got)
173 }
174 }
175
176 func TestLegacyCleanupTreatsRestoredTabWithoutRuntimeAsBusy(t *testing.T) {
177 app, workspaceRoot := newLegacyCleanupTestApp(t)
178 ref, _ := createLegacyCleanupSession(t, app, workspaceRoot, "legacy-restored-tab", false)
179 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
180 t.Fatal(err)
181 }
182 app.mu.Lock()
183 app.tabs["restored-tab"] = &WorkspaceTab{ID: "restored-tab", SessionID: ref.SessionID}
184 app.mu.Unlock()
185 app.processLegacyCleanupSession(cleanupCandidate(t, app, "session:"+ref.SessionID))
186 if got := cleanupCandidate(t, app, "session:"+ref.SessionID); got.Phase != "busy" || got.Reason != "session_open" {
187 t.Fatalf("candidate = %+v", got)
188 }
189 app.mu.Lock()
190 delete(app.tabs, "restored-tab")
191 app.tabsRestored = make(chan struct{})
192 close(app.tabsRestored)
193 app.mu.Unlock()
194 close(app.desktopMigrationDone)
195 // Releasing a view no longer authorizes automatic historical cleanup.
196 // Only the explicit maintenance operation may archive this candidate.
197 app.runLegacyEmptySessionCleanup(false)
198 if got := cleanupCandidate(t, app, "session:"+ref.SessionID); got.Phase != "archived" {
199 t.Fatalf("candidate after runtime release = %+v", got)
200 }
201 }
202
203 func TestLegacyCleanupTopicPlaceholderCanBeRestoredOnce(t *testing.T) {
204 app, _ := newLegacyCleanupTestApp(t)
205 if _, err := app.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil {
206 t.Fatal(err)
207 }
208 topic, err := app.CreateTopic("global", "", "")
209 if err != nil {
210 t.Fatal(err)
211 }
212 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
213 t.Fatal(err)
214 }
215 item := cleanupCandidate(t, app, "topic:"+topic.ID)
216 app.processLegacyCleanupTopic(item)
217 page, err := app.ListTrashEntries("", "", 50)
218 if err != nil {
219 t.Fatal(err)
220 }
221 var entry *TrashEntry
222 for index := range page.Items {
223 if page.Items[index].ID == item.ID {
224 entry = &page.Items[index]
225 break
226 }
227 }
228 if entry == nil || entry.Ref != nil || entry.RecoveryEntryID == "" {
229 t.Fatalf("topic placeholder trash entry = %+v", entry)
230 }
231 request := SessionLifecycleRequest{OperationID: "restore-placeholder", Action: "restore", ExpectedGeneration: page.Generation,
232 Targets: []SessionLifecycleTarget{{WorkspaceID: item.WorkspaceID, RecoveryEntryID: entry.RecoveryEntryID}}}
233 result, err := app.ApplySessionLifecycle(request)
234 if err != nil || !result.Committed || !topicIndexedInRegistry("global", "", topic.ID) {
235 t.Fatalf("restore placeholder = %+v, %v", result, err)
236 }
237 if got := cleanupCandidate(t, app, item.ID); !got.Restored || got.Phase != "restored" {
238 t.Fatalf("restored candidate = %+v", got)
239 }
240 if err := app.restoreLegacyCleanupTopic(item.ID, item.WorkspaceID); err != nil {
241 t.Fatalf("idempotent restore: %v", err)
242 }
243 }
244
245 func TestLegacyCleanupTopicArchivePendingReconcilesAfterLostResult(t *testing.T) {
246 app, _ := newLegacyCleanupTestApp(t)
247 if _, err := app.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil {
248 t.Fatal(err)
249 }
250 topic, err := app.CreateTopic("global", "", "")
251 if err != nil {
252 t.Fatal(err)
253 }
254 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
255 t.Fatal(err)
256 }
257 item := cleanupCandidate(t, app, "topic:"+topic.ID)
258 if !app.markLegacyCleanupTopicArchivePending(item.ID) {
259 t.Fatal("failed to persist archive marker")
260 }
261 if err := app.deleteTopic(topic.ID); err != nil {
262 t.Fatal(err)
263 }
264 item = cleanupCandidate(t, app, item.ID)
265 if !app.reconcileLegacyCleanupTopicArchive(item) {
266 t.Fatal("pending topic archive was not reconciled")
267 }
268 if got := cleanupCandidate(t, app, item.ID); got.Phase != "archived" {
269 t.Fatalf("candidate = %+v", got)
270 }
271 }
272
273 func TestLegacyCleanupTopicPlaceholderCanBePurgedWithoutSession(t *testing.T) {
274 app, _ := newLegacyCleanupTestApp(t)
275 if _, err := app.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil {
276 t.Fatal(err)
277 }
278 topic, err := app.CreateTopic("global", "", "")
279 if err != nil {
280 t.Fatal(err)
281 }
282 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
283 t.Fatal(err)
284 }
285 item := cleanupCandidate(t, app, "topic:"+topic.ID)
286 app.processLegacyCleanupTopic(item)
287 page, err := app.ListTrashEntries("", "", 50)
288 if err != nil {
289 t.Fatal(err)
290 }
291 var entry TrashEntry
292 for _, candidate := range page.Items {
293 if candidate.ID == item.ID {
294 entry = candidate
295 }
296 }
297 if entry.RecoveryEntryID == "" || !entry.CanPurge || entry.Ref != nil {
298 t.Fatalf("placeholder entry = %+v", entry)
299 }
300 request := SessionLifecycleRequest{OperationID: "purge-placeholder", Action: "purge", ExpectedGeneration: page.Generation,
301 Targets: []SessionLifecycleTarget{{WorkspaceID: item.WorkspaceID, RecoveryEntryID: entry.RecoveryEntryID}}}
302 result, err := app.ApplySessionLifecycle(request)
303 if err != nil || !result.Committed {
304 t.Fatalf("purge placeholder = %+v, %v", result, err)
305 }
306 got := cleanupCandidate(t, app, item.ID)
307 if got.Phase != "purged" || !got.Restored || got.Topic != nil {
308 t.Fatalf("purged candidate = %+v", got)
309 }
310 }
311
312 func TestLegacyCleanupDefaultTitleSetIsExact(t *testing.T) {
313 for _, title := range []string{"", " ", "新的会话", "新的會話", "New session", "新建会话", "新建會話", "新会话"} {
314 if !isDefaultTopicTitle(title) {
315 t.Fatalf("default title %q did not match", title)
316 }
317 }
318 for _, title := range []string{"新建会话(2)", "New session 2", "prefix 新的会话", "新的会话 suffix", "My session"} {
319 if isDefaultTopicTitle(title) {
320 t.Fatalf("custom title %q matched", title)
321 }
322 }
323 }
324
325 func TestLegacyCleanupCanonicalDurableEvidenceIsConservative(t *testing.T) {
326 app, workspaceRoot := newLegacyCleanupTestApp(t)
327 tests := []struct {
328 name string
329 event session.Event
330 op string
331 want string
332 }{
333 {
334 name: "accepted submission without visible message",
335 event: func() session.Event {
336 body, _ := json.Marshal(session.SubmissionReceipt{SessionID: "accepted", SubmissionID: "send", Fingerprint: "fingerprint", TurnID: "turn"})
337 return session.Event{Kind: "submission/accepted", Optional: true, Payload: body}
338 }(),
339 op: "accepted", want: "has_content",
340 },
341 {name: "explicit model", event: session.Event{Kind: "session/config", Payload: json.RawMessage(`{"modelRef":"provider/model"}`)}, op: "session-model:explicit", want: "has_content"},
342 {name: "unknown optional event", event: session.Event{Kind: "future/optional", Optional: true, Payload: json.RawMessage(`{}`)}, op: "future", want: "unknown"},
343 }
344 for index, test := range tests {
345 t.Run(test.name, func(t *testing.T) {
346 id := "evidence-" + string(rune('a'+index))
347 ref, _ := createLegacyCleanupSession(t, app, workspaceRoot, id, false)
348 binding, err := app.desktopSessionService("").Open(t.Context(), ref)
349 if err != nil {
350 t.Fatal(err)
351 }
352 runtime := binding.Runtime()
353 if test.event.Kind == "submission/accepted" {
354 body, _ := json.Marshal(session.SubmissionReceipt{SessionID: id, SubmissionID: "send", Fingerprint: "fingerprint", TurnID: "turn", MessageID: "message"})
355 test.event.Payload = body
356 }
357 if _, err := runtime.Session().Append(t.Context(), session.Batch{OperationID: test.op, TurnID: "turn", Events: []session.Event{test.event}}); err != nil {
358 t.Fatal(err)
359 }
360 if _, err := runtime.Session().Flush(t.Context()); err != nil {
361 t.Fatal(err)
362 }
363 info, err := app.desktopSessionService("").Query().Stat(t.Context(), ref)
364 if err != nil {
365 t.Fatal(err)
366 }
367 if err := binding.Release(t.Context()); err != nil {
368 t.Fatal(err)
369 }
370 if got, _ := canonicalSessionDurableEvidence(t.Context(), info); got != test.want {
371 t.Fatalf("classification = %q, want %q", got, test.want)
372 }
373 })
374 }
375 }
376
377 func TestLegacyCleanupCanonicalPinnedContextIsContent(t *testing.T) {
378 app, workspaceRoot := newLegacyCleanupTestApp(t)
379 ref, _ := createLegacyCleanupSession(t, app, workspaceRoot, "pinned-context", false)
380 info, err := app.desktopSessionService("").Query().Stat(t.Context(), ref)
381 if err != nil {
382 t.Fatal(err)
383 }
384 if err := savePinnedContextState(info.Path, []string{"README.md"}); err != nil {
385 t.Fatal(err)
386 }
387 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
388 t.Fatal(err)
389 }
390 item := cleanupCandidate(t, app, "session:"+ref.SessionID)
391 app.processLegacyCleanupSession(item)
392 if got := cleanupCandidate(t, app, item.ID); got.Phase != "has_content" || got.Reason != "pinned_context" {
393 t.Fatalf("candidate = %+v", got)
394 }
395 state, err := app.workspaceRegistry().Load(t.Context())
396 if err != nil {
397 t.Fatal(err)
398 }
399 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Active {
400 t.Fatal("canonical session with pinned context was archived")
401 }
402 }
403
404 func writeLegacyCleanupSource(t *testing.T, path string) string {
405 t.Helper()
406 legacy := agent.NewSession("system only")
407 if err := legacy.Save(path); err != nil {
408 t.Fatal(err)
409 }
410 if err := agent.SaveBranchMeta(path, agent.BranchMeta{CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now(), Scope: "global", TopicID: "legacy-empty-topic", TopicTitle: defaultTopicTitle}); err != nil {
411 t.Fatal(err)
412 }
413 fingerprint, err := legacyCleanupSourceFingerprint(path)
414 if err != nil {
415 t.Fatal(err)
416 }
417 return fingerprint
418 }
419
420 func TestLegacyCleanupSourceChecksCompleteArtifacts(t *testing.T) {
421 dir := t.TempDir()
422 t.Run("system only", func(t *testing.T) {
423 path := filepath.Join(dir, "system-only.jsonl")
424 fingerprint := writeLegacyCleanupSource(t, path)
425 if got, reason, _ := classifyLegacyCleanupSource(path, "", fingerprint); got != "empty" {
426 t.Fatalf("classification = %q (%s)", got, reason)
427 }
428 })
429 t.Run("unreadable event log", func(t *testing.T) {
430 path := filepath.Join(dir, "event-log.jsonl")
431 writeLegacyCleanupSource(t, path)
432 if err := os.WriteFile(store.SessionEventLog(path), []byte("durable execution\n"), 0o600); err != nil {
433 t.Fatal(err)
434 }
435 fingerprint, err := legacyCleanupSourceFingerprint(path)
436 if err != nil {
437 t.Fatal(err)
438 }
439 if got, _, _ := classifyLegacyCleanupSource(path, "", fingerprint); got != "unknown" {
440 t.Fatalf("classification = %q", got)
441 }
442 })
443 t.Run("unclassified context", func(t *testing.T) {
444 path := filepath.Join(dir, "context.jsonl")
445 writeLegacyCleanupSource(t, path)
446 if err := os.WriteFile(store.SessionContext(path), []byte(`{"future":true}`), 0o600); err != nil {
447 t.Fatal(err)
448 }
449 fingerprint, err := legacyCleanupSourceFingerprint(path)
450 if err != nil {
451 t.Fatal(err)
452 }
453 if got, _, _ := classifyLegacyCleanupSource(path, "", fingerprint); got != "unknown" {
454 t.Fatalf("classification = %q", got)
455 }
456 })
457 t.Run("empty derived checkpoints", func(t *testing.T) {
458 path := filepath.Join(dir, "empty-checkpoints.jsonl")
459 writeLegacyCleanupSource(t, path)
460 if err := os.WriteFile(store.SessionTranscriptProjection(path), []byte(`{"version":1,"identity":{},"records":[],"runtime":{"pendingEvents":[]},"activeAttempts":[]}`), 0o600); err != nil {
461 t.Fatal(err)
462 }
463 if err := os.WriteFile(store.SessionContext(path), []byte(`{"schema_version":4,"projection":{"messages":[]}}`), 0o600); err != nil {
464 t.Fatal(err)
465 }
466 fingerprint, err := legacyCleanupSourceFingerprint(path)
467 if err != nil {
468 t.Fatal(err)
469 }
470 if got, reason, _ := classifyLegacyCleanupSource(path, "", fingerprint); got != "empty" {
471 t.Fatalf("classification = %q (%s)", got, reason)
472 }
473 })
474 }
475
476 func TestLegacyCleanupMigratedSourceArchivesOnlyMappedSession(t *testing.T) {
477 app, _ := newLegacyCleanupTestApp(t)
478 path := filepath.Join(config.SessionDir(), "legacy-empty-source.jsonl")
479 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
480 t.Fatal(err)
481 }
482 writeLegacyCleanupSource(t, path)
483 if err := ensureTopicIndexed("global", "", "legacy-empty-topic", defaultTopicTitle, topicTitleSourceAuto); err != nil {
484 t.Fatal(err)
485 }
486 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
487 if err != nil {
488 t.Fatal(err)
489 }
490 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
491 t.Fatal(err)
492 }
493 cleanupState, err := app.legacyCleanup.Load(t.Context())
494 if err != nil {
495 t.Fatal(err)
496 }
497 var item legacycleanup.Candidate
498 for _, candidate := range cleanupState.Items {
499 if candidate.Kind == "legacy" && sameDesktopPath(candidate.SourcePath, path) {
500 item = candidate
501 break
502 }
503 }
504 if item.ID == "" {
505 t.Fatalf("legacy candidate missing from %+v", cleanupState.Items)
506 }
507 if err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{root: filepath.Dir(path), scope: "global", headID: item.SourceHeadID}, workspaceID); err != nil {
508 t.Fatal(err)
509 }
510 state, err := app.workspaceRegistry().Load(t.Context())
511 if err != nil {
512 t.Fatal(err)
513 }
514 mapping := state.SourceMappings[desktopSourceKey(path, item.SourceHeadID)]
515 if mapping.SessionID == "" {
516 t.Fatalf("missing source mapping: %+v", state.SourceMappings)
517 }
518 bound := cleanupCandidate(t, app, item.ID)
519 if bound.SessionID != mapping.SessionID || bound.EventSequence == 0 {
520 t.Fatalf("migration did not freeze canonical binding: %+v", bound)
521 }
522 app.processLegacyCleanupSource(bound)
523 state, err = app.workspaceRegistry().Load(t.Context())
524 if err != nil {
525 t.Fatal(err)
526 }
527 if state.SessionStates[mapping.SessionID].Lifecycle != workspacestate.Archived {
528 t.Fatalf("mapped session lifecycle = %q", state.SessionStates[mapping.SessionID].Lifecycle)
529 }
530 if got := cleanupCandidate(t, app, item.ID); got.SessionID != mapping.SessionID || got.Phase != "archived" {
531 t.Fatalf("cleanup candidate = %+v", got)
532 }
533 if _, err := os.Stat(path); err != nil {
534 t.Fatalf("legacy source was removed: %v", err)
535 }
536 }
537
538 func TestLegacyCleanupMigratedSourceRejectsSameTitleRewriteAfterBinding(t *testing.T) {
539 app, _ := newLegacyCleanupTestApp(t)
540 path := filepath.Join(config.SessionDir(), "legacy-title-rewrite.jsonl")
541 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
542 t.Fatal(err)
543 }
544 writeLegacyCleanupSource(t, path)
545 if err := ensureTopicIndexed("global", "", "legacy-empty-topic", defaultTopicTitle, topicTitleSourceAuto); err != nil {
546 t.Fatal(err)
547 }
548 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
549 if err != nil {
550 t.Fatal(err)
551 }
552 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
553 t.Fatal(err)
554 }
555 cleanupState, err := app.legacyCleanup.Load(t.Context())
556 if err != nil {
557 t.Fatal(err)
558 }
559 var item legacycleanup.Candidate
560 for _, candidate := range cleanupState.Items {
561 if candidate.Kind == "legacy" && sameDesktopPath(candidate.SourcePath, path) {
562 item = candidate
563 break
564 }
565 }
566 if item.ID == "" {
567 t.Fatal("legacy candidate missing")
568 }
569 if err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{root: filepath.Dir(path), scope: "global", headID: item.SourceHeadID}, workspaceID); err != nil {
570 t.Fatal(err)
571 }
572 bound := cleanupCandidate(t, app, item.ID)
573 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: bound.SessionID}
574 if err := app.desktopSessionService("").SetTitle(t.Context(), ref, defaultTopicTitle); err != nil {
575 t.Fatal(err)
576 }
577 app.processLegacyCleanupSource(bound)
578 if got := cleanupCandidate(t, app, item.ID); got.Phase != "protected" || got.Reason != "title_or_content_changed" {
579 t.Fatalf("same-title rewrite candidate = %+v", got)
580 }
581 state, err := app.workspaceRegistry().Load(t.Context())
582 if err != nil {
583 t.Fatal(err)
584 }
585 if state.SessionStates[bound.SessionID].Lifecycle != workspacestate.Active {
586 t.Fatal("same-title rewrite session was archived")
587 }
588 }
589
590 func TestLegacyCleanupAlreadyMappedSessionKeepsLegacySidecarContent(t *testing.T) {
591 app, _ := newLegacyCleanupTestApp(t)
592 path := filepath.Join(config.SessionDir(), "legacy-mapped-sidecar.jsonl")
593 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
594 t.Fatal(err)
595 }
596 writeLegacyCleanupSource(t, path)
597 if err := ensureTopicIndexed("global", "", "legacy-empty-topic", defaultTopicTitle, topicTitleSourceAuto); err != nil {
598 t.Fatal(err)
599 }
600 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
601 if err != nil {
602 t.Fatal(err)
603 }
604 heads, err := session.LegacyMigrationHeads(t.Context(), path)
605 if err != nil || len(heads) != 1 {
606 t.Fatalf("legacy heads = %+v, %v", heads, err)
607 }
608 headID := heads[0].ID
609 if err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{root: filepath.Dir(path), scope: "global", headID: headID}, workspaceID); err != nil {
610 t.Fatal(err)
611 }
612 if err := os.WriteFile(store.SessionRecoveryState(path), []byte(`{"phase":"recoverable"}`), 0o600); err != nil {
613 t.Fatal(err)
614 }
615 state, err := app.workspaceRegistry().Load(t.Context())
616 if err != nil {
617 t.Fatal(err)
618 }
619 mapping := state.SourceMappings[desktopSourceKey(path, headID)]
620 if mapping.SessionID == "" {
621 t.Fatalf("missing mapping: %+v", state.SourceMappings)
622 }
623 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
624 t.Fatal(err)
625 }
626 item := cleanupCandidate(t, app, "session:"+mapping.SessionID)
627 if len(item.Sources) != 1 || item.Sources[0].HeadID != headID {
628 t.Fatalf("frozen sources = %+v", item.Sources)
629 }
630 app.processLegacyCleanupSession(item)
631 if got := cleanupCandidate(t, app, item.ID); got.Phase != "has_content" || got.Reason != "recovery_state" {
632 t.Fatalf("candidate = %+v", got)
633 }
634 state, err = app.workspaceRegistry().Load(t.Context())
635 if err != nil {
636 t.Fatal(err)
637 }
638 if state.SessionStates[mapping.SessionID].Lifecycle != workspacestate.Active {
639 t.Fatal("mapped session with legacy recovery state was archived")
640 }
641 }
642
643 func TestLegacyCleanupTopicFinalFenceRejectsConcurrentRename(t *testing.T) {
644 app, _ := newLegacyCleanupTestApp(t)
645 if _, err := app.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil {
646 t.Fatal(err)
647 }
648 topic, err := app.CreateTopic("global", "", "")
649 if err != nil {
650 t.Fatal(err)
651 }
652 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
653 t.Fatal(err)
654 }
655 item := cleanupCandidate(t, app, "topic:"+topic.ID)
656 app.legacyCleanupWorker.beforeArchive = func() { app.protectLegacyCleanupTopicMutation(topic.ID) }
657 app.processLegacyCleanupTopic(item)
658 if !topicIndexedInRegistry("global", "", topic.ID) {
659 t.Fatal("topic was removed after a concurrent title mutation")
660 }
661 if got := cleanupCandidate(t, app, item.ID); got.Phase != "protected" || got.Reason != "title_mutated" {
662 t.Fatalf("candidate = %+v", got)
663 }
664 }
665
666 func TestLegacyCleanupTopicFinalFenceRejectsSameTitleMetadataRewrite(t *testing.T) {
667 app, _ := newLegacyCleanupTestApp(t)
668 if _, err := app.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil {
669 t.Fatal(err)
670 }
671 topic, err := app.CreateTopic("global", "", "")
672 if err != nil {
673 t.Fatal(err)
674 }
675 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
676 t.Fatal(err)
677 }
678 item := cleanupCandidate(t, app, "topic:"+topic.ID)
679 if item.Topic == nil || item.Topic.RowRevision == 0 {
680 t.Fatalf("candidate did not freeze topic row revision: %+v", item)
681 }
682 app.legacyCleanupWorker.beforeArchive = func() {
683 if rewriteErr := setTopicTitleWithSource("", topic.ID, defaultTopicTitle, topicTitleSourceManual); rewriteErr != nil {
684 t.Errorf("rewrite title: %v", rewriteErr)
685 }
686 }
687 app.processLegacyCleanupTopic(item)
688 if !topicIndexedInRegistry("global", "", topic.ID) {
689 t.Fatal("topic was removed after a same-title metadata rewrite")
690 }
691 got := cleanupCandidate(t, app, item.ID)
692 if got.Phase != "protected" || got.Reason != "title_mutated" {
693 t.Fatalf("candidate = %+v", got)
694 }
695 }
696
697 func TestLegacyCleanupTopicKeepsPlaceholderWhenWorkspaceIsUnavailable(t *testing.T) {
698 app, _ := newLegacyCleanupTestApp(t)
699 workspaceRoot := filepath.Join(t.TempDir(), "offline-project")
700 if err := os.MkdirAll(workspaceRoot, 0o755); err != nil {
701 t.Fatal(err)
702 }
703 if _, err := app.ensureDesktopWorkspace(t.Context(), "project", workspaceRoot); err != nil {
704 t.Fatal(err)
705 }
706 topic, err := app.CreateTopic("project", workspaceRoot, "")
707 if err != nil {
708 t.Fatal(err)
709 }
710 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
711 t.Fatal(err)
712 }
713 if err := os.RemoveAll(workspaceRoot); err != nil {
714 t.Fatal(err)
715 }
716 item := cleanupCandidate(t, app, "topic:"+topic.ID)
717 app.processLegacyCleanupTopic(item)
718 got := cleanupCandidate(t, app, item.ID)
719 if got.Phase != "unknown" || got.Reason != "workspace_unavailable" {
720 t.Fatalf("unavailable workspace candidate = %+v", got)
721 }
722 if !topicIndexedInRegistry("project", workspaceRoot, topic.ID) {
723 t.Fatal("placeholder from an unavailable workspace was removed")
724 }
725 }
726
727 func TestLegacyCleanupTopicRestoreMergesOriginalOrganization(t *testing.T) {
728 app, _ := newLegacyCleanupTestApp(t)
729 if _, err := app.ensureDesktopWorkspace(t.Context(), "global", ""); err != nil {
730 t.Fatal(err)
731 }
732 before, err := app.CreateTopic("global", "", "Before")
733 if err != nil {
734 t.Fatal(err)
735 }
736 target, err := app.CreateTopic("global", "", "")
737 if err != nil {
738 t.Fatal(err)
739 }
740 after, err := app.CreateTopic("global", "", "After")
741 if err != nil {
742 t.Fatal(err)
743 }
744 if err := updateProjectsFile(func(file *desktopProjectFile) (bool, error) {
745 file.GlobalTopics = []string{before.ID, target.ID, after.ID}
746 file.GlobalPinnedTopics = []string{target.ID}
747 file.GlobalGroups = []desktopGroup{{ID: "group", Title: "Group", TopicIDs: []string{before.ID, target.ID}}}
748 return true, nil
749 }); err != nil {
750 t.Fatal(err)
751 }
752 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
753 t.Fatal(err)
754 }
755 item := cleanupCandidate(t, app, "topic:"+target.ID)
756 app.processLegacyCleanupTopic(item)
757 concurrent, err := app.CreateTopic("global", "", "Concurrent")
758 if err != nil {
759 t.Fatal(err)
760 }
761 if err := app.restoreLegacyCleanupTopic(item.ID, item.WorkspaceID); err != nil {
762 t.Fatal(err)
763 }
764 file := loadProjectsFile()
765 if slices.Index(file.GlobalTopics, target.ID) != item.Topic.Order || !containsDesktopString(file.GlobalPinnedTopics, target.ID) {
766 t.Fatalf("restored organization = %+v", file)
767 }
768 if !containsDesktopString(file.GlobalTopics, concurrent.ID) {
769 t.Fatal("restore overwrote a concurrently added topic")
770 }
771 if len(file.GlobalGroups) != 1 || slices.Index(file.GlobalGroups[0].TopicIDs, target.ID) != item.Topic.GroupOrder {
772 t.Fatalf("restored group = %+v", file.GlobalGroups)
773 }
774 }
775
775 lines GO