返回 DeepSeek-Reasonix
catalog.go
根目录 / internal / sessioncatalog / catalog.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "database/sql"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "time"
16
17 "reasonix/internal/agent"
18 "reasonix/internal/projectiondb"
19 )
20
21 const defaultMissingGrace = 30 * time.Second
22
23 type Catalog struct {
24 db *sql.DB
25 opts Options
26 pathIdentity func(string) string
27 mutationSeq atomic.Uint64
28 revision atomic.Uint64
29 statusMu sync.RWMutex
30 status Status
31 writeCh chan string
32 writeMu sync.Mutex
33 writeQueued map[string]SessionRecord
34 // mutationMu is the process-local SQLite single-writer boundary. WAL permits
35 // concurrent readers, but repair, metadata, and reconcile mutations must not
36 // race into avoidable SQLITE_BUSY failures.
37 mutationMu sync.Mutex
38 removedPaths sync.Map
39 repairCh chan string
40 repairQueued sync.Map
41 reconcileCh chan DirectoryTarget
42 reconcileQueued sync.Map
43 reconcileDirtyMu sync.Mutex
44 reconcileDirty map[string]DirectoryTarget
45 verifiedDirsMu sync.RWMutex
46 verifiedDirs map[string]string
47 pathCh chan sessionPathRequest
48 pathQueueMu sync.Mutex
49 pathQueued sync.Map
50 directoryLocksMu sync.Mutex
51 directoryLocks map[string]*sync.Mutex
52 workerCtx context.Context
53 workerCancel context.CancelFunc
54 stop chan struct{}
55 stopOnce sync.Once
56 workers sync.WaitGroup
57 closeDone chan struct{}
58 closeErr error
59 // testReconcileBatchHook deterministically pauses an uncommitted directory
60 // projection. Production catalogs leave it nil.
61 testReconcileBatchHook func(int)
62 // testReconcileStartHook observes queued reconcile waves. Direct explicit
63 // ReconcileDirectory calls do not invoke it.
64 testReconcileStartHook func(DirectoryTarget)
65 // testRepairSessionHook replaces the filesystem repair in scheduler tests.
66 testRepairSessionHook func(context.Context, string) (agent.SessionListingRepairResult, error)
67 // testRepairBatchError injects publication failures by transaction stage.
68 testRepairBatchError func(string) error
69 // testSessionContentLoadHook counts strict lineage snapshot loads.
70 testSessionContentLoadHook func(string)
71 // testPathMutationLoadedHook pauses after reading a removal generation.
72 // Production catalogs leave it nil.
73 testPathMutationLoadedHook func(string)
74 }
75
76 type sessionPathRequest struct {
77 target DirectoryTarget
78 path string
79 queueKey string
80 sequence uint64
81 }
82
83 type pageCursor struct {
84 Pinned int `json:"p"`
85 ManualOrder bool `json:"m,omitempty"`
86 SortOrder int64 `json:"o,omitempty"`
87 Activity int64 `json:"a"`
88 TopicID string `json:"t"`
89 Binding string `json:"b,omitempty"`
90 }
91
92 func Open(ctx context.Context, opts Options) (*Catalog, error) {
93 if opts.Path == "" {
94 opts.Path = DefaultPath()
95 }
96 if opts.Now == nil {
97 opts.Now = time.Now
98 }
99 if opts.MissingGrace <= 0 {
100 opts.MissingGrace = defaultMissingGrace
101 }
102 if opts.QueueCapacity <= 0 {
103 opts.QueueCapacity = 1024
104 }
105 // An empty path (no cache dir) or explicit memory flag must never write a
106 // relative session-catalog file into the current project directory.
107 if strings.TrimSpace(opts.Path) == "" {
108 opts.Path = ""
109 opts.InMemory = true
110 }
111 if !opts.InMemory {
112 if env := strings.TrimSpace(os.Getenv("REASONIX_SESSION_CATALOG_MEMORY")); env == "1" {
113 opts.InMemory = true
114 }
115 }
116
117 c := &Catalog{
118 opts: opts,
119 pathIdentity: PathIdentityKey,
120 writeCh: make(chan string, opts.QueueCapacity),
121 writeQueued: map[string]SessionRecord{},
122 repairCh: make(chan string, opts.QueueCapacity),
123 reconcileCh: make(chan DirectoryTarget, 64),
124 reconcileDirty: map[string]DirectoryTarget{},
125 verifiedDirs: map[string]string{},
126 pathCh: make(chan sessionPathRequest, opts.QueueCapacity),
127 directoryLocks: map[string]*sync.Mutex{},
128 stop: make(chan struct{}),
129 closeDone: make(chan struct{}),
130 status: Status{State: StateOpening, Path: opts.Path},
131 }
132 handle, err := projectiondb.Open(ctx, projectiondb.OpenOptions{
133 Path: opts.Path,
134 MemoryName: "session-catalog",
135 Migrations: sessionMigrations(),
136 InMemory: opts.InMemory,
137 MaxOpenConns: 4,
138 Now: opts.Now,
139 })
140 if err != nil {
141 return nil, err
142 }
143 c.db = handle.DB
144 c.status.Mode = Mode(handle.Status.Mode)
145 c.status.State = State(handle.Status.State)
146 if c.status.State == "" {
147 c.status.State = StateReady
148 }
149 if c.status.Mode == ModeMemory {
150 c.status.Path = ""
151 } else {
152 c.status.Path = handle.Status.Path
153 }
154 c.status.LastError = handle.Status.LastError
155 c.status.QuarantinedPath = handle.Status.QuarantinedPath
156 if err := c.loadStatus(ctx); err != nil {
157 _ = c.db.Close()
158 return nil, err
159 }
160 if !opts.DisableRepair {
161 if err := c.resetRepairSchedule(ctx); err != nil {
162 _ = c.db.Close()
163 return nil, err
164 }
165 c.refreshCounts(ctx)
166 }
167 c.testRepairSessionHook = opts.repairSession
168 c.workerCtx, c.workerCancel = context.WithCancel(context.Background())
169 c.workers.Add(1)
170 go c.writerLoop()
171 c.workers.Add(1)
172 go c.reconcileLoop()
173 c.workers.Add(1)
174 go c.sessionPathLoop()
175 if !opts.DisableRepair {
176 c.workers.Add(1)
177 go c.repairLoop()
178 c.enqueuePersistedRepairs(ctx)
179 }
180 return c, nil
181 }
182
183 func (c *Catalog) loadStatus(ctx context.Context) error {
184 var revision uint64
185 if err := c.db.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&revision); err != nil {
186 return err
187 }
188 c.revision.Store(revision)
189 c.statusMu.Lock()
190 c.status.Revision = revision
191 c.statusMu.Unlock()
192 c.refreshCounts(ctx)
193 return nil
194 }
195
196 func (c *Catalog) Status() Status {
197 if c == nil {
198 return Status{State: StateDegraded, Mode: ModeMemory, LastError: "session catalog unavailable"}
199 }
200 c.statusMu.RLock()
201 defer c.statusMu.RUnlock()
202 return c.status
203 }
204
205 func (c *Catalog) refreshCounts(ctx context.Context) {
206 if c == nil || c.db == nil {
207 return
208 }
209 var indexed, pending, total, physical, logical, groups, branches, diverged, cleanup int64
210 var active, deferred, blocked int64
211 var nextRepair sql.NullInt64
212 err := c.db.QueryRowContext(ctx, `SELECT
213 COUNT(*),
214 COALESCE(SUM(CASE WHEN turns_state='unknown' THEN 1 ELSE 0 END),0),
215 (SELECT COALESCE(SUM(total),0) FROM catalog_directories),
216 COALESCE(SUM(CASE WHEN missing_since=0 THEN 1 ELSE 0 END),0),
217 (SELECT COUNT(*) FROM catalog_topics),
218 COUNT(DISTINCT CASE WHEN recovered=1 AND recovery_group_id<>'' AND missing_since=0 THEN recovery_group_id END),
219 COALESCE(SUM(CASE WHEN recovered=1 AND missing_since=0 THEN 1 ELSE 0 END),0),
220 COALESCE(SUM(CASE WHEN recovered=1 AND recovery_role='diverged' AND missing_since=0 THEN 1 ELSE 0 END),0),
221 COALESCE(SUM(CASE WHEN recovered=1 AND recovery_role='covered_copy' AND missing_since=0 THEN 1 ELSE 0 END),0),
222 COALESCE(SUM(CASE WHEN turns_state='unknown' AND repair_state IN ('pending','active') THEN 1 ELSE 0 END),0),
223 COALESCE(SUM(CASE WHEN turns_state='unknown' AND repair_state='deferred' THEN 1 ELSE 0 END),0),
224 COALESCE(SUM(CASE WHEN turns_state='unknown' AND repair_state='blocked' THEN 1 ELSE 0 END),0),
225 MIN(CASE WHEN turns_state='unknown' AND repair_state='deferred' THEN repair_retry_at END)
226 FROM catalog_sessions`).Scan(&indexed, &pending, &total, &physical, &logical, &groups, &branches,
227 &diverged, &cleanup, &active, &deferred, &blocked, &nextRepair)
228 if err != nil {
229 return
230 }
231 errorKinds := map[string]int64{}
232 if rows, queryErr := c.db.QueryContext(ctx, `SELECT repair_error_kind,COUNT(*) FROM catalog_sessions
233 WHERE turns_state='unknown' AND repair_error_kind<>'' GROUP BY repair_error_kind`); queryErr == nil {
234 for rows.Next() {
235 var kind string
236 var count int64
237 if rows.Scan(&kind, &count) == nil {
238 errorKinds[kind] = count
239 }
240 }
241 _ = rows.Close()
242 }
243 c.statusMu.Lock()
244 c.status.Indexed = indexed
245 c.status.Total = total
246 c.status.RepairPending = pending
247 c.status.RepairActive = active
248 c.status.RepairDeferred = deferred
249 c.status.RepairBlocked = blocked
250 c.status.NextRepairAt = 0
251 if nextRepair.Valid {
252 c.status.NextRepairAt = nextRepair.Int64
253 }
254 c.status.RepairErrorKinds = errorKinds
255 c.status.PhysicalSessions = physical
256 c.status.LogicalSessions = logical
257 c.status.RecoveryGroups = groups
258 c.status.RecoveryBranches = branches
259 c.status.RecoveryDiverged = diverged
260 c.status.CleanupEligible = cleanup
261 c.status.SourceCount = total
262 c.status.Revision = c.revision.Load()
263 c.statusMu.Unlock()
264 }
265
266 func (c *Catalog) markRepair(reason string, at int64) {
267 if c == nil || strings.TrimSpace(reason) == "" {
268 return
269 }
270 if at <= 0 {
271 at = time.Now().UnixMilli()
272 }
273 c.statusMu.Lock()
274 c.status.RepairReason = strings.TrimSpace(reason)
275 c.status.LastRepairAt = at
276 c.statusMu.Unlock()
277 }
278
279 // MarkRepairReason records a lifecycle-level repair cause (for example, a
280 // clean index-generation cutover) without touching the authoritative session
281 // files. Integrity checks use the internal helper so they can attach their
282 // timestamp at the point of detection.
283 func (c *Catalog) MarkRepairReason(reason string) {
284 if c == nil {
285 return
286 }
287 c.markRepair(reason, c.opts.Now().UnixMilli())
288 }
289
290 func normalizeScope(scope, root string) (string, string) {
291 if strings.TrimSpace(scope) != "project" {
292 return "global", ""
293 }
294 return "project", strings.TrimSpace(root)
295 }
296
297 func normalizeSessionRecord(record SessionRecord) SessionRecord {
298 record.Path = cleanCatalogAccessPath(record.Path)
299 if record.Directory == "" {
300 record.Directory = filepath.Dir(record.Path)
301 }
302 record.Directory = cleanCatalogAccessPath(record.Directory)
303 record.Scope, record.WorkspaceRoot = normalizeScope(record.Scope, record.WorkspaceRoot)
304 if record.TurnsState == "" {
305 record.TurnsState = TurnsUnknown
306 }
307 if record.Health == "" {
308 record.Health = HealthOK
309 }
310 return record
311 }
312
313 func (c *Catalog) pathKey(path string) string {
314 if c != nil && c.pathIdentity != nil {
315 return c.pathIdentity(path)
316 }
317 return PathIdentityKey(path)
318 }
319
320 func (c *Catalog) workspaceRootKey(scope, root string) string {
321 scope, root = normalizeScope(scope, root)
322 if scope != "project" || root == "" {
323 return ""
324 }
325 return c.pathKey(root)
326 }
327
328 // queuePathKey is intentionally lexical. Save observers call the enqueue APIs
329 // synchronously, so filesystem probes (EvalSymlinks/platform case detection)
330 // belong to background workers and the SQLite uniqueness boundary.
331 func queuePathKey(path string) string {
332 return cleanCatalogAccessPath(path)
333 }
334
335 func (c *Catalog) EnqueueSession(record SessionRecord) bool {
336 if c == nil {
337 return false
338 }
339 record = normalizeSessionRecord(record)
340 record.enqueueSequence = c.mutationSeq.Add(1)
341 key := queuePathKey(record.Path)
342 if key == "" {
343 return false
344 }
345 c.writeMu.Lock()
346 if _, loaded := c.writeQueued[key]; loaded {
347 c.writeQueued[key] = record
348 c.writeMu.Unlock()
349 return true
350 }
351 c.writeQueued[key] = record
352 select {
353 case <-c.stop:
354 delete(c.writeQueued, key)
355 c.writeMu.Unlock()
356 return false
357 case c.writeCh <- key:
358 c.writeMu.Unlock()
359 return true
360 default:
361 delete(c.writeQueued, key)
362 c.writeMu.Unlock()
363 return false
364 }
365 }
366
367 func (c *Catalog) takeQueuedWrite(path string) (SessionRecord, bool) {
368 c.writeMu.Lock()
369 defer c.writeMu.Unlock()
370 record, ok := c.writeQueued[path]
371 if ok {
372 delete(c.writeQueued, path)
373 }
374 return record, ok
375 }
376
377 func (c *Catalog) writerLoop() {
378 defer c.workers.Done()
379 ticker := time.NewTicker(20 * time.Millisecond)
380 defer ticker.Stop()
381 pending := map[string]SessionRecord{}
382 flush := func() {
383 if len(pending) == 0 {
384 return
385 }
386 records := make([]SessionRecord, 0, len(pending))
387 for _, record := range pending {
388 records = append(records, record)
389 }
390 pending = map[string]SessionRecord{}
391 ctx, cancel := context.WithTimeout(c.workerCtx, time.Second)
392 _ = c.upsertSessions(ctx, records, nil, "write")
393 cancel()
394 }
395 for {
396 select {
397 case path := <-c.writeCh:
398 if record, ok := c.takeQueuedWrite(path); ok {
399 pending[path] = record
400 }
401 if len(pending) >= 64 {
402 flush()
403 }
404 case <-ticker.C:
405 flush()
406 case <-c.stop:
407 for {
408 select {
409 case path := <-c.writeCh:
410 if record, ok := c.takeQueuedWrite(path); ok {
411 pending[path] = record
412 }
413 default:
414 flush()
415 return
416 }
417 }
418 }
419 }
420 }
421
422 func (c *Catalog) recomputeTopic(ctx context.Context, tx *sql.Tx, key TopicKey) error {
423 key.Scope, key.WorkspaceRoot = normalizeScope(key.Scope, key.WorkspaceRoot)
424 rootKey := key.workspaceKey
425 if key.Scope == "project" && rootKey == "" {
426 rootKey = c.workspaceRootKey(key.Scope, key.WorkspaceRoot)
427 }
428 var count int
429 if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE scope=? AND workspace_root_key=? AND topic_id=?`, key.Scope, rootKey, key.TopicID).Scan(&count); err != nil {
430 return err
431 }
432 if count == 0 {
433 _, err := tx.ExecContext(ctx, `DELETE FROM catalog_topics WHERE scope=? AND workspace_root_key=? AND topic_id=?`, key.Scope, rootKey, key.TopicID)
434 return err
435 }
436 if err := removeRemappedTopicIdentity(ctx, tx, key, rootKey); err != nil {
437 return err
438 }
439 // Covered copies skip turn/health totals but still update recency. Adopted
440 // branches are alternate continuations, so preserve the pre-catalog contract:
441 // max(sum(normal turns), max(adopted recovery turns)).
442 _, err := tx.ExecContext(ctx, `INSERT INTO catalog_topics(
443 scope,workspace_root,workspace_root_key,topic_id,title,turns,turns_state,created_at,
444 last_activity_at,recovery_state,recovery_branch_count,
445 recovery_unresolved_count,recovery_cleanup_eligible_count,health
446 ) SELECT ?,?,?,?,
447 COALESCE(NULLIF((SELECT COALESCE(NULLIF(topic_title,''), preview, '')
448 FROM catalog_sessions WHERE scope=? AND workspace_root_key=? AND topic_id=?
449 ORDER BY recovery_copy ASC, last_activity_at DESC, path ASC LIMIT 1),''), ?),
450 MAX(
451 COALESCE(SUM(CASE WHEN recovery_copy=0 AND recovered=0 AND turns_state='valid' THEN turns ELSE 0 END),0),
452 COALESCE(MAX(CASE WHEN recovery_copy=0 AND recovered=1 AND turns_state='valid' THEN turns ELSE 0 END),0)
453 ),
454 CASE WHEN SUM(CASE WHEN recovery_copy=0 AND turns_state='corrupt' THEN 1 ELSE 0 END)>0 THEN 'corrupt'
455 WHEN SUM(CASE WHEN recovery_copy=0 AND turns_state='unknown' THEN 1 ELSE 0 END)>0 THEN 'unknown'
456 WHEN SUM(CASE WHEN recovery_copy=0 THEN 1 ELSE 0 END)=0 THEN 'valid'
457 ELSE 'valid' END,
458 COALESCE(MIN(NULLIF(created_at,0)),0), COALESCE(MAX(last_activity_at),0),
459 CASE WHEN SUM(CASE WHEN recovered=1 AND recovery_role='preferred' THEN 1 ELSE 0 END)>0 THEN 'preferred'
460 WHEN SUM(CASE WHEN recovered=1 AND recovery_role='diverged' THEN 1 ELSE 0 END)>0 THEN 'diverged'
461 WHEN SUM(CASE WHEN recovered=1 AND recovery_role='adopted' THEN 1 ELSE 0 END)>0 THEN 'adopted'
462 WHEN SUM(CASE WHEN recovery_copy=0 THEN 1 ELSE 0 END)=0 THEN 'recovery_only' ELSE '' END,
463 SUM(CASE WHEN recovered=1 THEN 1 ELSE 0 END),
464 CASE WHEN SUM(CASE WHEN recovered=1 AND recovery_role='preferred' THEN 1 ELSE 0 END)>0 THEN 0
465 ELSE SUM(CASE WHEN recovered=1 AND recovery_role='diverged' THEN 1 ELSE 0 END) END,
466 SUM(CASE WHEN recovered=1 AND recovery_role='covered_copy' THEN 1 ELSE 0 END),
467 CASE WHEN SUM(CASE WHEN recovery_copy=0 AND health='corrupt' THEN 1 ELSE 0 END)>0 THEN 'corrupt'
468 WHEN SUM(CASE WHEN recovery_copy=0 AND health='missing' THEN 1 ELSE 0 END)>0 THEN 'missing'
469 ELSE 'ok' END
470 FROM catalog_sessions WHERE scope=? AND workspace_root_key=? AND topic_id=?
471 ON CONFLICT(scope,workspace_root_key,topic_id) DO UPDATE SET
472 title=excluded.title, turns=excluded.turns, turns_state=excluded.turns_state,
473 created_at=excluded.created_at, last_activity_at=excluded.last_activity_at,
474 recovery_state=excluded.recovery_state,
475 recovery_branch_count=excluded.recovery_branch_count,
476 recovery_unresolved_count=excluded.recovery_unresolved_count,
477 recovery_cleanup_eligible_count=excluded.recovery_cleanup_eligible_count,
478 health=excluded.health`,
479 key.Scope, key.WorkspaceRoot, rootKey, key.TopicID,
480 key.Scope, rootKey, key.TopicID, key.TopicID,
481 key.Scope, rootKey, key.TopicID)
482 return err
483 }
484
485 func boolToInt(value bool) int {
486 if value {
487 return 1
488 }
489 return 0
490 }
491
492 func bumpRevision(ctx context.Context, tx *sql.Tx) (uint64, error) {
493 if _, err := tx.ExecContext(ctx, `UPDATE catalog_state SET revision=revision+1 WHERE id=1`); err != nil {
494 return 0, err
495 }
496 var revision uint64
497 if err := tx.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&revision); err != nil {
498 return 0, err
499 }
500 return revision, nil
501 }
502
503 func (c *Catalog) publishRevision(revision uint64, roots []string, reason string) {
504 c.rememberRevision(revision)
505 if c.opts.OnRevision != nil {
506 c.opts.OnRevision(revision, c.registeredRevisionRoots(roots), reason)
507 }
508 }
509
510 func (c *Catalog) registeredRevisionRoots(roots []string) []string {
511 out := make([]string, 0, len(roots))
512 seen := make(map[string]struct{}, len(roots))
513 for _, root := range roots {
514 _, root = normalizeScope("project", root)
515 rootKey := c.workspaceRootKey("project", root)
516 if _, ok := seen[rootKey]; ok {
517 continue
518 }
519 seen[rootKey] = struct{}{}
520 registered := root
521 if rootKey != "" && c.db != nil {
522 var candidate string
523 if err := c.db.QueryRowContext(context.Background(), `SELECT workspace_root FROM catalog_projects
524 WHERE scope='project' AND workspace_root_key=?`, rootKey).Scan(&candidate); err == nil && candidate != "" {
525 registered = candidate
526 }
527 }
528 out = append(out, registered)
529 }
530 return out
531 }
532
533 func (c *Catalog) rememberRevision(revision uint64) {
534 c.revision.Store(revision)
535 c.statusMu.Lock()
536 c.status.Revision = revision
537 c.statusMu.Unlock()
538 }
539
540 func mapKeys(values map[string]struct{}) []string {
541 out := make([]string, 0, len(values))
542 for value := range values {
543 out = append(out, value)
544 }
545 return out
546 }
547
548 func (c *Catalog) listTopicSessionsByRootKey(ctx context.Context, key TopicKey, rootKey string) ([]SessionRecord, error) {
549 out := []SessionRecord{}
550 var cursor *sessionPageCursor
551 for len(out) < MaxLimit {
552 where := `scope=? AND workspace_root_key=? AND topic_id=?`
553 args := []any{key.Scope, rootKey, key.TopicID}
554 if cursor != nil {
555 where += ` AND (last_activity_at<? OR (last_activity_at=? AND path>?))`
556 args = append(args, cursor.Activity, cursor.Activity, cursor.Path)
557 }
558 args = append(args, MaxLimit)
559 rows, err := c.db.QueryContext(ctx, `SELECT `+sessionSelectColumns+` FROM catalog_sessions
560 WHERE `+where+` ORDER BY last_activity_at DESC,path ASC LIMIT ?`, args...)
561 if err != nil {
562 return nil, err
563 }
564 rawCount := 0
565 var lastScanned SessionRecord
566 for rows.Next() {
567 record, err := scanSession(rows)
568 if err != nil {
569 _ = rows.Close()
570 return nil, err
571 }
572 rawCount++
573 lastScanned = record
574 if c.pathRemovedKey(record.pathKey, record.Path) {
575 continue
576 }
577 out = append(out, record)
578 if len(out) == MaxLimit {
579 break
580 }
581 }
582 rowsErr := rows.Err()
583 _ = rows.Close()
584 if rowsErr != nil {
585 return nil, rowsErr
586 }
587 if len(out) == MaxLimit || rawCount < MaxLimit || rawCount == 0 {
588 break
589 }
590 cursor = &sessionPageCursor{Activity: lastScanned.LastActivityAt, Path: lastScanned.Path}
591 }
592 return out, nil
593 }
594
595 func (c *Catalog) GetTopic(ctx context.Context, key TopicKey) (TopicRecord, bool, error) {
596 key.Scope, key.WorkspaceRoot = normalizeScope(key.Scope, key.WorkspaceRoot)
597 key.TopicID = strings.TrimSpace(key.TopicID)
598 rootKey := c.workspaceRootKey(key.Scope, key.WorkspaceRoot)
599 item := TopicRecord{Sessions: []SessionRecord{}}
600 err := c.db.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id,title,title_source,pinned,
601 CASE WHEN metadata_present=1 THEN sort_order ELSE -1 END,
602 turns,turns_state,created_at,last_activity_at,recovery_state,recovery_branch_count,
603 recovery_unresolved_count,recovery_cleanup_eligible_count,health
604 FROM catalog_topics WHERE scope=? AND workspace_root_key=? AND topic_id=?`,
605 key.Scope, rootKey, key.TopicID).Scan(
606 &item.Scope, &item.WorkspaceRoot, &item.TopicID, &item.Title, &item.TitleSource,
607 &item.Pinned, &item.SortOrder, &item.Turns, &item.TurnsState,
608 &item.CreatedAt, &item.LastActivityAt, &item.RecoveryState, &item.RecoveryBranchCount,
609 &item.RecoveryUnresolvedCount, &item.RecoveryCleanupEligibleCount, &item.Health)
610 if errors.Is(err, sql.ErrNoRows) {
611 return item, false, nil
612 }
613 if err != nil {
614 return item, false, err
615 }
616 item.Sessions, err = c.listTopicSessionsByRootKey(ctx, key, rootKey)
617 if err != nil {
618 return TopicRecord{Sessions: []SessionRecord{}}, false, err
619 }
620 // Tombstone overlay: topic rows may lag behind RemoveSession while the
621 // durable DELETE waits on locks or a short caller context.
622 if len(item.Sessions) == 0 {
623 return TopicRecord{Sessions: []SessionRecord{}}, false, nil
624 }
625 hydrateTopicDisplay(&item)
626 return item, true, nil
627 }
628
629 func topicRepresentativePath(sessions []SessionRecord) string {
630 if path := OrdinaryContinuePath(sessions, ""); path != "" {
631 return path
632 }
633 preferred := PreferredOrdinarySessionPaths(sessions)
634 best := SessionRecord{}
635 found := false
636 for _, session := range sessions {
637 path := strings.TrimSpace(session.Path)
638 _, isPreferred := preferred[path]
639 if !session.OrdinaryVisible && !isPreferred && (session.Recovered || session.RecoveryCopy) {
640 continue
641 }
642 if !found || recoveryRank(session) > recoveryRank(best) ||
643 (recoveryRank(session) == recoveryRank(best) && session.LastActivityAt > best.LastActivityAt) {
644 best = session
645 found = true
646 }
647 }
648 if found {
649 return best.Path
650 }
651 if len(sessions) > 0 {
652 return sessions[0].Path
653 }
654 return ""
655 }
656
657 // EncodeTopicCursor builds an exclusive ListTopics keyset cursor after the
658 // given topic position. Desktop post-filters recovery-only rows and needs the
659 // same cursor shape catalog.ListTopics emits.
660 func EncodeTopicCursor(pinned int, lastActivityAt int64, topicID string) string {
661 return encodeCursor(pageCursor{Pinned: pinned, Activity: lastActivityAt, TopicID: topicID})
662 }
663
664 func EncodeTopicCursorBound(pinned int, lastActivityAt int64, topicID, binding string) string {
665 return encodeCursor(pageCursor{Pinned: pinned, Activity: lastActivityAt, TopicID: topicID, Binding: binding})
666 }
667
668 // EncodeOrderedTopicCursor builds a cursor for a workspace with explicit
669 // manual topic ordering. A negative sortOrder places metadata-free/runtime
670 // topics after every explicitly ranked topic in the same pinned bucket.
671 func EncodeOrderedTopicCursor(pinned, sortOrder int, lastActivityAt int64, topicID string) string {
672 manualSortOrder := int64(sortOrder)
673 if sortOrder < 0 {
674 manualSortOrder = unrankedTopicSortOrder
675 }
676 return encodeCursor(pageCursor{
677 Pinned: pinned, ManualOrder: true, SortOrder: manualSortOrder,
678 Activity: lastActivityAt, TopicID: topicID,
679 })
680 }
681
682 func EncodeOrderedTopicCursorBound(pinned, sortOrder int, lastActivityAt int64, topicID, binding string) string {
683 manualSortOrder := int64(sortOrder)
684 if sortOrder < 0 {
685 manualSortOrder = unrankedTopicSortOrder
686 }
687 return encodeCursor(pageCursor{
688 Pinned: pinned, ManualOrder: true, SortOrder: manualSortOrder,
689 Activity: lastActivityAt, TopicID: topicID, Binding: binding,
690 })
691 }
692
693 func encodeCursor(cursor pageCursor) string {
694 b, _ := json.Marshal(cursor)
695 return base64.RawURLEncoding.EncodeToString(b)
696 }
697
698 func decodeCursor(encoded string) (*pageCursor, error) {
699 if strings.TrimSpace(encoded) == "" {
700 return nil, nil
701 }
702 b, err := base64.RawURLEncoding.DecodeString(encoded)
703 if err != nil {
704 return nil, fmt.Errorf("invalid session catalog cursor: %w", err)
705 }
706 var cursor pageCursor
707 if err := json.Unmarshal(b, &cursor); err != nil || cursor.TopicID == "" {
708 return nil, errors.New("invalid session catalog cursor")
709 }
710 return &cursor, nil
711 }
712
713 func timeFilterCutoff(filter string, now time.Time) int64 {
714 var duration time.Duration
715 value := strings.TrimSpace(strings.ToLower(filter))
716 switch value {
717 case "day", "24h":
718 duration = 24 * time.Hour
719 case "week", "7d":
720 duration = 7 * 24 * time.Hour
721 case "month", "30d":
722 duration = 30 * 24 * time.Hour
723 default:
724 parsed, err := time.ParseDuration(value)
725 if err != nil || parsed <= 0 {
726 return 0
727 }
728 duration = parsed
729 }
730 return now.Add(-duration).UnixMilli()
731 }
732
733 func (c *Catalog) Close(ctx context.Context) error {
734 if c == nil {
735 return nil
736 }
737 c.stopOnce.Do(func() {
738 if c.workerCancel != nil {
739 c.workerCancel()
740 }
741 close(c.stop)
742 go func() {
743 c.workers.Wait()
744 c.closeErr = c.db.Close()
745 c.statusMu.Lock()
746 c.status.State = StateClosed
747 c.statusMu.Unlock()
748 close(c.closeDone)
749 }()
750 })
751 select {
752 case <-c.closeDone:
753 return c.closeErr
754 case <-ctx.Done():
755 return ctx.Err()
756 }
757 }
758
758 lines GO