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 | |||
| 430 | 430 | ||
| 431 | 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 | 445 | ## Providers | |
| 434 | 446 | ||
| 435 | 447 | 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 | ## Providers | 445 | ## 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) { | |||
| 417 | 417 | row{"zot update", "download and install the latest release"}, | |
| 418 | 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 | 424 | section("telegram", | |
| 421 | 425 | row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"}, | |
| 422 | 426 | 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 { | |||
| 208 | 208 | if handled, err := runUpdateCommand(rawArgs, version); handled { | |
| 209 | 209 | return err | |
| 210 | 210 | } | |
| 211 | + | if handled, err := runSessionsCommand(rawArgs); handled { | |
| 212 | + | return err | |
| 213 | + | } | |
| 211 | 214 | if handled, err := runZotfileCommand(rawArgs, version); handled { | |
| 212 | 215 | return err | |
| 213 | 216 | } | |
| @@ -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 err | 209 | 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 err | 215 | 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 | + | } | |||