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