返回 DeepSeek-Reasonix
legacy_empty_session_cleanup_topics.go
根目录 / desktop / legacy_empty_session_cleanup_topics.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11 "time"
12
13 "reasonix/desktop/internal/legacycleanup"
14 "reasonix/desktop/internal/workspacestate"
15 "reasonix/internal/agent"
16 "reasonix/internal/store"
17 "reasonix/internal/topicstate"
18 )
19
20 func (a *App) processLegacyCleanupTopic(item legacycleanup.Candidate) {
21 if item.Topic == nil || !isDefaultTopicTitle(item.Title) {
22 a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) {
23 next.Phase, next.Classification, next.Reason = "protected", "protected", "invalid_topic_snapshot"
24 })
25 return
26 }
27 if a.reconcileLegacyCleanupTopicArchive(item) {
28 return
29 }
30 releaseRuntime, ok := a.tryLockRuntimeMutation("legacy empty topic cleanup")
31 if !ok {
32 a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) {
33 next.Phase, next.Classification, next.Reason = "busy", "busy", "runtime_mutation"
34 })
35 return
36 }
37 defer releaseRuntime()
38 a.topicTitleMutationMu.Lock()
39 defer a.topicTitleMutationMu.Unlock()
40 topicIndexMu.Lock()
41 defer topicIndexMu.Unlock()
42 classification, reason := a.classifyLegacyCleanupTopicLocked(item)
43 if classification != "empty" {
44 a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) {
45 next.Phase, next.Classification, next.Reason = classification, classification, reason
46 })
47 return
48 }
49 if a.legacyCleanupWorker.beforeArchive != nil {
50 a.legacyCleanupWorker.beforeArchive()
51 }
52 a.archiveLegacyCleanupTopic(item)
53 }
54
55 func (a *App) archiveLegacyCleanupTopic(item legacycleanup.Candidate) {
56 classification, reason := "unknown", "topic_archive_failed"
57 archived := false
58 _, err := a.legacyCleanup.Transition(a.bootContext(), func(state *legacycleanup.State) error {
59 current, ok := state.Items[item.ID]
60 if !ok || current.Restored || current.Kind != "topic" || current.Topic == nil ||
61 current.Phase == "archived" || current.Phase == "protected" || current.Phase == "has_content" {
62 return errLegacyCleanupStateChanged
63 }
64 current.Phase, current.Classification, current.Reason = "archive_pending", "empty", ""
65 state.Items[item.ID] = current
66 item = current
67 return nil
68 }, func() error {
69 return a.workspaceRegistry().WithStateLocked(a.bootContext(), func(workspaces workspacestate.State) error {
70 desktopProjectsFileMu.Lock()
71 defer desktopProjectsFileMu.Unlock()
72 releaseProjects, lockErr := acquireDesktopProjectsFileLock()
73 if lockErr != nil {
74 return lockErr
75 }
76 defer releaseProjects()
77 projects := loadProjectsFile()
78 root := topicTitleRoot(item.Topic.Scope, item.Topic.WorkspaceRoot)
79 return desktopTopicState.withExclusiveScope(root, func(ctx context.Context, topicStore *topicstate.Store) error {
80 snapshot, snapshotErr := topicStore.Snapshot(ctx)
81 if snapshotErr != nil {
82 return snapshotErr
83 }
84 classification, reason = a.classifyLegacyCleanupTopicSnapshotLocked(item, workspaces, projects, snapshot)
85 if classification != "empty" {
86 return nil
87 }
88 if removeErr := removeTopicFromProjectsFileCrossProcessLocked(item.TopicID); removeErr != nil {
89 return removeErr
90 }
91 if _, deleteErr := topicStore.Delete(ctx, item.TopicID); deleteErr != nil {
92 return deleteErr
93 }
94 archived = true
95 return nil
96 })
97 })
98 }, func(state *legacycleanup.State, effectErr error) error {
99 current := state.Items[item.ID]
100 if effectErr != nil {
101 current.Phase, current.Classification, current.Reason = "unknown", "unknown", "topic_archive_failed"
102 } else if archived {
103 current.Phase, current.Classification, current.Reason, current.ArchivedAt = "archived", "empty", "", time.Now().UTC().UnixMilli()
104 } else {
105 current.Phase, current.Classification, current.Reason = classification, classification, reason
106 }
107 state.Items[item.ID] = current
108 return nil
109 })
110 if err != nil && !errors.Is(err, errLegacyCleanupStateChanged) {
111 slog.Warn("desktop: legacy topic cleanup transition failed", "err", err)
112 }
113 }
114
115 func (a *App) markLegacyCleanupTopicArchivePending(id string) bool {
116 _, err := a.legacyCleanup.Update(a.bootContext(), func(state *legacycleanup.State) error {
117 item, ok := state.Items[id]
118 if !ok || item.Restored || item.Kind != "topic" || item.Phase == "archived" || item.Phase == "protected" || item.Phase == "has_content" {
119 return errLegacyCleanupStateChanged
120 }
121 item.Phase, item.Classification, item.Reason = "archive_pending", "empty", ""
122 state.Items[id] = item
123 return nil
124 })
125 if err != nil {
126 if !errors.Is(err, errLegacyCleanupStateChanged) {
127 slog.Warn("desktop: legacy cleanup archive marker failed", "err", err)
128 }
129 return false
130 }
131 return true
132 }
133
134 func (a *App) reconcileLegacyCleanupTopicArchive(item legacycleanup.Candidate) bool {
135 if item.Phase != "archive_pending" || item.Topic == nil {
136 return false
137 }
138 a.topicTitleMutationMu.Lock()
139 defer a.topicTitleMutationMu.Unlock()
140 topicIndexMu.Lock()
141 defer topicIndexMu.Unlock()
142 desktopProjectsFileMu.Lock()
143 defer desktopProjectsFileMu.Unlock()
144 releaseProjects, err := acquireDesktopProjectsFileLock()
145 if err != nil {
146 return false
147 }
148 defer releaseProjects()
149 file := loadProjectsFile()
150 if !containsDesktopString(file.DeletedTopics, item.TopicID) || topicIndexedInRegistry(item.Topic.Scope, item.Topic.WorkspaceRoot, item.TopicID) {
151 return false
152 }
153 a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) {
154 if next.Restored || next.Phase != "archive_pending" {
155 return
156 }
157 next.Phase, next.Classification, next.Reason = "archived", "empty", ""
158 if next.ArchivedAt == 0 {
159 next.ArchivedAt = time.Now().UTC().UnixMilli()
160 }
161 })
162 return true
163 }
164
165 // classifyLegacyCleanupTopicLocked requires runtime mutation admission,
166 // topicTitleMutationMu and topicIndexMu. It performs no mutation and never
167 // creates a Session or Controller.
168 func (a *App) classifyLegacyCleanupTopicLocked(item legacycleanup.Candidate) (string, string) {
169 cleanupState, err := a.legacyCleanup.Load(a.bootContext())
170 if err != nil {
171 return "unknown", "cleanup_state_unavailable"
172 }
173 current, ok := cleanupState.Items[item.ID]
174 if !ok || current.Restored || current.Phase == "archived" || current.Topic == nil {
175 return "protected", "cleanup_state_changed"
176 }
177 if current.Phase == "protected" || current.Phase == "has_content" {
178 classification := current.Classification
179 if classification == "" {
180 classification = current.Phase
181 }
182 return classification, current.Reason
183 }
184 state, err := a.workspaceRegistry().Load(a.bootContext())
185 if err != nil {
186 return "unknown", "workspace_unavailable"
187 }
188 projects := loadProjectsFile()
189 snapshot, err := desktopTopicState.snapshot(topicTitleRoot(item.Topic.Scope, item.Topic.WorkspaceRoot))
190 if err != nil {
191 return "unknown", "topic_metadata_unavailable"
192 }
193 return a.classifyLegacyCleanupTopicSnapshotLocked(current, state, projects, snapshot)
194 }
195
196 func (a *App) classifyLegacyCleanupTopicSnapshotLocked(item legacycleanup.Candidate, state workspacestate.State, projects desktopProjectFile, snapshot topicstate.Snapshot) (string, string) {
197 for id, presentation := range state.Presentation {
198 if presentation.TopicID == item.TopicID && state.SessionStates[id].Lifecycle != workspacestate.Deleted {
199 return "protected", "canonical_session_present"
200 }
201 }
202 if classification, reason := classifyLegacyCleanupTopicSources(item, a.knownSessionDirs()); classification != "empty" {
203 return classification, reason
204 }
205 a.mu.RLock()
206 for _, tab := range a.runtimeTabsLocked() {
207 if tab != nil && tab.TopicID == item.TopicID {
208 a.mu.RUnlock()
209 return "busy", "topic_open"
210 }
211 }
212 a.mu.RUnlock()
213 record, ok := snapshot.Records[item.TopicID]
214 if !ok || agent.UserPreviewText(record.Title) != item.Title || !topicIndexedInProjectsSnapshot(projects, item.Topic.Scope, item.Topic.WorkspaceRoot, item.TopicID) {
215 return "protected", "topic_changed"
216 }
217 if item.Topic.RowRevision == 0 {
218 return "unknown", "topic_version_unavailable"
219 }
220 if record.RowRevision != item.Topic.RowRevision {
221 return "protected", "title_mutated"
222 }
223 if !legacyCleanupTopicSnapshotMatchesProjects(item, projects) {
224 return "protected", "topic_organization_changed"
225 }
226 return "empty", ""
227 }
228
229 func classifyLegacyCleanupTopicSources(item legacycleanup.Candidate, knownDirs []string) (string, string) {
230 if item.Topic == nil {
231 return "protected", "invalid_topic_snapshot"
232 }
233 if item.Topic.Scope == "project" {
234 info, err := os.Stat(item.Topic.WorkspaceRoot)
235 if err != nil || !info.IsDir() {
236 return "unknown", "workspace_unavailable"
237 }
238 }
239 for _, dir := range uniqueStrings(knownDirs) {
240 entries, err := os.ReadDir(dir)
241 if os.IsNotExist(err) {
242 continue
243 }
244 if err != nil {
245 return "unknown", "legacy_session_index_unavailable"
246 }
247 for _, entry := range entries {
248 if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) {
249 continue
250 }
251 path := filepath.Join(dir, entry.Name())
252 meta, ok, err := agent.LoadBranchMeta(path)
253 if err != nil || !ok {
254 // Without readable metadata there is no reliable way to prove that
255 // this historical transcript belongs to a different Topic.
256 return "unknown", "legacy_session_metadata_unavailable"
257 }
258 match := topicSessionMatch{path: path, scope: meta.DefaultScope(), workspaceRoot: meta.WorkspaceRoot}
259 if strings.TrimSpace(meta.TopicID) != item.TopicID || !topicSessionMatchMatchesTarget(match, item.Topic.Scope, item.Topic.WorkspaceRoot) {
260 continue
261 }
262 if agent.IsCleanupPending(path) {
263 return "protected", "legacy_cleanup_pending"
264 }
265 return "protected", "legacy_session_appeared"
266 }
267 }
268 return "empty", ""
269 }
270
271 func topicIndexedInProjectsSnapshot(file desktopProjectFile, scope, workspaceRoot, topicID string) bool {
272 if scope == "global" {
273 return containsDesktopString(file.GlobalTopics, topicID)
274 }
275 index := projectIndexByRoot(file.Projects, workspaceRoot)
276 return index >= 0 && containsDesktopString(file.Projects[index].Topics, topicID)
277 }
278
279 func legacyCleanupTopicSnapshotMatchesProjects(item legacycleanup.Candidate, file desktopProjectFile) bool {
280 if item.Topic == nil {
281 return false
282 }
283 var topics, pinned []string
284 var groups []desktopGroup
285 if item.Topic.Scope == "global" {
286 topics, pinned, groups = file.GlobalTopics, file.GlobalPinnedTopics, file.GlobalGroups
287 } else {
288 index := projectIndexByRoot(file.Projects, item.Topic.WorkspaceRoot)
289 if index < 0 {
290 return false
291 }
292 topics, pinned, groups = file.Projects[index].Topics, file.Projects[index].PinnedTopics, file.Projects[index].Groups
293 }
294 if slices.Index(topics, item.TopicID) != item.Topic.Order || containsDesktopString(pinned, item.TopicID) != item.Topic.Pinned {
295 return false
296 }
297 groupID, groupOrder := "", -1
298 for _, group := range groups {
299 if index := slices.Index(group.TopicIDs, item.TopicID); index >= 0 {
300 groupID, groupOrder = group.ID, index
301 break
302 }
303 }
304 return groupID == item.Topic.GroupID && groupOrder == item.Topic.GroupOrder
305 }
306
307 func (a *App) protectLegacyCleanupTopicMutation(topicID string) {
308 if a == nil || a.legacyCleanup == nil || strings.TrimSpace(topicID) == "" {
309 return
310 }
311 _, err := a.legacyCleanup.Update(a.bootContext(), func(state *legacycleanup.State) error {
312 for id, item := range state.Items {
313 if item.Kind != "topic" || item.TopicID != topicID || item.Restored || item.Phase == "archived" {
314 continue
315 }
316 item.Phase, item.Classification, item.Reason = "protected", "protected", "title_mutated"
317 state.Items[id] = item
318 }
319 return nil
320 })
321 if err != nil && !errors.Is(err, legacycleanup.ErrNotInitialized) {
322 slog.Warn("desktop: legacy cleanup title mutation marker failed", "err", err)
323 }
324 }
325
326 func (a *App) restoreLegacyCleanupTopic(id, expectedWorkspaceID string) error {
327 state, err := a.legacyCleanup.Load(a.bootContext())
328 if err != nil {
329 return err
330 }
331 item, ok := state.Items[id]
332 if !ok || item.Kind != "topic" || item.Topic == nil || item.WorkspaceID != expectedWorkspaceID {
333 return errors.New("legacy cleanup topic entry is unavailable")
334 }
335 if item.Restored && item.Phase == "restored" {
336 return nil
337 }
338 if item.Phase != "archived" {
339 return errors.New("legacy cleanup topic entry is unavailable")
340 }
341 a.topicTitleMutationMu.Lock()
342 defer a.topicTitleMutationMu.Unlock()
343 topicIndexMu.Lock()
344 defer topicIndexMu.Unlock()
345 if err := restoreLegacyCleanupTopicLocked(item); err != nil {
346 return err
347 }
348 _, err = a.legacyCleanup.Update(a.bootContext(), func(next *legacycleanup.State) error {
349 current := next.Items[id]
350 if current.Phase != "archived" {
351 return errLegacyCleanupStateChanged
352 }
353 current.Phase, current.Classification, current.Reason, current.Restored = "restored", "protected", "restored_by_user", true
354 next.Items[id] = current
355 return nil
356 })
357 if err == nil {
358 a.emitProjectTreeChanged()
359 }
360 return err
361 }
362
363 func (a *App) purgeLegacyCleanupTopic(id, expectedWorkspaceID string) error {
364 _, err := a.legacyCleanup.Update(a.bootContext(), func(state *legacycleanup.State) error {
365 item, ok := state.Items[id]
366 if !ok || item.Kind != "topic" || item.WorkspaceID != expectedWorkspaceID {
367 return errors.New("legacy cleanup topic entry is unavailable")
368 }
369 if item.Phase == "purged" && item.Restored {
370 return nil
371 }
372 if item.Phase != "archived" || item.Restored {
373 return errLegacyCleanupStateChanged
374 }
375 item.Phase, item.Classification, item.Reason = "purged", "protected", "purged_by_user"
376 item.Restored = true
377 item.Topic = nil
378 state.Items[id] = item
379 return nil
380 })
381 return err
382 }
383
384 func insertLegacyTopicAt(items []string, id string, order int) []string {
385 items = removeString(items, id)
386 if order < 0 || order > len(items) {
387 order = len(items)
388 }
389 items = append(items, "")
390 copy(items[order+1:], items[order:])
391 items[order] = id
392 return items
393 }
394
395 func restoreLegacyTopicGroup(groups []desktopGroup, snapshot *legacycleanup.TopicSnapshot, topicID string) ([]desktopGroup, bool) {
396 if snapshot == nil || snapshot.GroupID == "" {
397 return groups, false
398 }
399 for i := range groups {
400 if groups[i].ID != snapshot.GroupID {
401 continue
402 }
403 next := insertLegacyTopicAt(groups[i].TopicIDs, topicID, snapshot.GroupOrder)
404 if sameStringList(next, groups[i].TopicIDs) {
405 return groups, false
406 }
407 groups[i].TopicIDs = next
408 return groups, true
409 }
410 return groups, false
411 }
412
413 // restoreLegacyCleanupTopicLocked requires topicTitleMutationMu followed by
414 // topicIndexMu. It merges one frozen placeholder into the current project file
415 // and deliberately leaves unrelated concurrent entries untouched.
416 func restoreLegacyCleanupTopicLocked(item legacycleanup.Candidate) error {
417 snapshot := item.Topic
418 if snapshot == nil {
419 return errors.New("legacy cleanup topic snapshot is unavailable")
420 }
421 titles, err := loadTopicTitlesForUpdate(topicTitleRoot(snapshot.Scope, snapshot.WorkspaceRoot))
422 if err != nil {
423 return err
424 }
425 alreadyIndexed := topicIndexedInRegistry(snapshot.Scope, snapshot.WorkspaceRoot, item.TopicID)
426 if alreadyIndexed && titles[item.TopicID] != item.Title {
427 return errLegacyCleanupStateChanged
428 }
429 if !alreadyIndexed {
430 if snapshot.CreatedAt > 0 {
431 err = createTopicState(snapshot.WorkspaceRoot, item.TopicID, item.Title, snapshot.TitleSource, snapshot.CreatedAt)
432 } else {
433 err = setTopicTitleWithSource(snapshot.WorkspaceRoot, item.TopicID, item.Title, snapshot.TitleSource)
434 }
435 if err != nil {
436 return err
437 }
438 }
439 err = updateProjectsFile(func(file *desktopProjectFile) (bool, error) {
440 changed := false
441 if next := removeString(file.DeletedTopics, item.TopicID); !sameStringList(next, file.DeletedTopics) {
442 file.DeletedTopics = next
443 changed = true
444 }
445 if snapshot.Scope == "global" {
446 next := insertLegacyTopicAt(file.GlobalTopics, item.TopicID, snapshot.Order)
447 if !sameStringList(next, file.GlobalTopics) {
448 file.GlobalTopics = next
449 changed = true
450 }
451 if snapshot.Pinned && !containsDesktopString(file.GlobalPinnedTopics, item.TopicID) {
452 file.GlobalPinnedTopics = append(file.GlobalPinnedTopics, item.TopicID)
453 changed = true
454 }
455 if groups, groupChanged := restoreLegacyTopicGroup(file.GlobalGroups, snapshot, item.TopicID); groupChanged {
456 file.GlobalGroups = groups
457 file.GlobalGroupsRevision++
458 changed = true
459 }
460 return changed, nil
461 }
462 index := projectIndexByRoot(file.Projects, snapshot.WorkspaceRoot)
463 if index < 0 {
464 return false, errors.New("legacy cleanup workspace is no longer registered")
465 }
466 project := &file.Projects[index]
467 next := insertLegacyTopicAt(project.Topics, item.TopicID, snapshot.Order)
468 if !sameStringList(next, project.Topics) {
469 project.Topics = next
470 changed = true
471 }
472 if snapshot.Pinned && !containsDesktopString(project.PinnedTopics, item.TopicID) {
473 project.PinnedTopics = append(project.PinnedTopics, item.TopicID)
474 changed = true
475 }
476 if groups, groupChanged := restoreLegacyTopicGroup(project.Groups, snapshot, item.TopicID); groupChanged {
477 project.Groups = groups
478 project.GroupsRevision++
479 changed = true
480 }
481 return changed, nil
482 })
483 if err != nil && !alreadyIndexed {
484 _ = deleteTopicState(snapshot.WorkspaceRoot, item.TopicID)
485 }
486 return err
487 }
488
489 func (a *App) markLegacyCleanupSessionRestored(sessionID string) {
490 if a == nil || a.legacyCleanup == nil || strings.TrimSpace(sessionID) == "" {
491 return
492 }
493 _, err := a.legacyCleanup.Update(a.bootContext(), func(state *legacycleanup.State) error {
494 for id, item := range state.Items {
495 if item.SessionID != sessionID || item.Phase != "archived" {
496 continue
497 }
498 item.Phase, item.Classification, item.Reason, item.Restored = "restored", "protected", "restored_by_user", true
499 state.Items[id] = item
500 }
501 return nil
502 })
503 if err != nil && !errors.Is(err, legacycleanup.ErrNotInitialized) {
504 slog.Warn("desktop: legacy cleanup restore marker failed", "err", err)
505 }
506 }
507
507 lines GO