detached 56b82c0

Add stale session pruning command

commit
56b82c064c9f77623314ac328d5c644dab31eb64
author
patriceckhart · · 2026-08-21 17:51 UTC
parent
a995321
signature
unsigned
refs
tag: v0.3.47
7 files · +632 -0
M README.md +12 -0
@@ -430,6 +430,18 @@ This is a guardrail against accidents, not a hard security boundary. If you need
430430
431431 Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.
432432
433+Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails for any reason other than "not found", which avoids deleting sessions for inaccessible or temporarily unavailable mounts. It also rechecks each selected directory immediately before deletion.
434+
435+For inspection or scripts, use these non-interactive options:
436+
437+```sh
438+zot sessions prune --dry-run # list stale groups without deleting
439+zot sessions prune --all # select every stale group, then confirm
440+zot sessions prune --all --yes # delete every stale group without prompting
441+```
442+
443+Malformed, unreadable, symlinked, and non-absolute session entries are reported and preserved. The scan includes normal sessions and named-agent sessions below `$ZOT_HOME/sessions/`; explicit session files stored elsewhere and swarm-agent state are not included.
444+
433445 ## Providers
434446
435447 zot's built-in provider catalog includes:
@@ -430,6 +430,18 @@ This is a guardrail against accidents, not a hard security boundary. If you need
430 430
431 Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.431 Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.
432 432
433+Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails for any reason other than "not found", which avoids deleting sessions for inaccessible or temporarily unavailable mounts. It also rechecks each selected directory immediately before deletion.
434+
435+For inspection or scripts, use these non-interactive options:
436+
437+```sh
438+zot sessions prune --dry-run # list stale groups without deleting
439+zot sessions prune --all # select every stale group, then confirm
440+zot sessions prune --all --yes # delete every stale group without prompting
441+```
442+
443+Malformed, unreadable, symlinked, and non-absolute session entries are reported and preserved. The scan includes normal sessions and named-agent sessions below `$ZOT_HOME/sessions/`; explicit session files stored elsewhere and swarm-agent state are not included.
444+
433 ## Providers445 ## Providers
434 446
435 zot's built-in provider catalog includes:447 zot's built-in provider catalog includes:
M packages/agent/args.go +4 -0
@@ -417,6 +417,10 @@ func printHelp(out *os.File, version string) {
417417 row{"zot update", "download and install the latest release"},
418418 row{"zot update --check", "show whether a new release is available"},
419419 )
420+ section("session management",
421+ row{"zot sessions prune", "select and delete sessions for directories that no longer exist"},
422+ row{"zot sessions prune --dry-run", "list stale session groups without deleting them"},
423+ )
420424 section("telegram",
421425 row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"},
422426 row{"zot telegram-bot run", "foreground bridge (ctrl+c to stop)"},
@@ -417,6 +417,10 @@ func printHelp(out *os.File, version string) {
417 row{"zot update", "download and install the latest release"},417 row{"zot update", "download and install the latest release"},
418 row{"zot update --check", "show whether a new release is available"},418 row{"zot update --check", "show whether a new release is available"},
419 )419 )
420+ section("session management",
421+ row{"zot sessions prune", "select and delete sessions for directories that no longer exist"},
422+ row{"zot sessions prune --dry-run", "list stale session groups without deleting them"},
423+ )
420 section("telegram",424 section("telegram",
421 row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"},425 row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"},
422 row{"zot telegram-bot run", "foreground bridge (ctrl+c to stop)"},426 row{"zot telegram-bot run", "foreground bridge (ctrl+c to stop)"},
M packages/agent/cli.go +3 -0
@@ -208,6 +208,9 @@ func Run(rawArgs []string, version string) error {
208208 if handled, err := runUpdateCommand(rawArgs, version); handled {
209209 return err
210210 }
211+ if handled, err := runSessionsCommand(rawArgs); handled {
212+ return err
213+ }
211214 if handled, err := runZotfileCommand(rawArgs, version); handled {
212215 return err
213216 }
@@ -208,6 +208,9 @@ func Run(rawArgs []string, version string) error {
208 if handled, err := runUpdateCommand(rawArgs, version); handled {208 if handled, err := runUpdateCommand(rawArgs, version); handled {
209 return err209 return err
210 }210 }
211+ if handled, err := runSessionsCommand(rawArgs); handled {
212+ return err
213+ }
211 if handled, err := runZotfileCommand(rawArgs, version); handled {214 if handled, err := runZotfileCommand(rawArgs, version); handled {
212 return err215 return err
213 }216 }
A packages/agent/sessionscmd.go +271 -0
@@ -0,0 +1,271 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "io/fs"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+ "strconv"
13+ "strings"
14+
15+ "github.com/patriceckhart/zot/packages/core"
16+)
17+
18+type sessionsPruneOptions struct {
19+ dryRun bool
20+ all bool
21+ yes bool
22+}
23+
24+type statPathFunc func(string) (fs.FileInfo, error)
25+
26+// runSessionsCommand dispatches standalone session management commands before
27+// normal agent startup, so they do not require a provider or credentials.
28+func runSessionsCommand(rawArgs []string) (handled bool, err error) {
29+ if len(rawArgs) == 0 || rawArgs[0] != "sessions" {
30+ return false, nil
31+ }
32+ if len(rawArgs) == 1 {
33+ printSessionsHelp(os.Stdout)
34+ return true, nil
35+ }
36+ switch rawArgs[1] {
37+ case "help", "-h", "--help":
38+ printSessionsHelp(os.Stdout)
39+ return true, nil
40+ case "prune":
41+ opts, err := parseSessionsPruneOptions(rawArgs[2:])
42+ if errors.Is(err, errSessionsPruneHelp) {
43+ printSessionsHelp(os.Stdout)
44+ return true, nil
45+ }
46+ if err != nil {
47+ printSessionsHelp(os.Stderr)
48+ return true, err
49+ }
50+ return true, runSessionsPrune(opts, os.Stdin, os.Stdout, os.Stderr, os.Stat)
51+ default:
52+ printSessionsHelp(os.Stderr)
53+ return true, fmt.Errorf("unknown sessions subcommand: %s", rawArgs[1])
54+ }
55+}
56+
57+func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
58+ var opts sessionsPruneOptions
59+ for _, arg := range args {
60+ switch arg {
61+ case "--dry-run":
62+ opts.dryRun = true
63+ case "--all":
64+ opts.all = true
65+ case "-y", "--yes":
66+ opts.yes = true
67+ case "-h", "--help":
68+ return opts, errSessionsPruneHelp
69+ default:
70+ return opts, fmt.Errorf("unknown sessions prune flag: %s", arg)
71+ }
72+ }
73+ if opts.yes && !opts.all {
74+ return opts, fmt.Errorf("--yes requires --all")
75+ }
76+ if opts.yes && opts.dryRun {
77+ return opts, fmt.Errorf("--yes cannot be used with --dry-run")
78+ }
79+ return opts, nil
80+}
81+
82+var errSessionsPruneHelp = errors.New("sessions prune help requested")
83+
84+func printSessionsHelp(out io.Writer) {
85+ fmt.Fprintln(out, `zot sessions - manage stored sessions
86+
87+usage:
88+ zot sessions prune select stale directory groups and confirm deletion
89+ zot sessions prune --dry-run list stale groups without deleting anything
90+ zot sessions prune --all select every stale group, then confirm
91+ zot sessions prune --all --yes delete every stale group without prompting
92+
93+A group is stale only when its recorded working directory no longer exists.
94+Unreadable, malformed, and temporarily inaccessible entries are preserved.`)
95+}
96+
97+func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error {
98+ groups, scanIssues := core.ScanStoredSessionGroups(SessionsPath())
99+ for _, issue := range scanIssues {
100+ fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err)
101+ }
102+
103+ stale := make([]core.StoredSessionGroup, 0, len(groups))
104+ for _, group := range groups {
105+ if !filepath.IsAbs(group.CWD) {
106+ fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD)
107+ continue
108+ }
109+ _, err := stat(group.CWD)
110+ switch {
111+ case err == nil:
112+ continue
113+ case errors.Is(err, fs.ErrNotExist):
114+ stale = append(stale, group)
115+ default:
116+ fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err)
117+ }
118+ }
119+ if len(stale) == 0 {
120+ fmt.Fprintln(out, "no stale session directories found")
121+ return nil
122+ }
123+
124+ fmt.Fprintln(out, "stale session directories:")
125+ for idx, group := range stale {
126+ fmt.Fprintf(out, " %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes)
127+ }
128+ if opts.dryRun {
129+ fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(stale)), directoryCount(len(stale)))
130+ return nil
131+ }
132+
133+ reader := bufio.NewReader(in)
134+ selected := make([]int, 0, len(stale))
135+ if opts.all {
136+ for idx := range stale {
137+ selected = append(selected, idx)
138+ }
139+ } else {
140+ fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ")
141+ line, err := reader.ReadString('\n')
142+ if err != nil && !errors.Is(err, io.EOF) {
143+ return fmt.Errorf("read selection: %w", err)
144+ }
145+ selected, err = parseSessionSelection(line, len(stale))
146+ if err != nil {
147+ return err
148+ }
149+ if len(selected) == 0 {
150+ fmt.Fprintln(out, "no sessions deleted")
151+ return nil
152+ }
153+ }
154+
155+ selectedSessions := 0
156+ for _, idx := range selected {
157+ selectedSessions += len(stale[idx].Paths)
158+ }
159+ if !opts.yes {
160+ fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected)))
161+ line, err := reader.ReadString('\n')
162+ if err != nil && !errors.Is(err, io.EOF) {
163+ return fmt.Errorf("read confirmation: %w", err)
164+ }
165+ answer := strings.ToLower(strings.TrimSpace(line))
166+ if answer != "y" && answer != "yes" {
167+ fmt.Fprintln(out, "no sessions deleted")
168+ return nil
169+ }
170+ }
171+
172+ deletedSessions := 0
173+ deletedGroups := 0
174+ var deleteErrors []error
175+ for _, idx := range selected {
176+ group := stale[idx]
177+ _, err := stat(group.CWD)
178+ if err == nil {
179+ fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD)
180+ continue
181+ }
182+ if !errors.Is(err, fs.ErrNotExist) {
183+ fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err)
184+ continue
185+ }
186+
187+ groupDeleted := 0
188+ for _, path := range group.Paths {
189+ if err := core.DeleteSession(path); err != nil {
190+ deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
191+ continue
192+ }
193+ groupDeleted++
194+ _ = os.Remove(filepath.Dir(path))
195+ }
196+ deletedSessions += groupDeleted
197+ if groupDeleted == len(group.Paths) {
198+ deletedGroups++
199+ }
200+ }
201+ fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups))
202+ return errors.Join(deleteErrors...)
203+}
204+
205+func parseSessionSelection(input string, count int) ([]int, error) {
206+ input = strings.ToLower(strings.TrimSpace(input))
207+ switch input {
208+ case "", "none", "n":
209+ return nil, nil
210+ case "all", "a":
211+ selected := make([]int, count)
212+ for idx := range selected {
213+ selected[idx] = idx
214+ }
215+ return selected, nil
216+ }
217+
218+ seen := make(map[int]bool)
219+ for _, field := range strings.Split(input, ",") {
220+ field = strings.TrimSpace(field)
221+ if field == "" {
222+ return nil, fmt.Errorf("invalid empty selection")
223+ }
224+ startText, endText, isRange := strings.Cut(field, "-")
225+ start, err := strconv.Atoi(strings.TrimSpace(startText))
226+ if err != nil {
227+ return nil, fmt.Errorf("invalid selection %q", field)
228+ }
229+ end := start
230+ if isRange {
231+ end, err = strconv.Atoi(strings.TrimSpace(endText))
232+ if err != nil || end < start {
233+ return nil, fmt.Errorf("invalid selection range %q", field)
234+ }
235+ }
236+ if start < 1 || end > count {
237+ return nil, fmt.Errorf("selection %q is outside 1-%d", field, count)
238+ }
239+ for value := start; value <= end; value++ {
240+ seen[value-1] = true
241+ }
242+ }
243+ selected := make([]int, 0, len(seen))
244+ for idx := range seen {
245+ selected = append(selected, idx)
246+ }
247+ sort.Ints(selected)
248+ return selected, nil
249+}
250+
251+func totalSessions(groups []core.StoredSessionGroup) int {
252+ total := 0
253+ for _, group := range groups {
254+ total += len(group.Paths)
255+ }
256+ return total
257+}
258+
259+func sessionCount(count int) string {
260+ if count == 1 {
261+ return "1 session"
262+ }
263+ return fmt.Sprintf("%d sessions", count)
264+}
265+
266+func directoryCount(count int) string {
267+ if count == 1 {
268+ return "1 directory"
269+ }
270+ return fmt.Sprintf("%d directories", count)
271+}
@@ -0,0 +1,271 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "io/fs"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+ "strconv"
13+ "strings"
14+
15+ "github.com/patriceckhart/zot/packages/core"
16+)
17+
18+type sessionsPruneOptions struct {
19+ dryRun bool
20+ all bool
21+ yes bool
22+}
23+
24+type statPathFunc func(string) (fs.FileInfo, error)
25+
26+// runSessionsCommand dispatches standalone session management commands before
27+// normal agent startup, so they do not require a provider or credentials.
28+func runSessionsCommand(rawArgs []string) (handled bool, err error) {
29+ if len(rawArgs) == 0 || rawArgs[0] != "sessions" {
30+ return false, nil
31+ }
32+ if len(rawArgs) == 1 {
33+ printSessionsHelp(os.Stdout)
34+ return true, nil
35+ }
36+ switch rawArgs[1] {
37+ case "help", "-h", "--help":
38+ printSessionsHelp(os.Stdout)
39+ return true, nil
40+ case "prune":
41+ opts, err := parseSessionsPruneOptions(rawArgs[2:])
42+ if errors.Is(err, errSessionsPruneHelp) {
43+ printSessionsHelp(os.Stdout)
44+ return true, nil
45+ }
46+ if err != nil {
47+ printSessionsHelp(os.Stderr)
48+ return true, err
49+ }
50+ return true, runSessionsPrune(opts, os.Stdin, os.Stdout, os.Stderr, os.Stat)
51+ default:
52+ printSessionsHelp(os.Stderr)
53+ return true, fmt.Errorf("unknown sessions subcommand: %s", rawArgs[1])
54+ }
55+}
56+
57+func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
58+ var opts sessionsPruneOptions
59+ for _, arg := range args {
60+ switch arg {
61+ case "--dry-run":
62+ opts.dryRun = true
63+ case "--all":
64+ opts.all = true
65+ case "-y", "--yes":
66+ opts.yes = true
67+ case "-h", "--help":
68+ return opts, errSessionsPruneHelp
69+ default:
70+ return opts, fmt.Errorf("unknown sessions prune flag: %s", arg)
71+ }
72+ }
73+ if opts.yes && !opts.all {
74+ return opts, fmt.Errorf("--yes requires --all")
75+ }
76+ if opts.yes && opts.dryRun {
77+ return opts, fmt.Errorf("--yes cannot be used with --dry-run")
78+ }
79+ return opts, nil
80+}
81+
82+var errSessionsPruneHelp = errors.New("sessions prune help requested")
83+
84+func printSessionsHelp(out io.Writer) {
85+ fmt.Fprintln(out, `zot sessions - manage stored sessions
86+
87+usage:
88+ zot sessions prune select stale directory groups and confirm deletion
89+ zot sessions prune --dry-run list stale groups without deleting anything
90+ zot sessions prune --all select every stale group, then confirm
91+ zot sessions prune --all --yes delete every stale group without prompting
92+
93+A group is stale only when its recorded working directory no longer exists.
94+Unreadable, malformed, and temporarily inaccessible entries are preserved.`)
95+}
96+
97+func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error {
98+ groups, scanIssues := core.ScanStoredSessionGroups(SessionsPath())
99+ for _, issue := range scanIssues {
100+ fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err)
101+ }
102+
103+ stale := make([]core.StoredSessionGroup, 0, len(groups))
104+ for _, group := range groups {
105+ if !filepath.IsAbs(group.CWD) {
106+ fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD)
107+ continue
108+ }
109+ _, err := stat(group.CWD)
110+ switch {
111+ case err == nil:
112+ continue
113+ case errors.Is(err, fs.ErrNotExist):
114+ stale = append(stale, group)
115+ default:
116+ fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err)
117+ }
118+ }
119+ if len(stale) == 0 {
120+ fmt.Fprintln(out, "no stale session directories found")
121+ return nil
122+ }
123+
124+ fmt.Fprintln(out, "stale session directories:")
125+ for idx, group := range stale {
126+ fmt.Fprintf(out, " %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes)
127+ }
128+ if opts.dryRun {
129+ fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(stale)), directoryCount(len(stale)))
130+ return nil
131+ }
132+
133+ reader := bufio.NewReader(in)
134+ selected := make([]int, 0, len(stale))
135+ if opts.all {
136+ for idx := range stale {
137+ selected = append(selected, idx)
138+ }
139+ } else {
140+ fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ")
141+ line, err := reader.ReadString('\n')
142+ if err != nil && !errors.Is(err, io.EOF) {
143+ return fmt.Errorf("read selection: %w", err)
144+ }
145+ selected, err = parseSessionSelection(line, len(stale))
146+ if err != nil {
147+ return err
148+ }
149+ if len(selected) == 0 {
150+ fmt.Fprintln(out, "no sessions deleted")
151+ return nil
152+ }
153+ }
154+
155+ selectedSessions := 0
156+ for _, idx := range selected {
157+ selectedSessions += len(stale[idx].Paths)
158+ }
159+ if !opts.yes {
160+ fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected)))
161+ line, err := reader.ReadString('\n')
162+ if err != nil && !errors.Is(err, io.EOF) {
163+ return fmt.Errorf("read confirmation: %w", err)
164+ }
165+ answer := strings.ToLower(strings.TrimSpace(line))
166+ if answer != "y" && answer != "yes" {
167+ fmt.Fprintln(out, "no sessions deleted")
168+ return nil
169+ }
170+ }
171+
172+ deletedSessions := 0
173+ deletedGroups := 0
174+ var deleteErrors []error
175+ for _, idx := range selected {
176+ group := stale[idx]
177+ _, err := stat(group.CWD)
178+ if err == nil {
179+ fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD)
180+ continue
181+ }
182+ if !errors.Is(err, fs.ErrNotExist) {
183+ fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err)
184+ continue
185+ }
186+
187+ groupDeleted := 0
188+ for _, path := range group.Paths {
189+ if err := core.DeleteSession(path); err != nil {
190+ deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
191+ continue
192+ }
193+ groupDeleted++
194+ _ = os.Remove(filepath.Dir(path))
195+ }
196+ deletedSessions += groupDeleted
197+ if groupDeleted == len(group.Paths) {
198+ deletedGroups++
199+ }
200+ }
201+ fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups))
202+ return errors.Join(deleteErrors...)
203+}
204+
205+func parseSessionSelection(input string, count int) ([]int, error) {
206+ input = strings.ToLower(strings.TrimSpace(input))
207+ switch input {
208+ case "", "none", "n":
209+ return nil, nil
210+ case "all", "a":
211+ selected := make([]int, count)
212+ for idx := range selected {
213+ selected[idx] = idx
214+ }
215+ return selected, nil
216+ }
217+
218+ seen := make(map[int]bool)
219+ for _, field := range strings.Split(input, ",") {
220+ field = strings.TrimSpace(field)
221+ if field == "" {
222+ return nil, fmt.Errorf("invalid empty selection")
223+ }
224+ startText, endText, isRange := strings.Cut(field, "-")
225+ start, err := strconv.Atoi(strings.TrimSpace(startText))
226+ if err != nil {
227+ return nil, fmt.Errorf("invalid selection %q", field)
228+ }
229+ end := start
230+ if isRange {
231+ end, err = strconv.Atoi(strings.TrimSpace(endText))
232+ if err != nil || end < start {
233+ return nil, fmt.Errorf("invalid selection range %q", field)
234+ }
235+ }
236+ if start < 1 || end > count {
237+ return nil, fmt.Errorf("selection %q is outside 1-%d", field, count)
238+ }
239+ for value := start; value <= end; value++ {
240+ seen[value-1] = true
241+ }
242+ }
243+ selected := make([]int, 0, len(seen))
244+ for idx := range seen {
245+ selected = append(selected, idx)
246+ }
247+ sort.Ints(selected)
248+ return selected, nil
249+}
250+
251+func totalSessions(groups []core.StoredSessionGroup) int {
252+ total := 0
253+ for _, group := range groups {
254+ total += len(group.Paths)
255+ }
256+ return total
257+}
258+
259+func sessionCount(count int) string {
260+ if count == 1 {
261+ return "1 session"
262+ }
263+ return fmt.Sprintf("%d sessions", count)
264+}
265+
266+func directoryCount(count int) string {
267+ if count == 1 {
268+ return "1 directory"
269+ }
270+ return fmt.Sprintf("%d directories", count)
271+}
A packages/agent/sessionscmd_test.go +171 -0
@@ -0,0 +1,171 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
14+)
15+
16+func TestParseSessionSelection(t *testing.T) {
17+ tests := []struct {
18+ input string
19+ count int
20+ want []int
21+ wantErr bool
22+ }{
23+ {input: "", count: 3},
24+ {input: "none", count: 3},
25+ {input: "all", count: 3, want: []int{0, 1, 2}},
26+ {input: "1,3-4,3", count: 4, want: []int{0, 2, 3}},
27+ {input: "0", count: 3, wantErr: true},
28+ {input: "4", count: 3, wantErr: true},
29+ {input: "3-2", count: 3, wantErr: true},
30+ {input: "1,,2", count: 3, wantErr: true},
31+ }
32+ for _, tt := range tests {
33+ t.Run(tt.input, func(t *testing.T) {
34+ got, err := parseSessionSelection(tt.input, tt.count)
35+ if (err != nil) != tt.wantErr {
36+ t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
37+ }
38+ if !reflect.DeepEqual(got, tt.want) {
39+ t.Fatalf("selection = %v, want %v", got, tt.want)
40+ }
41+ })
42+ }
43+}
44+
45+func TestSessionsPruneDryRunPreservesSessions(t *testing.T) {
46+ home := t.TempDir()
47+ t.Setenv("ZOT_HOME", home)
48+ missing := filepath.Join(home, "missing-project")
49+ existing := t.TempDir()
50+ missingPath := createPruneTestSession(t, home, missing)
51+ existingPath := createPruneTestSession(t, home, existing)
52+
53+ var out, errOut bytes.Buffer
54+ if err := runSessionsPrune(sessionsPruneOptions{dryRun: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
55+ t.Fatal(err)
56+ }
57+ if !strings.Contains(out.String(), missing) || strings.Contains(out.String(), existing) {
58+ t.Fatalf("output = %q, want only missing cwd", out.String())
59+ }
60+ if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
61+ t.Fatalf("output = %q, want dry-run summary", out.String())
62+ }
63+ for _, path := range []string{missingPath, existingPath} {
64+ if _, err := os.Stat(path); err != nil {
65+ t.Fatalf("dry run removed %s: %v", path, err)
66+ }
67+ }
68+}
69+
70+func TestSessionsPruneInteractiveDeletesSelectedGroup(t *testing.T) {
71+ home := t.TempDir()
72+ t.Setenv("ZOT_HOME", home)
73+ firstCWD := filepath.Join(home, "gone-a")
74+ secondCWD := filepath.Join(home, "gone-b")
75+ first := createPruneTestSession(t, home, firstCWD)
76+ second := createPruneTestSession(t, home, secondCWD)
77+
78+ var out, errOut bytes.Buffer
79+ if err := runSessionsPrune(sessionsPruneOptions{}, strings.NewReader("2\ny\n"), &out, &errOut, os.Stat); err != nil {
80+ t.Fatal(err)
81+ }
82+ if _, err := os.Stat(first); err != nil {
83+ t.Fatalf("unselected session was removed: %v", err)
84+ }
85+ if _, err := os.Stat(second); !os.IsNotExist(err) {
86+ t.Fatalf("selected session still exists or stat failed unexpectedly: %v", err)
87+ }
88+ if !strings.Contains(out.String(), "deleted 1 session from 1 directory") {
89+ t.Fatalf("output = %q", out.String())
90+ }
91+}
92+
93+func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing.T) {
94+ home := t.TempDir()
95+ t.Setenv("ZOT_HOME", home)
96+ cwd := filepath.Join(home, "unavailable-mount")
97+ path := createPruneTestSession(t, home, cwd)
98+ stat := func(string) (fs.FileInfo, error) { return nil, fs.ErrPermission }
99+
100+ var out, errOut bytes.Buffer
101+ if err := runSessionsPrune(sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
102+ t.Fatal(err)
103+ }
104+ if _, err := os.Stat(path); err != nil {
105+ t.Fatalf("session was removed after inconclusive stat: %v", err)
106+ }
107+ if !strings.Contains(errOut.String(), "permission denied") {
108+ t.Fatalf("stderr = %q, want stat warning", errOut.String())
109+ }
110+ if !strings.Contains(out.String(), "no stale session directories found") {
111+ t.Fatalf("output = %q", out.String())
112+ }
113+}
114+
115+func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) {
116+ home := t.TempDir()
117+ t.Setenv("ZOT_HOME", home)
118+ cwd := filepath.Join(home, "temporarily-missing")
119+ path := createPruneTestSession(t, home, cwd)
120+ calls := 0
121+ stat := func(path string) (fs.FileInfo, error) {
122+ calls++
123+ if calls == 1 {
124+ return nil, fs.ErrNotExist
125+ }
126+ return nil, nil
127+ }
128+
129+ var out, errOut bytes.Buffer
130+ err := runSessionsPrune(sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat)
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ if _, err := os.Stat(path); err != nil {
135+ t.Fatalf("session was removed after cwd reappeared: %v", err)
136+ }
137+ if !strings.Contains(errOut.String(), "directory now exists") {
138+ t.Fatalf("stderr = %q, want recheck warning", errOut.String())
139+ }
140+}
141+
142+func TestParseSessionsPruneOptionsRequiresAllForYes(t *testing.T) {
143+ if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil {
144+ t.Fatal("--yes without --all was accepted")
145+ }
146+ opts, err := parseSessionsPruneOptions([]string{"--all", "--yes"})
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ if !opts.all || !opts.yes {
151+ t.Fatalf("options = %#v", opts)
152+ }
153+}
154+
155+func createPruneTestSession(t *testing.T, root, cwd string) string {
156+ t.Helper()
157+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ if err := session.AppendMessage(provider.Message{
162+ Role: provider.RoleUser,
163+ Content: []provider.Content{provider.TextBlock{Text: "hello"}},
164+ }); err != nil {
165+ t.Fatal(err)
166+ }
167+ if err := session.Close(); err != nil {
168+ t.Fatal(err)
169+ }
170+ return session.Path
171+}
@@ -0,0 +1,171 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
14+)
15+
16+func TestParseSessionSelection(t *testing.T) {
17+ tests := []struct {
18+ input string
19+ count int
20+ want []int
21+ wantErr bool
22+ }{
23+ {input: "", count: 3},
24+ {input: "none", count: 3},
25+ {input: "all", count: 3, want: []int{0, 1, 2}},
26+ {input: "1,3-4,3", count: 4, want: []int{0, 2, 3}},
27+ {input: "0", count: 3, wantErr: true},
28+ {input: "4", count: 3, wantErr: true},
29+ {input: "3-2", count: 3, wantErr: true},
30+ {input: "1,,2", count: 3, wantErr: true},
31+ }
32+ for _, tt := range tests {
33+ t.Run(tt.input, func(t *testing.T) {
34+ got, err := parseSessionSelection(tt.input, tt.count)
35+ if (err != nil) != tt.wantErr {
36+ t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
37+ }
38+ if !reflect.DeepEqual(got, tt.want) {
39+ t.Fatalf("selection = %v, want %v", got, tt.want)
40+ }
41+ })
42+ }
43+}
44+
45+func TestSessionsPruneDryRunPreservesSessions(t *testing.T) {
46+ home := t.TempDir()
47+ t.Setenv("ZOT_HOME", home)
48+ missing := filepath.Join(home, "missing-project")
49+ existing := t.TempDir()
50+ missingPath := createPruneTestSession(t, home, missing)
51+ existingPath := createPruneTestSession(t, home, existing)
52+
53+ var out, errOut bytes.Buffer
54+ if err := runSessionsPrune(sessionsPruneOptions{dryRun: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
55+ t.Fatal(err)
56+ }
57+ if !strings.Contains(out.String(), missing) || strings.Contains(out.String(), existing) {
58+ t.Fatalf("output = %q, want only missing cwd", out.String())
59+ }
60+ if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
61+ t.Fatalf("output = %q, want dry-run summary", out.String())
62+ }
63+ for _, path := range []string{missingPath, existingPath} {
64+ if _, err := os.Stat(path); err != nil {
65+ t.Fatalf("dry run removed %s: %v", path, err)
66+ }
67+ }
68+}
69+
70+func TestSessionsPruneInteractiveDeletesSelectedGroup(t *testing.T) {
71+ home := t.TempDir()
72+ t.Setenv("ZOT_HOME", home)
73+ firstCWD := filepath.Join(home, "gone-a")
74+ secondCWD := filepath.Join(home, "gone-b")
75+ first := createPruneTestSession(t, home, firstCWD)
76+ second := createPruneTestSession(t, home, secondCWD)
77+
78+ var out, errOut bytes.Buffer
79+ if err := runSessionsPrune(sessionsPruneOptions{}, strings.NewReader("2\ny\n"), &out, &errOut, os.Stat); err != nil {
80+ t.Fatal(err)
81+ }
82+ if _, err := os.Stat(first); err != nil {
83+ t.Fatalf("unselected session was removed: %v", err)
84+ }
85+ if _, err := os.Stat(second); !os.IsNotExist(err) {
86+ t.Fatalf("selected session still exists or stat failed unexpectedly: %v", err)
87+ }
88+ if !strings.Contains(out.String(), "deleted 1 session from 1 directory") {
89+ t.Fatalf("output = %q", out.String())
90+ }
91+}
92+
93+func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing.T) {
94+ home := t.TempDir()
95+ t.Setenv("ZOT_HOME", home)
96+ cwd := filepath.Join(home, "unavailable-mount")
97+ path := createPruneTestSession(t, home, cwd)
98+ stat := func(string) (fs.FileInfo, error) { return nil, fs.ErrPermission }
99+
100+ var out, errOut bytes.Buffer
101+ if err := runSessionsPrune(sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
102+ t.Fatal(err)
103+ }
104+ if _, err := os.Stat(path); err != nil {
105+ t.Fatalf("session was removed after inconclusive stat: %v", err)
106+ }
107+ if !strings.Contains(errOut.String(), "permission denied") {
108+ t.Fatalf("stderr = %q, want stat warning", errOut.String())
109+ }
110+ if !strings.Contains(out.String(), "no stale session directories found") {
111+ t.Fatalf("output = %q", out.String())
112+ }
113+}
114+
115+func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) {
116+ home := t.TempDir()
117+ t.Setenv("ZOT_HOME", home)
118+ cwd := filepath.Join(home, "temporarily-missing")
119+ path := createPruneTestSession(t, home, cwd)
120+ calls := 0
121+ stat := func(path string) (fs.FileInfo, error) {
122+ calls++
123+ if calls == 1 {
124+ return nil, fs.ErrNotExist
125+ }
126+ return nil, nil
127+ }
128+
129+ var out, errOut bytes.Buffer
130+ err := runSessionsPrune(sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat)
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ if _, err := os.Stat(path); err != nil {
135+ t.Fatalf("session was removed after cwd reappeared: %v", err)
136+ }
137+ if !strings.Contains(errOut.String(), "directory now exists") {
138+ t.Fatalf("stderr = %q, want recheck warning", errOut.String())
139+ }
140+}
141+
142+func TestParseSessionsPruneOptionsRequiresAllForYes(t *testing.T) {
143+ if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil {
144+ t.Fatal("--yes without --all was accepted")
145+ }
146+ opts, err := parseSessionsPruneOptions([]string{"--all", "--yes"})
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ if !opts.all || !opts.yes {
151+ t.Fatalf("options = %#v", opts)
152+ }
153+}
154+
155+func createPruneTestSession(t *testing.T, root, cwd string) string {
156+ t.Helper()
157+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ if err := session.AppendMessage(provider.Message{
162+ Role: provider.RoleUser,
163+ Content: []provider.Content{provider.TextBlock{Text: "hello"}},
164+ }); err != nil {
165+ t.Fatal(err)
166+ }
167+ if err := session.Close(); err != nil {
168+ t.Fatal(err)
169+ }
170+ return session.Path
171+}
A packages/core/session_prune.go +90 -0
@@ -0,0 +1,90 @@
1+package core
2+
3+import (
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.
15+type 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.
23+type 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.
31+func ScanStoredSessionGroups(sessionsRoot string) ([]StoredSessionGroup, []SessionScanIssue) {
32+ byCWD := make(map[string]*StoredSessionGroup)
33+ var issues []SessionScanIssue
34+
35+ err := filepath.WalkDir(sessionsRoot, func(path string, entry fs.DirEntry, walkErr error) error {
36+ if walkErr != nil {
37+ if path == sessionsRoot && errors.Is(walkErr, fs.ErrNotExist) {
38+ return nil
39+ }
40+ issues = append(issues, SessionScanIssue{Path: path, Err: walkErr})
41+ if entry != nil && entry.IsDir() {
42+ return fs.SkipDir
43+ }
44+ return nil
45+ }
46+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
47+ return nil
48+ }
49+ if entry.Type()&os.ModeSymlink != 0 {
50+ issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("symbolic link is not a stored session")})
51+ return nil
52+ }
53+
54+ meta, err := readSessionMeta(path)
55+ if err != nil {
56+ issues = append(issues, SessionScanIssue{Path: path, Err: err})
57+ return nil
58+ }
59+ cwd := strings.TrimSpace(meta.CWD)
60+ if cwd == "" {
61+ issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("session metadata has an empty cwd")})
62+ return nil
63+ }
64+ info, err := entry.Info()
65+ if err != nil {
66+ issues = append(issues, SessionScanIssue{Path: path, Err: err})
67+ return nil
68+ }
69+ group := byCWD[cwd]
70+ if group == nil {
71+ group = &StoredSessionGroup{CWD: cwd}
72+ byCWD[cwd] = group
73+ }
74+ group.Paths = append(group.Paths, path)
75+ group.SizeBytes += info.Size()
76+ return nil
77+ })
78+ if err != nil && !os.IsNotExist(err) {
79+ issues = append(issues, SessionScanIssue{Path: sessionsRoot, Err: err})
80+ }
81+
82+ groups := make([]StoredSessionGroup, 0, len(byCWD))
83+ for _, group := range byCWD {
84+ sort.Strings(group.Paths)
85+ groups = append(groups, *group)
86+ }
87+ sort.Slice(groups, func(i, j int) bool { return groups[i].CWD < groups[j].CWD })
88+ sort.Slice(issues, func(i, j int) bool { return issues[i].Path < issues[j].Path })
89+ return groups, issues
90+}
@@ -0,0 +1,90 @@
1+package core
2+
3+import (
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.
15+type 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.
23+type 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.
31+func ScanStoredSessionGroups(sessionsRoot string) ([]StoredSessionGroup, []SessionScanIssue) {
32+ byCWD := make(map[string]*StoredSessionGroup)
33+ var issues []SessionScanIssue
34+
35+ err := filepath.WalkDir(sessionsRoot, func(path string, entry fs.DirEntry, walkErr error) error {
36+ if walkErr != nil {
37+ if path == sessionsRoot && errors.Is(walkErr, fs.ErrNotExist) {
38+ return nil
39+ }
40+ issues = append(issues, SessionScanIssue{Path: path, Err: walkErr})
41+ if entry != nil && entry.IsDir() {
42+ return fs.SkipDir
43+ }
44+ return nil
45+ }
46+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
47+ return nil
48+ }
49+ if entry.Type()&os.ModeSymlink != 0 {
50+ issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("symbolic link is not a stored session")})
51+ return nil
52+ }
53+
54+ meta, err := readSessionMeta(path)
55+ if err != nil {
56+ issues = append(issues, SessionScanIssue{Path: path, Err: err})
57+ return nil
58+ }
59+ cwd := strings.TrimSpace(meta.CWD)
60+ if cwd == "" {
61+ issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("session metadata has an empty cwd")})
62+ return nil
63+ }
64+ info, err := entry.Info()
65+ if err != nil {
66+ issues = append(issues, SessionScanIssue{Path: path, Err: err})
67+ return nil
68+ }
69+ group := byCWD[cwd]
70+ if group == nil {
71+ group = &StoredSessionGroup{CWD: cwd}
72+ byCWD[cwd] = group
73+ }
74+ group.Paths = append(group.Paths, path)
75+ group.SizeBytes += info.Size()
76+ return nil
77+ })
78+ if err != nil && !os.IsNotExist(err) {
79+ issues = append(issues, SessionScanIssue{Path: sessionsRoot, Err: err})
80+ }
81+
82+ groups := make([]StoredSessionGroup, 0, len(byCWD))
83+ for _, group := range byCWD {
84+ sort.Strings(group.Paths)
85+ groups = append(groups, *group)
86+ }
87+ sort.Slice(groups, func(i, j int) bool { return groups[i].CWD < groups[j].CWD })
88+ sort.Slice(issues, func(i, j int) bool { return issues[i].Path < issues[j].Path })
89+ return groups, issues
90+}
A packages/core/session_prune_test.go +81 -0
@@ -0,0 +1,81 @@
1+package core
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+func TestScanStoredSessionGroupsIncludesDefaultAndAgentSessions(t *testing.T) {
12+ home := t.TempDir()
13+ cwdA := filepath.Join(home, "missing-a")
14+ cwdB := filepath.Join(home, "missing-b")
15+
16+ create := func(root, cwd, text string) string {
17+ t.Helper()
18+ session, err := NewSession(root, cwd, "test", "test-model", "test-version")
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := session.AppendMessage(provider.Message{
23+ Role: provider.RoleUser,
24+ Content: []provider.Content{provider.TextBlock{Text: text}},
25+ }); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := session.Close(); err != nil {
29+ t.Fatal(err)
30+ }
31+ return session.Path
32+ }
33+
34+ first := create(home, cwdA, "one")
35+ second := create(home, cwdA, "two")
36+ agentRoot := filepath.Join(home, "sessions", "agents", "reviewer")
37+ third := create(agentRoot, cwdA, "agent")
38+ _ = create(home, cwdB, "other")
39+ bad := filepath.Join(home, "sessions", "bad.jsonl")
40+ if err := os.WriteFile(bad, []byte("not json\n"), 0o600); err != nil {
41+ t.Fatal(err)
42+ }
43+
44+ groups, issues := ScanStoredSessionGroups(filepath.Join(home, "sessions"))
45+ if len(groups) != 2 {
46+ t.Fatalf("groups = %#v, want 2", groups)
47+ }
48+ if groups[0].CWD != cwdA || len(groups[0].Paths) != 3 {
49+ t.Fatalf("first group = %#v, want three sessions for %q", groups[0], cwdA)
50+ }
51+ for _, want := range []string{first, second, third} {
52+ if !pruneTestContainsString(groups[0].Paths, want) {
53+ t.Errorf("group paths %q do not contain %q", groups[0].Paths, want)
54+ }
55+ }
56+ if groups[0].SizeBytes <= 0 {
57+ t.Fatalf("group summary missing size: %#v", groups[0])
58+ }
59+ if groups[1].CWD != cwdB || len(groups[1].Paths) != 1 {
60+ t.Fatalf("second group = %#v", groups[1])
61+ }
62+ if len(issues) != 1 || issues[0].Path != bad {
63+ t.Fatalf("issues = %#v, want malformed session issue", issues)
64+ }
65+}
66+
67+func TestScanStoredSessionGroupsMissingRootIsEmpty(t *testing.T) {
68+ groups, issues := ScanStoredSessionGroups(filepath.Join(t.TempDir(), "missing"))
69+ if len(groups) != 0 || len(issues) != 0 {
70+ t.Fatalf("groups = %#v, issues = %#v, want empty", groups, issues)
71+ }
72+}
73+
74+func pruneTestContainsString(values []string, want string) bool {
75+ for _, value := range values {
76+ if value == want {
77+ return true
78+ }
79+ }
80+ return false
81+}
@@ -0,0 +1,81 @@
1+package core
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+func TestScanStoredSessionGroupsIncludesDefaultAndAgentSessions(t *testing.T) {
12+ home := t.TempDir()
13+ cwdA := filepath.Join(home, "missing-a")
14+ cwdB := filepath.Join(home, "missing-b")
15+
16+ create := func(root, cwd, text string) string {
17+ t.Helper()
18+ session, err := NewSession(root, cwd, "test", "test-model", "test-version")
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := session.AppendMessage(provider.Message{
23+ Role: provider.RoleUser,
24+ Content: []provider.Content{provider.TextBlock{Text: text}},
25+ }); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := session.Close(); err != nil {
29+ t.Fatal(err)
30+ }
31+ return session.Path
32+ }
33+
34+ first := create(home, cwdA, "one")
35+ second := create(home, cwdA, "two")
36+ agentRoot := filepath.Join(home, "sessions", "agents", "reviewer")
37+ third := create(agentRoot, cwdA, "agent")
38+ _ = create(home, cwdB, "other")
39+ bad := filepath.Join(home, "sessions", "bad.jsonl")
40+ if err := os.WriteFile(bad, []byte("not json\n"), 0o600); err != nil {
41+ t.Fatal(err)
42+ }
43+
44+ groups, issues := ScanStoredSessionGroups(filepath.Join(home, "sessions"))
45+ if len(groups) != 2 {
46+ t.Fatalf("groups = %#v, want 2", groups)
47+ }
48+ if groups[0].CWD != cwdA || len(groups[0].Paths) != 3 {
49+ t.Fatalf("first group = %#v, want three sessions for %q", groups[0], cwdA)
50+ }
51+ for _, want := range []string{first, second, third} {
52+ if !pruneTestContainsString(groups[0].Paths, want) {
53+ t.Errorf("group paths %q do not contain %q", groups[0].Paths, want)
54+ }
55+ }
56+ if groups[0].SizeBytes <= 0 {
57+ t.Fatalf("group summary missing size: %#v", groups[0])
58+ }
59+ if groups[1].CWD != cwdB || len(groups[1].Paths) != 1 {
60+ t.Fatalf("second group = %#v", groups[1])
61+ }
62+ if len(issues) != 1 || issues[0].Path != bad {
63+ t.Fatalf("issues = %#v, want malformed session issue", issues)
64+ }
65+}
66+
67+func TestScanStoredSessionGroupsMissingRootIsEmpty(t *testing.T) {
68+ groups, issues := ScanStoredSessionGroups(filepath.Join(t.TempDir(), "missing"))
69+ if len(groups) != 0 || len(issues) != 0 {
70+ t.Fatalf("groups = %#v, issues = %#v, want empty", groups, issues)
71+ }
72+}
73+
74+func pruneTestContainsString(values []string, want string) bool {
75+ for _, value := range values {
76+ if value == want {
77+ return true
78+ }
79+ }
80+ return false
81+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close