返回 DeepSeek-Reasonix
ordinary_visibility.go
根目录 / internal / sessioncatalog / ordinary_visibility.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "path/filepath"
6 "strings"
7
8 "reasonix/internal/agent"
9 )
10
11 // TopicFolded consults only an already-proved catalog relationship. It cannot
12 // create a new relationship by reading a source or matching its filename.
13 func (c *Catalog) TopicFolded(ctx context.Context, scope, root, topicID string) bool {
14 scope, root = normalizeScope(scope, root)
15 key := c.workspaceRootKey(scope, root)
16 var folded bool
17 err := c.readDB(ctx).QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM catalog_folded_topics
18 WHERE scope=? AND workspace_root_key=? AND topic_id=?) AND NOT EXISTS(
19 SELECT 1 FROM catalog_sessions WHERE scope=? AND workspace_root_key=? AND topic_id=?
20 AND ordinary_visible=1 AND missing_since=0 LIMIT 1)`, scope, key, topicID, scope, key, topicID).Scan(&folded)
21 return err == nil && folded
22 }
23
24 // PreferredOrdinarySessionPaths returns the session paths that may appear in
25 // the ordinary project tree for one workspace. Covered recovery copies and
26 // non-preferred conflict forks are omitted so the sidebar matches the 1.23
27 // contract: one conversation row, not a wall of recovery replicas.
28 //
29 // Rules:
30 // - Every non-recovered session is preferred.
31 // - When a recovery group still has its non-recovered parent, no recovered
32 // member of that group is preferred (parent is the ordinary row).
33 // - Otherwise the group keeps a single preferred recovered leaf: canonical
34 // or adopted first, then highest turns, then latest activity.
35 //
36 // Open/running tabs may still force-show a path outside this set at the UI
37 // boundary; this helper only encodes on-disk ordinary visibility.
38 func (c *Catalog) PreferredOrdinarySessionPaths(ctx context.Context, scope, workspaceRoot string) (map[string]struct{}, error) {
39 out := map[string]struct{}{}
40 if c == nil || c.db == nil {
41 return out, nil
42 }
43 if c.opts.MetadataOnly {
44 // Metadata discovery cannot infer branch coverage. The persisted
45 // per-row visibility already records any previously proved relation;
46 // do not rescan the workspace or fold sources from their filenames.
47 return nil, nil
48 }
49 scope, workspaceRoot = normalizeScope(scope, workspaceRoot)
50 rows, err := c.readDB(ctx).QueryContext(ctx, `
51 SELECT path, recovered, parent_id, recovery_copy, recovery_group_id,
52 recovery_role, recovery_canonical, turns, last_activity_at
53 FROM catalog_sessions
54 WHERE scope=? AND workspace_root_key=? AND missing_since=0 AND health<>'missing'`,
55 scope, c.workspaceRootKey(scope, workspaceRoot))
56 if err != nil {
57 return out, err
58 }
59 defer rows.Close()
60 var sessions []SessionRecord
61 for rows.Next() {
62 var rec SessionRecord
63 var recoveryCopy, recoveryCanonical, recovered int
64 if err := rows.Scan(&rec.Path, &recovered, &rec.ParentID, &recoveryCopy,
65 &rec.RecoveryGroupID, &rec.RecoveryRole, &recoveryCanonical,
66 &rec.Turns, &rec.LastActivityAt); err != nil {
67 return map[string]struct{}{}, err
68 }
69 rec.Recovered = recovered != 0
70 rec.RecoveryCopy = recoveryCopy != 0
71 rec.RecoveryCanonical = recoveryCanonical != 0
72 if rec.RecoveryRole == "" {
73 if rec.RecoveryCopy {
74 rec.RecoveryRole = RecoveryRoleCoveredCopy
75 } else if rec.Recovered {
76 rec.RecoveryRole = RecoveryRoleDiverged
77 } else {
78 rec.RecoveryRole = RecoveryRoleNormal
79 }
80 }
81 sessions = append(sessions, rec)
82 }
83 if err := rows.Err(); err != nil {
84 return map[string]struct{}{}, err
85 }
86 return PreferredOrdinarySessionPaths(sessions), nil
87 }
88
89 // PreferredOrdinarySessionPaths selects ordinary-tree paths from an in-memory
90 // session set. Pure helper for tests and callers that already hold records.
91 func PreferredOrdinarySessionPaths(sessions []SessionRecord) map[string]struct{} {
92 preferred := make(map[string]struct{}, len(sessions))
93 normalRoots := map[string]struct{}{}
94 recoveredByGroup := map[string][]SessionRecord{}
95 for _, session := range sessions {
96 path := strings.TrimSpace(session.Path)
97 if path == "" {
98 continue
99 }
100 if session.RecoveryCopy || session.RecoveryRole == RecoveryRoleCoveredCopy {
101 continue
102 }
103 if session.OrdinaryVisible && session.Recovered {
104 preferred[path] = struct{}{}
105 continue
106 }
107 if !session.Recovered {
108 preferred[path] = struct{}{}
109 normalRoots[agent.BranchID(path)] = struct{}{}
110 continue
111 }
112 key := recoveryLineageKey(session)
113 recoveredByGroup[key] = append(recoveredByGroup[key], session)
114 }
115 for group, members := range recoveredByGroup {
116 if _, parentPresent := normalRoots[group]; parentPresent {
117 // Parent conversation still exists: keep only that row.
118 continue
119 }
120 best := pickPreferredRecovery(members)
121 if path := strings.TrimSpace(best.Path); path != "" {
122 preferred[path] = struct{}{}
123 }
124 }
125 return preferred
126 }
127
128 // OrdinaryTreeSession reports whether a catalog session should appear in the
129 // ordinary project tree given open/running runtime state and the preferred set.
130 func OrdinaryTreeSession(session SessionRecord, open, running bool, preferred map[string]struct{}) bool {
131 if open || running {
132 return true
133 }
134 if session.Health == HealthCorrupt || session.Health == HealthMissing {
135 return false
136 }
137 if session.RecoveryCopy || session.RecoveryRole == RecoveryRoleCoveredCopy {
138 return false
139 }
140 if !session.Recovered {
141 return true
142 }
143 if preferred == nil {
144 // Without a preference set, still hide covered copies (handled above)
145 // but keep recovered leaves so callers that skip preference computation
146 // do not blank the tree. Prefer building PreferredOrdinarySessionPaths.
147 return true
148 }
149 _, ok := preferred[strings.TrimSpace(session.Path)]
150 return ok
151 }
152
153 func recoveryLineageKey(session SessionRecord) string {
154 if group := strings.TrimSpace(session.RecoveryGroupID); group != "" {
155 return group
156 }
157 if parent := strings.TrimSpace(session.ParentID); parent != "" {
158 return parent
159 }
160 // Isolated recovered leaf without parent metadata: its own group.
161 return "path:" + agent.BranchID(session.Path)
162 }
163
164 func pickPreferredRecovery(members []SessionRecord) SessionRecord {
165 if len(members) == 0 {
166 return SessionRecord{}
167 }
168 best := members[0]
169 for _, candidate := range members[1:] {
170 if recoveryRank(candidate) > recoveryRank(best) {
171 best = candidate
172 continue
173 }
174 if recoveryRank(candidate) < recoveryRank(best) {
175 continue
176 }
177 if candidate.Turns != best.Turns {
178 if candidate.Turns > best.Turns {
179 best = candidate
180 }
181 continue
182 }
183 if candidate.LastActivityAt != best.LastActivityAt {
184 if candidate.LastActivityAt > best.LastActivityAt {
185 best = candidate
186 }
187 continue
188 }
189 if filepath.Base(candidate.Path) < filepath.Base(best.Path) {
190 best = candidate
191 }
192 }
193 return best
194 }
195
196 func recoveryRank(session SessionRecord) int {
197 switch {
198 case session.RecoveryCanonical:
199 return 3
200 case session.RecoveryRole == RecoveryRoleAdopted:
201 return 2
202 case session.RecoveryRole == RecoveryRolePreferred:
203 return 2
204 case session.RecoveryRole == RecoveryRoleDiverged || session.Recovered:
205 return 1
206 default:
207 return 0
208 }
209 }
210
210 lines GO