1package core
2
3import (
4 "errors"
5 "fmt"
6 "io/fs"
7 "os"
8 "path/filepath"
9 "sort"
10 "strings"
11)
12
13// StoredSessionGroup describes sessions whose metadata names the same working
14// directory. Paths can span the default and named-agent session namespaces.
15type StoredSessionGroup struct {
16 CWD string
17 Paths []string
18 SizeBytes int64
19}
20
21// SessionScanIssue describes a stored entry that could not be safely
22// classified. Callers should report these entries and leave them untouched.
23type SessionScanIssue struct {
24 Path string
25 Err error
26}
27
28// ScanStoredSessionGroups finds JSONL sessions below sessionsRoot and groups
29// valid entries by the cwd recorded in their metadata. Symlinks and malformed
30// sessions are reported as issues rather than followed or deleted.
31func ScanStoredSessionGroups(sessionsRoot string) ([]StoredSessionGroup, []SessionScanIssue) {
32 byCWD := make(map[string]*StoredSessionGroup)
33 var issues []SessionScanIssue
34
35 // A symlinked sessions root would otherwise scan as an empty store
36 // with no hint; report it instead of silently finding nothing.
37 if info, err := os.Lstat(sessionsRoot); err == nil && info.Mode()&os.ModeSymlink != 0 {
38 return nil, []SessionScanIssue{{Path: sessionsRoot, Err: fmt.Errorf("sessions root is a symbolic link")}}
39 }
40 err := filepath.WalkDir(sessionsRoot, func(path string, entry fs.DirEntry, walkErr error) error {
41 if walkErr != nil {
42 if path == sessionsRoot && errors.Is(walkErr, fs.ErrNotExist) {
43 return nil
44 }
45 issues = append(issues, SessionScanIssue{Path: path, Err: walkErr})
46 if entry != nil && entry.IsDir() {
47 return fs.SkipDir
48 }
49 return nil
50 }
51 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
52 return nil
53 }
54 if entry.Type()&os.ModeSymlink != 0 {
55 issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("symbolic link is not a stored session")})
56 return nil
57 }
58 // Never block on or stream non-regular entries: a FIFO named
59 // *.jsonl would hang open(2) forever and a device node would
60 // return data without end. Report and preserve them instead.
61 if !entry.Type().IsRegular() {
62 issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("not a regular file")})
63 return nil
64 }
65
66 meta, err := readSessionMeta(path)
67 if err != nil {
68 issues = append(issues, SessionScanIssue{Path: path, Err: err})
69 return nil
70 }
71 cwd := strings.TrimSpace(meta.CWD)
72 if cwd == "" {
73 issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("session metadata has an empty cwd")})
74 return nil
75 }
76 // Tree-navigation branches stay visible through /session tree;
77 // pruning them here would silently remove sessions the picker
78 // deliberately hides from the flat list.
79 if meta.HideFromSessions {
80 return nil
81 }
82 info, err := entry.Info()
83 if err != nil {
84 issues = append(issues, SessionScanIssue{Path: path, Err: err})
85 return nil
86 }
87 group := byCWD[cwd]
88 if group == nil {
89 group = &StoredSessionGroup{CWD: cwd}
90 byCWD[cwd] = group
91 }
92 group.Paths = append(group.Paths, path)
93 group.SizeBytes += info.Size()
94 return nil
95 })
96 if err != nil && !os.IsNotExist(err) {
97 issues = append(issues, SessionScanIssue{Path: sessionsRoot, Err: err})
98 }
99
100 groups := make([]StoredSessionGroup, 0, len(byCWD))
101 for _, group := range byCWD {
102 sort.Strings(group.Paths)
103 groups = append(groups, *group)
104 }
105 sort.Slice(groups, func(i, j int) bool { return groups[i].CWD < groups[j].CWD })
106 sort.Slice(issues, func(i, j int) bool { return issues[i].Path < issues[j].Path })
107 return groups, issues
108}