| 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/workspacestate" |
| 12 | "reasonix/internal/agent" |
| 13 | "reasonix/internal/session" |
| 14 | ) |
| 15 | |
| 16 | // Earlier purge writers dropped the deleted session's topic from every receipt, |
| 17 | // so its retained legacy transcript lists again as a live topic. Clearing that |
| 18 | // row must survive a restart (#10735). |
| 19 | func TestAlreadyRemovedSourceStaysRemovedAfterRestart(t *testing.T) { |
| 20 | isolateDesktopUserDirs(t) |
| 21 | path, _, head := migrationSingleDAGFixture(t) |
| 22 | const topicID = "topic_20260903-142302_04afb4407ec2fa2a" |
| 23 | if err := agent.UpdateBranchMeta(path, false, func(m *agent.BranchMeta) error { |
| 24 | m.Scope, m.TopicID, m.TopicTitle = "global", topicID, "Ghost" |
| 25 | return nil |
| 26 | }); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | if err := saveProjectsFile(desktopProjectFile{GlobalTopics: []string{topicID}}); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | app := NewApp() |
| 33 | app.ctx = t.Context() |
| 34 | pinDesktopSessionRoot(t, app) |
| 35 | root := app.desktopSessions.root |
| 36 | installNoopRuntimeEvents(app) |
| 37 | t.Cleanup(app.closeSessionServices) |
| 38 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: state.SourceMappings[desktopSourceKey(path, head)].SessionID} |
| 46 | if err := app.ArchiveCanonicalSession(ref); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if err := app.PurgeCanonicalSession(ref); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | app.closeSessionServices() |
| 53 | dropTopicEvidence(t, app.workspaceRegistry().Path(), ref.SessionID) |
| 54 | |
| 55 | restart := func() *App { |
| 56 | next := NewApp() |
| 57 | next.ctx = t.Context() |
| 58 | next.desktopSessions.root = root |
| 59 | installNoopRuntimeEvents(next) |
| 60 | t.Cleanup(next.closeSessionServices) |
| 61 | installSessionCatalogForTest(t, next, filepath.Dir(path), "global", "") |
| 62 | t.Cleanup(func() { next.stopSessionCatalog(time.Second) }) |
| 63 | return next |
| 64 | } |
| 65 | list := func(a *App) []ProjectNode { |
| 66 | t.Helper() |
| 67 | page, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50}) |
| 68 | if err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | a.ReleaseReadSnapshot(page.SnapshotID) |
| 72 | return page.Items |
| 73 | } |
| 74 | app = restart() |
| 75 | rows := list(app) |
| 76 | if len(rows) != 1 || rows[0].TopicID != topicID { |
| 77 | t.Fatalf("fixture must reproduce the residual row: %+v", rows) |
| 78 | } |
| 79 | result, err := app.ArchiveSessionTarget(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: head}}) |
| 80 | if err != nil || !result.Committed || result.Outcome != "already_removed" { |
| 81 | t.Fatalf("clear residual row = %+v, %v", result, err) |
| 82 | } |
| 83 | app.stopSessionCatalog(time.Second) |
| 84 | app.closeSessionServices() |
| 85 | app = restart() |
| 86 | if rows := list(app); len(rows) != 0 { |
| 87 | t.Fatalf("cleared residual row returned after restart: %+v", rows) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func dropTopicEvidence(t *testing.T, statePath, sessionID string) { |
| 92 | t.Helper() |
| 93 | body, err := os.ReadFile(statePath) |
| 94 | if err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | var state map[string]any |
| 98 | if err := json.Unmarshal(body, &state); err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | ops, _ := state["pendingOperations"].(map[string]any) |
| 102 | for _, raw := range ops { |
| 103 | op, _ := raw.(map[string]any) |
| 104 | ids, _ := op["sessionIds"].([]any) |
| 105 | if slices.Contains(ids, any(sessionID)) { |
| 106 | delete(op, "presentation") |
| 107 | } |
| 108 | } |
| 109 | if body, err = json.Marshal(state); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | if err := os.WriteFile(statePath, body, 0o600); err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | if _, err := workspacestate.NewStore(statePath).Load(t.Context()); err != nil { |
| 116 | t.Fatalf("rewritten state: %v", err) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestRemovedSourceTopicStaysWhileAnotherTranscriptCarriesIt(t *testing.T) { |
| 121 | dir := t.TempDir() |
| 122 | const topicID = "topic_shared" |
| 123 | paths := []string{filepath.Join(dir, "removed.jsonl"), filepath.Join(dir, "sibling.jsonl")} |
| 124 | for _, path := range paths { |
| 125 | if err := agent.NewSession("system").Save(path); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | if err := agent.UpdateBranchMeta(path, false, func(m *agent.BranchMeta) error { m.TopicID = topicID; return nil }); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | } |
| 132 | state := workspacestate.State{ |
| 133 | SessionStates: map[string]workspacestate.SessionState{"gone": {Lifecycle: workspacestate.Deleted}}, |
| 134 | SourceMappings: map[string]workspacestate.SourceMapping{"removed": {SourceKey: "removed", Path: paths[0], SessionID: "gone"}}, |
| 135 | } |
| 136 | if shared, err := legacyTopicSharedBeyond(state, paths[0], topicID); err != nil || !shared { |
| 137 | t.Fatalf("unadopted sibling transcript = %v, %v; want shared", shared, err) |
| 138 | } |
| 139 | state.SourceMappings["sibling"] = workspacestate.SourceMapping{SourceKey: "sibling", Path: paths[1], SessionID: "gone"} |
| 140 | if shared, err := legacyTopicSharedBeyond(state, paths[0], topicID); err != nil || shared { |
| 141 | t.Fatalf("sibling adopted by a deleted session = %v, %v; want unshared", shared, err) |
| 142 | } |
| 143 | state.Presentation = map[string]workspacestate.Presentation{"live": {TopicID: topicID}} |
| 144 | state.SessionStates["live"] = workspacestate.SessionState{Lifecycle: workspacestate.Archived} |
| 145 | if !topicHasSurvivingOwner(state, topicID) { |
| 146 | t.Fatal("archived session no longer owns its topic") |
| 147 | } |
| 148 | } |
| 149 |