返回 DeepSeek-Reasonix
ordinary_page.go
根目录 / internal / sessioncatalog / ordinary_page.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "database/sql/driver"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "strings"
10
11 "reasonix/internal/agent"
12
13 "modernc.org/sqlite"
14 )
15
16 // OrdinaryPage is a flat session projection. A topic containing many sessions
17 // must not expand every member to return the first page of the sidebar.
18 type OrdinaryPageRequest struct {
19 Scope, WorkspaceRoot, Cursor, SortMode, ExcludedPathsJSON string
20 Limit int
21 MinActivity int64
22 PinnedOnly, ExcludePinned bool
23 // Empty means no filter; [] explicitly includes no sources. These keys
24 // describe physical single-head sources, never topics or adopted sessions.
25 IncludeSourceKeysJSON, ExcludeSourceKeysJSON string
26 // Explicit legacy topic tombstones override retained source metadata.
27 ExcludedTopicIDsJSON string
28 }
29
30 func init() {
31 // This function is deliberately not persisted in a schema/index: older
32 // readers can still open the disposable database unchanged. Filtering can
33 // inspect metadata keys, but never resolves source paths or decodes bodies.
34 sqlite.MustRegisterDeterministicScalarFunction("reasonix_catalog_source_key", 1,
35 func(_ *sqlite.FunctionContext, args []driver.Value) (driver.Value, error) {
36 pathKey, ok := args[0].(string)
37 if !ok {
38 return nil, fmt.Errorf("invalid catalog path identity")
39 }
40 return agent.SessionSourceKeyFromIdentity(pathKey, ""), nil
41 })
42 }
43
44 type OrdinaryRecord struct {
45 SessionRecord
46 Title, TitleSource string
47 Pinned bool
48 Cursor string
49 }
50
51 // SourceKey uses the catalog's captured physical identity. Inspecting listing
52 // metadata must not stat or resolve each candidate transcript again.
53 func (r OrdinaryRecord) SourceKey() string {
54 return agent.SessionSourceKeyFromIdentity(r.pathKey, "")
55 }
56
57 // ListMatchingOrdinarySessions preserves Go/display-layer matching semantics
58 // without materializing an entire search result. Callers supply a read lease
59 // for fixed-snapshot paging. Sparse matches may inspect many metadata pages,
60 // but only a bounded batch and the requested matches remain resident.
61 func (c *Catalog) ListMatchingOrdinarySessions(ctx context.Context, req OrdinaryPageRequest, match func(OrdinaryRecord) bool) ([]OrdinaryRecord, error) {
62 if match == nil {
63 return c.ListOrdinarySessions(ctx, req)
64 }
65 limit := req.Limit
66 if limit <= 0 {
67 limit = DefaultLimit
68 }
69 limit = min(limit, MaxLimit)
70 req.Limit = max(limit, DefaultLimit)
71 result := make([]OrdinaryRecord, 0, limit)
72 for {
73 if err := ctx.Err(); err != nil {
74 return nil, err
75 }
76 rows, err := c.ListOrdinarySessions(ctx, req)
77 if err != nil {
78 return nil, err
79 }
80 for _, row := range rows {
81 if err := ctx.Err(); err != nil {
82 return nil, err
83 }
84 if match(row) {
85 result = append(result, row)
86 if len(result) == limit {
87 if err := ctx.Err(); err != nil {
88 return nil, err
89 }
90 return result, nil
91 }
92 }
93 }
94 if len(rows) < req.Limit {
95 return result, nil
96 }
97 req.Cursor = rows[len(rows)-1].Cursor
98 }
99 }
100
101 type ordinaryCursor struct {
102 Pinned int `json:"p"`
103 Activity int64 `json:"a"`
104 Topic string `json:"t"`
105 Path string `json:"s"`
106 }
107
108 func (c *Catalog) MetadataOnly() bool { return c.opts.MetadataOnly }
109
110 // Multi-head projections retain the existing branch-aware adapter until each
111 // head is independently represented in the flat catalog view.
112 func (c *Catalog) HasMultipleHeads(ctx context.Context, scope, root string) (bool, error) {
113 var found bool
114 err := c.readDB(ctx).QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM catalog_sessions WHERE scope=? AND workspace_root_key=? AND head_count>1)`, scope, c.workspaceRootKey(scope, root)).Scan(&found)
115 return found, err
116 }
117
118 func (c *Catalog) HasTopicSessions(ctx context.Context, scope, root, topic string) (bool, error) {
119 var found bool
120 err := c.readDB(ctx).QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM catalog_sessions WHERE scope=? AND workspace_root_key=? AND topic_id=?)`, scope, c.workspaceRootKey(scope, root), topic).Scan(&found)
121 return found, err
122 }
123
124 func (c *Catalog) ListOrdinarySessions(ctx context.Context, req OrdinaryPageRequest) ([]OrdinaryRecord, error) {
125 limit := req.Limit
126 if limit <= 0 {
127 limit = DefaultLimit
128 }
129 limit = min(limit, MaxLimit)
130 activity := `COALESCE(NULLIF(s.last_activity_at,0),s.created_at)`
131 index := `idx_catalog_sessions_flat_activity`
132 if req.SortMode == "created" {
133 activity = `COALESCE(NULLIF(s.created_at,0),s.last_activity_at)`
134 index = `idx_catalog_sessions_flat_created`
135 }
136 where := `s.scope=? AND s.workspace_root_key=? AND s.missing_since=0 AND s.health<>'missing' AND s.health<>'corrupt' AND s.ordinary_visible=1`
137 args := []any{req.Scope, c.workspaceRootKey(req.Scope, req.WorkspaceRoot)}
138 if req.MinActivity > 0 {
139 where += ` AND max(s.created_at,s.last_activity_at)>=?`
140 args = append(args, req.MinActivity)
141 }
142 if req.PinnedOnly {
143 where += ` AND s.topic_pinned=1`
144 }
145 if req.ExcludePinned {
146 where += ` AND s.topic_pinned=0`
147 }
148 if req.ExcludedPathsJSON != "" {
149 where += ` AND s.path NOT IN (SELECT value FROM json_each(?))`
150 args = append(args, req.ExcludedPathsJSON)
151 }
152 if req.ExcludedTopicIDsJSON != "" && req.ExcludedTopicIDsJSON != "[]" {
153 where += ` AND s.topic_id NOT IN (SELECT value FROM json_each(?))`
154 args = append(args, req.ExcludedTopicIDsJSON)
155 }
156 if req.IncludeSourceKeysJSON != "" {
157 where += ` AND reasonix_catalog_source_key(s.path_key) IN (SELECT value FROM json_each(?))`
158 args = append(args, req.IncludeSourceKeysJSON)
159 }
160 if req.ExcludeSourceKeysJSON != "" && req.ExcludeSourceKeysJSON != "[]" {
161 where += ` AND reasonix_catalog_source_key(s.path_key) NOT IN (SELECT value FROM json_each(?))`
162 args = append(args, req.ExcludeSourceKeysJSON)
163 }
164 var cursor *ordinaryCursor
165 if req.Cursor != "" {
166 var cur ordinaryCursor
167 b, err := base64.RawURLEncoding.DecodeString(req.Cursor)
168 if err != nil || json.Unmarshal(b, &cur) != nil {
169 return nil, fmt.Errorf("invalid ordinary cursor")
170 }
171 cursor = &cur
172 }
173 if req.IncludeSourceKeysJSON == "[]" {
174 return []OrdinaryRecord{}, nil
175 }
176 columns := "s." + strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(sessionSelectColumns, "\n", ""), " ", ""), ",", ",s.")
177 selectSQL := `SELECT ` + columns + `,t.title,t.title_source,s.topic_pinned AS page_pin,` + activity + ` AS page_activity FROM catalog_sessions s INDEXED BY ` + index + ` JOIN catalog_topics t ON t.scope=s.scope AND t.workspace_root_key=s.workspace_root_key AND t.topic_id=s.topic_id WHERE ` + where
178 orderSQL := ` ORDER BY s.topic_pinned DESC,` + activity + ` DESC,s.topic_id,s.path LIMIT ?`
179 query := selectSQL + orderSQL
180 if cursor == nil {
181 args = append(args, limit)
182 } else {
183 // Negated mixed-direction tuples cannot seek the descending index.
184 // Three disjoint index ranges merge at most 3*limit rows, so deep
185 // pages never walk every preceding row to locate their first result.
186 cur := *cursor
187 ranges := []struct {
188 predicate string
189 values []any
190 }{
191 {`s.topic_pinned=? AND ` + activity + `=? AND (s.topic_id,s.path)>(?,?)`, []any{cur.Pinned, cur.Activity, cur.Topic, cur.Path}},
192 {`s.topic_pinned=? AND ` + activity + `<?`, []any{cur.Pinned, cur.Activity}},
193 {`s.topic_pinned<?`, []any{cur.Pinned}},
194 }
195 parts := make([]string, 0, len(ranges))
196 baseArgs := args
197 args = nil
198 for _, span := range ranges {
199 parts = append(parts, `SELECT * FROM (`+selectSQL+` AND `+span.predicate+orderSQL+`)`)
200 args = append(args, baseArgs...)
201 args = append(args, span.values...)
202 args = append(args, limit)
203 }
204 query = `SELECT * FROM (` + strings.Join(parts, ` UNION ALL `) + `) ORDER BY page_pin DESC,page_activity DESC,topic_id,path LIMIT ?`
205 args = append(args, limit)
206 }
207 rows, err := c.readDB(ctx).QueryContext(ctx, query, args...)
208 if err != nil {
209 return nil, err
210 }
211 defer rows.Close()
212 out := []OrdinaryRecord{}
213 for rows.Next() {
214 var record OrdinaryRecord
215 var pin int
216 var value int64
217 scanner := appendedScanner{rows, []any{&record.Title, &record.TitleSource, &pin, &value}}
218 record.SessionRecord, err = scanSession(scanner)
219 if err != nil {
220 return nil, err
221 }
222 if c.pathRemovedKey(record.pathKey, record.Path) {
223 return nil, fmt.Errorf("catalog snapshot source removed")
224 }
225 record.Pinned = pin != 0
226 b, _ := json.Marshal(ordinaryCursor{pin, value, record.TopicID, record.Path})
227 record.Cursor = base64.RawURLEncoding.EncodeToString(b)
228 out = append(out, record)
229 }
230 return out, rows.Err()
231 }
232
233 type appendedScanner struct {
234 row interface{ Scan(...any) error }
235 tail []any
236 }
237
238 func (s appendedScanner) Scan(args ...any) error { return s.row.Scan(append(args, s.tail...)...) }
239
239 lines GO