返回 DeepSeek-Reasonix
topic_state_recovery.go
根目录 / desktop / topic_state_recovery.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "path/filepath"
9 "strings"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/config"
13 "reasonix/internal/topicstate"
14 )
15
16 func (m *topicStateManager) openTopicStoreWithRecovery(ctx context.Context, scope *topicStateScope) (*topicstate.Store, error) {
17 store, err := topicstate.Open(ctx, scope.path)
18 if err == nil || !topicstate.IsCorruptionError(err) {
19 return store, err
20 }
21 recovered, recoveryErr := recoverTopicRecordsFromSessions(scope.root)
22 hasLegacy := legacyTopicFilesExist(scope.root)
23 if recoveryErr != nil {
24 slog.Warn("desktop: topic session recovery source incomplete", "scope", topicScopeKind(scope.root), "error_type", topicStateErrorType(recoveryErr))
25 }
26 if !hasLegacy && len(recovered) == 0 {
27 return nil, err
28 }
29 quarantined, quarantineErr := topicstate.Quarantine(scope.path, m.now())
30 if quarantineErr != nil {
31 return nil, fmt.Errorf("preserve corrupt topic database: %w", quarantineErr)
32 }
33 slog.Warn("desktop: rebuilt corrupt topic state", "scope", topicScopeKind(scope.root), "records", len(recovered), "legacy", hasLegacy, "quarantined", filepath.Base(quarantined))
34 store, err = topicstate.Open(ctx, scope.path)
35 if err != nil || len(recovered) == 0 {
36 return store, err
37 }
38 if _, err := store.ReplaceAll(ctx, recovered); err != nil {
39 _ = store.Close()
40 return nil, err
41 }
42 return store, nil
43 }
44
45 // recoverTopicRecordsFromSessions reconstructs only metadata already carried
46 // by valid branch sidecars. It is used after a quick_check failure, before the
47 // corrupt database is quarantined, and never turns transcript text into a new
48 // authority source.
49 func recoverTopicRecordsFromSessions(workspaceRoot string) (map[string]topicstate.Record, error) {
50 workspaceRoot = normalizeProjectRoot(workspaceRoot)
51 dirs := topicRecoverySessionDirs(workspaceRoot)
52 deleted := deletedTopicSet()
53 records := map[string]topicstate.Record{}
54 var recoveryErr error
55 for _, dir := range dirs {
56 infos, err := agent.ListSessionOrder(dir)
57 if err != nil {
58 recoveryErr = errors.Join(recoveryErr, err)
59 continue
60 }
61 sessionTitles := loadSessionTitles(dir)
62 for _, info := range infos {
63 topicID := strings.TrimSpace(info.TopicID)
64 if topicID == "" || deleted[topicID] || !topicRecoveryInfoMatchesScope(info, workspaceRoot) {
65 continue
66 }
67 title := topicTitleFromText(info.TopicTitle)
68 if title == "" {
69 title = topicTitleFromText(sessionTitles[filepath.Base(info.Path)])
70 }
71 if title == "" {
72 continue
73 }
74 record := records[topicID]
75 record.TopicID = topicID
76 if record.Title == "" || isDefaultTopicTitle(record.Title) {
77 record.Title = agent.UserPreviewText(title)
78 record.TitleSource = topicTitleSourceManual
79 }
80 createdAt := info.CreatedAt.UnixMilli()
81 if createdAt > 0 && (record.CreatedAtMS == 0 || createdAt < record.CreatedAtMS) {
82 record.CreatedAtMS = createdAt
83 }
84 records[topicID] = record
85 }
86 }
87 return records, recoveryErr
88 }
89
90 func topicRecoverySessionDirs(workspaceRoot string) []string {
91 if workspaceRoot != "" {
92 return []string{desktopSessionDir(workspaceRoot)}
93 }
94 dirs := []string{config.SessionDir(), desktopSessionDir(globalWorkspaceRoot())}
95 if sameDesktopPath(dirs[0], dirs[1]) {
96 return dirs[:1]
97 }
98 return dirs
99 }
100
101 func topicRecoveryInfoMatchesScope(info agent.SessionOrderInfo, workspaceRoot string) bool {
102 if workspaceRoot == "" {
103 return strings.TrimSpace(info.Scope) != "project" && strings.TrimSpace(info.WorkspaceRoot) == ""
104 }
105 return strings.TrimSpace(info.Scope) == "project" && sameProjectRoot(info.WorkspaceRoot, workspaceRoot)
106 }
107
107 lines GO