| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "path/filepath" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/desktop/internal/legacycleanup" |
| 10 | "reasonix/desktop/internal/workspacestate" |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/session" |
| 13 | ) |
| 14 | |
| 15 | type legacyCleanupBatchBuilder struct { |
| 16 | app *App |
| 17 | state workspacestate.State |
| 18 | items map[string]legacycleanup.Candidate |
| 19 | mappedSources map[string]bool |
| 20 | seenLegacy map[string]bool |
| 21 | } |
| 22 | |
| 23 | func newLegacyCleanupBatchBuilder(app *App, state workspacestate.State) *legacyCleanupBatchBuilder { |
| 24 | mapped := make(map[string]bool, len(state.SourceMappings)) |
| 25 | for _, mapping := range state.SourceMappings { |
| 26 | mapped[sessionRuntimeKey(mapping.Path)+"\x00"+mapping.HeadID] = true |
| 27 | } |
| 28 | return &legacyCleanupBatchBuilder{ |
| 29 | app: app, state: state, items: map[string]legacycleanup.Candidate{}, |
| 30 | mappedSources: mapped, seenLegacy: map[string]bool{}, |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | func (b *legacyCleanupBatchBuilder) registerCanonicalSessions(ctx context.Context) { |
| 35 | query := b.app.desktopSessionService("").Query() |
| 36 | for workspaceID, workspace := range b.state.Workspaces { |
| 37 | for _, sessionID := range workspace.SessionIDs { |
| 38 | status := b.state.SessionStates[sessionID] |
| 39 | if status.Lifecycle != "" && status.Lifecycle != workspacestate.Active { |
| 40 | continue |
| 41 | } |
| 42 | info, statErr := query.Stat(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID}) |
| 43 | title := b.state.Presentation[sessionID].Title |
| 44 | if statErr == nil && (info.TitleSequence > 0 || strings.TrimSpace(info.Title) != "") { |
| 45 | title = info.Title |
| 46 | } |
| 47 | if statErr == nil && !isDefaultTopicTitle(title) { |
| 48 | continue |
| 49 | } |
| 50 | candidate := legacycleanup.Candidate{ |
| 51 | ID: "session:" + sessionID, Kind: "session", WorkspaceID: workspaceID, SessionID: sessionID, |
| 52 | Title: title, TitleSequence: info.TitleSequence, EventSequence: info.EventSequence, |
| 53 | LifecycleGeneration: status.Generation, OperationID: "legacy-empty-" + sessionID, Phase: "registered", |
| 54 | } |
| 55 | candidate.Sources = b.sourcesForSession(sessionID) |
| 56 | b.items[candidate.ID] = candidate |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func (b *legacyCleanupBatchBuilder) sourcesForSession(sessionID string) []legacycleanup.SourceSnapshot { |
| 62 | var sources []legacycleanup.SourceSnapshot |
| 63 | for _, mapping := range b.state.SourceMappings { |
| 64 | if mapping.SessionID != sessionID || mapping.Format != "legacy" { |
| 65 | continue |
| 66 | } |
| 67 | fingerprint, _ := legacyCleanupSourceFingerprint(mapping.Path) |
| 68 | sources = append(sources, legacycleanup.SourceSnapshot{Path: mapping.Path, HeadID: mapping.HeadID, Fingerprint: fingerprint}) |
| 69 | } |
| 70 | slices.SortFunc(sources, func(left, right legacycleanup.SourceSnapshot) int { |
| 71 | return strings.Compare(sessionRuntimeKey(left.Path)+"\x00"+left.HeadID, sessionRuntimeKey(right.Path)+"\x00"+right.HeadID) |
| 72 | }) |
| 73 | return sources |
| 74 | } |
| 75 | |
| 76 | func (b *legacyCleanupBatchBuilder) registerLegacy(scope, root, workspaceID, topicID, title string) bool { |
| 77 | registered := false |
| 78 | for _, dir := range b.app.knownSessionDirs() { |
| 79 | for _, match := range topicSessionMatches(dir, topicID) { |
| 80 | if !topicSessionMatchMatchesTarget(match, scope, root) { |
| 81 | continue |
| 82 | } |
| 83 | path := filepath.Clean(match.path) |
| 84 | heads, err := session.LegacyMigrationHeads(b.app.bootContext(), path) |
| 85 | if err != nil || len(heads) == 0 { |
| 86 | heads = []agent.SessionHead{{}} |
| 87 | } |
| 88 | fingerprint, _ := legacyCleanupSourceFingerprint(path) |
| 89 | for _, head := range heads { |
| 90 | if head.Retired || !b.registerLegacyHead(workspaceID, topicID, title, path, fingerprint, head.ID) { |
| 91 | continue |
| 92 | } |
| 93 | registered = true |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | return registered |
| 98 | } |
| 99 | |
| 100 | func (b *legacyCleanupBatchBuilder) registerLegacyHead(workspaceID, topicID, title, path, fingerprint, headID string) bool { |
| 101 | identity := sessionRuntimeKey(path) + "\x00" + headID |
| 102 | if b.seenLegacy[identity] || b.mappedSources[identity] { |
| 103 | return false |
| 104 | } |
| 105 | b.seenLegacy[identity] = true |
| 106 | key := desktopSourceKey(path, headID) |
| 107 | id := "legacy:" + key |
| 108 | b.items[id] = legacycleanup.Candidate{ |
| 109 | ID: id, Kind: "legacy", WorkspaceID: workspaceID, TopicID: topicID, |
| 110 | SourcePath: path, SourceHeadID: headID, SourceFingerprint: fingerprint, |
| 111 | Title: title, OperationID: "legacy-empty-source-" + key, Phase: "registered", |
| 112 | } |
| 113 | return true |
| 114 | } |
| 115 | |
| 116 | func (b *legacyCleanupBatchBuilder) registerTopics(scope, root, workspaceID string, ids, pinned []string, groups []desktopGroup) { |
| 117 | topicRoot := topicTitleRoot(scope, root) |
| 118 | titles, sources, created, rowRevisions := legacyCleanupTopicMetadata(topicRoot) |
| 119 | for order, topicID := range ids { |
| 120 | title := titles[topicID] |
| 121 | if !isDefaultTopicTitle(title) { |
| 122 | continue |
| 123 | } |
| 124 | legacyRegistered := b.registerLegacy(scope, root, workspaceID, topicID, title) |
| 125 | if b.topicHasCanonicalSession(topicID) || legacyRegistered { |
| 126 | continue |
| 127 | } |
| 128 | snapshot := legacyCleanupTopicSnapshot(scope, root, topicID, sources[topicID], created[topicID], rowRevisions[topicID], order, pinned, groups) |
| 129 | id := "topic:" + topicID |
| 130 | b.items[id] = legacycleanup.Candidate{ID: id, Kind: "topic", WorkspaceID: workspaceID, TopicID: topicID, Title: title, OperationID: "legacy-empty-topic-" + topicID, Phase: "registered", Topic: snapshot} |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func legacyCleanupTopicMetadata(root string) (map[string]string, map[string]string, map[string]int64, map[string]int64) { |
| 135 | titles, sources := map[string]string{}, map[string]string{} |
| 136 | created, revisions := map[string]int64{}, map[string]int64{} |
| 137 | snapshot, err := desktopTopicState.snapshot(root) |
| 138 | if err != nil { |
| 139 | return loadTopicTitles(root), loadTopicTitleSources(root), loadTopicCreatedAts(root), revisions |
| 140 | } |
| 141 | for topicID, record := range snapshot.Records { |
| 142 | titles[topicID] = agent.UserPreviewText(record.Title) |
| 143 | sources[topicID] = record.TitleSource |
| 144 | created[topicID] = record.CreatedAtMS |
| 145 | revisions[topicID] = record.RowRevision |
| 146 | } |
| 147 | return titles, sources, created, revisions |
| 148 | } |
| 149 | |
| 150 | func (b *legacyCleanupBatchBuilder) topicHasCanonicalSession(topicID string) bool { |
| 151 | for _, presentation := range b.state.Presentation { |
| 152 | if presentation.TopicID == topicID { |
| 153 | return true |
| 154 | } |
| 155 | } |
| 156 | return false |
| 157 | } |
| 158 | |
| 159 | func (b *legacyCleanupBatchBuilder) workspaceIDForRoot(root string) string { |
| 160 | for id, workspace := range b.state.Workspaces { |
| 161 | if sameDesktopPath(workspace.Root, root) { |
| 162 | return id |
| 163 | } |
| 164 | } |
| 165 | return "" |
| 166 | } |
| 167 | |
| 168 | func legacyCleanupTopicSnapshot(scope, root, topicID, source string, createdAt, rowRevision int64, order int, pinned []string, groups []desktopGroup) *legacycleanup.TopicSnapshot { |
| 169 | snapshot := &legacycleanup.TopicSnapshot{ |
| 170 | Scope: scope, WorkspaceRoot: root, TitleSource: source, CreatedAt: createdAt, |
| 171 | RowRevision: rowRevision, Order: order, Pinned: slices.Contains(pinned, topicID), GroupOrder: -1, |
| 172 | } |
| 173 | for _, group := range groups { |
| 174 | if index := slices.Index(group.TopicIDs, topicID); index >= 0 { |
| 175 | snapshot.GroupID, snapshot.GroupOrder = group.ID, index |
| 176 | break |
| 177 | } |
| 178 | } |
| 179 | return snapshot |
| 180 | } |
| 181 |