1package agent
2
3import (
4 "bufio"
5 "errors"
6 "fmt"
7 "io"
8 "io/fs"
9 "os"
10 "path/filepath"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/bnema/zut/packages/core"
17)
18
19type sessionsPruneOptions struct {
20 dryRun bool
21 all bool
22 yes bool
23 olderThan *sessionAge
24 cwd string
25}
26
27type sessionAge struct {
28 value int
29 unit string
30}
31
32type statPathFunc func(string) (fs.FileInfo, error)
33
34// runSessionsCommand dispatches standalone session management commands
35// before normal agent startup, so they do not require a provider or
36// credentials.
37func runSessionsCommand(rawArgs []string) (handled bool, err error) {
38 if len(rawArgs) == 0 || rawArgs[0] != "sessions" {
39 return false, nil
40 }
41 if len(rawArgs) == 1 {
42 printSessionsHelp(os.Stdout)
43 return true, nil
44 }
45 switch rawArgs[1] {
46 case "help", "-h", "--help":
47 printSessionsHelp(os.Stdout)
48 return true, nil
49 case "prune":
50 opts, err := parseSessionsPruneOptions(rawArgs[2:])
51 if errors.Is(err, errSessionsPruneHelp) {
52 printSessionsHelp(os.Stdout)
53 return true, nil
54 }
55 if err != nil {
56 printSessionsHelp(os.Stderr)
57 return true, err
58 }
59 return true, runSessionsPrune(SessionsPath(), opts, os.Stdin, os.Stdout, os.Stderr, os.Stat)
60 default:
61 printSessionsHelp(os.Stderr)
62 return true, fmt.Errorf("unknown sessions subcommand: %s", rawArgs[1])
63 }
64}
65
66func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
67 var opts sessionsPruneOptions
68 for idx := 0; idx < len(args); idx++ {
69 arg := args[idx]
70 switch arg {
71 case "--dry-run":
72 opts.dryRun = true
73 case "--all":
74 opts.all = true
75 case "-y", "--yes":
76 opts.yes = true
77 case "--older-than":
78 if idx+1 >= len(args) {
79 return opts, fmt.Errorf("--older-than requires an age")
80 }
81 idx++
82 age, err := parseSessionAge(args[idx])
83 if err != nil {
84 return opts, err
85 }
86 opts.olderThan = &age
87 case "--cwd":
88 if idx+1 >= len(args) {
89 return opts, fmt.Errorf("--cwd requires a path")
90 }
91 idx++
92 if strings.TrimSpace(args[idx]) == "" {
93 return opts, fmt.Errorf("--cwd requires a non-empty path")
94 }
95 opts.cwd = args[idx]
96 case "-h", "--help":
97 return opts, errSessionsPruneHelp
98 default:
99 return opts, fmt.Errorf("unknown sessions prune flag: %s", arg)
100 }
101 }
102 if opts.yes && !opts.all {
103 return opts, fmt.Errorf("--yes requires --all")
104 }
105 if opts.yes && opts.dryRun {
106 return opts, fmt.Errorf("--yes cannot be used with --dry-run")
107 }
108 if opts.cwd != "" && opts.olderThan == nil {
109 return opts, fmt.Errorf("--cwd requires --older-than")
110 }
111 return opts, nil
112}
113
114func parseSessionAge(value string) (sessionAge, error) {
115 value = strings.TrimSpace(value)
116 digitEnd := 0
117 for digitEnd < len(value) && value[digitEnd] >= '0' && value[digitEnd] <= '9' {
118 digitEnd++
119 }
120 if digitEnd == 0 || digitEnd == len(value) {
121 return sessionAge{}, fmt.Errorf("invalid age %q (use m, h, d, w, mo, or y)", value)
122 }
123 amount, err := strconv.ParseInt(value[:digitEnd], 10, 32)
124 if err != nil || amount <= 0 {
125 return sessionAge{}, fmt.Errorf("invalid age %q (value must be a positive whole number)", value)
126 }
127 unit := value[digitEnd:]
128 switch unit {
129 case "m", "h", "d", "w", "mo", "y":
130 default:
131 return sessionAge{}, fmt.Errorf("invalid age unit %q (use m, h, d, w, mo, or y)", unit)
132 }
133 age := sessionAge{value: int(amount), unit: unit}
134 if _, err := age.cutoff(time.Unix(0, 0)); err != nil {
135 return sessionAge{}, fmt.Errorf("invalid age %q: %w", value, err)
136 }
137 return age, nil
138}
139
140func (age sessionAge) cutoff(now time.Time) (time.Time, error) {
141 var unit time.Duration
142 switch age.unit {
143 case "m":
144 unit = time.Minute
145 case "h":
146 unit = time.Hour
147 case "d":
148 unit = 24 * time.Hour
149 case "w":
150 unit = 7 * 24 * time.Hour
151 case "mo":
152 return subtractCalendarAge(now, 0, age.value), nil
153 case "y":
154 return subtractCalendarAge(now, age.value, 0), nil
155 default:
156 return time.Time{}, fmt.Errorf("unsupported unit %q", age.unit)
157 }
158 if int64(age.value) > int64((time.Duration(1<<63-1))/unit) {
159 return time.Time{}, fmt.Errorf("duration is too large")
160 }
161 return now.Add(-time.Duration(age.value) * unit), nil
162}
163
164// subtractCalendarAge clamps the day to the target month's final day. This
165// makes one month before March 31 the end of February rather than early March.
166func subtractCalendarAge(now time.Time, years, months int) time.Time {
167 first := time.Date(now.Year(), now.Month(), 1, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location())
168 target := first.AddDate(-years, -months, 0)
169 lastDay := target.AddDate(0, 1, -1).Day()
170 day := now.Day()
171 if day > lastDay {
172 day = lastDay
173 }
174 return time.Date(target.Year(), target.Month(), day, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location())
175}
176
177var errSessionsPruneHelp = errors.New("sessions prune help requested")
178
179func printSessionsHelp(out io.Writer) {
180 fmt.Fprintln(out, `zut sessions - manage stored sessions
181
182usage:
183 zut sessions prune select missing directory groups
184 zut sessions prune --older-than 30d select sessions inactive for 30 days
185 zut sessions prune --older-than 1mo --cwd PATH
186 zut sessions prune --dry-run list matches without deleting
187 zut sessions prune --all [--yes] select every match, optionally without prompting
188
189Age units are m (minutes), h (hours), d (24-hour days), w (7-day weeks),
190mo (calendar months), and y (calendar years). Age uses last session activity.
191Without --older-than, only sessions whose recorded directory is missing match.
192A missing directory cannot be distinguished from some unmounted filesystems.
193Unreadable and malformed entries are preserved.`)
194}
195
196func runSessionsPrune(sessionsRoot string, opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error {
197 groups, scanIssues := core.ScanStoredSessionGroups(sessionsRoot)
198 for _, issue := range scanIssues {
199 fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err)
200 }
201
202 candidates := make([]core.StoredSessionGroup, 0, len(groups))
203 var cutoff time.Time
204 if opts.olderThan != nil {
205 var err error
206 cutoff, err = opts.olderThan.cutoff(time.Now())
207 if err != nil {
208 return err
209 }
210 cwd := opts.cwd
211 if cwd != "" {
212 cwd, err = filepath.Abs(cwd)
213 if err != nil {
214 return fmt.Errorf("resolve --cwd: %w", err)
215 }
216 cwd = filepath.Clean(cwd)
217 }
218 for _, group := range groups {
219 if cwd != "" && filepath.Clean(group.CWD) != cwd {
220 continue
221 }
222 matching := core.StoredSessionGroup{CWD: group.CWD}
223 for _, path := range group.Paths {
224 info, err := stat(path)
225 if err != nil {
226 fmt.Fprintf(errOut, "warning: preserving %s: %v\n", path, err)
227 continue
228 }
229 if info.ModTime().Before(cutoff) {
230 matching.Paths = append(matching.Paths, path)
231 matching.SizeBytes += info.Size()
232 }
233 }
234 if len(matching.Paths) > 0 {
235 candidates = append(candidates, matching)
236 }
237 }
238 } else {
239 for _, group := range groups {
240 if !filepath.IsAbs(group.CWD) {
241 fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD)
242 continue
243 }
244 _, err := stat(group.CWD)
245 switch {
246 case err == nil:
247 continue
248 case errors.Is(err, fs.ErrNotExist):
249 candidates = append(candidates, group)
250 default:
251 fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err)
252 }
253 }
254 }
255 if len(candidates) == 0 {
256 if opts.olderThan != nil {
257 fmt.Fprintln(out, "no sessions older than the requested age found")
258 } else {
259 fmt.Fprintln(out, "no stale session directories found")
260 }
261 return nil
262 }
263
264 if opts.olderThan != nil {
265 fmt.Fprintln(out, "session directories with inactive sessions:")
266 } else {
267 fmt.Fprintln(out, "missing session directories:")
268 }
269 for idx, group := range candidates {
270 fmt.Fprintf(out, " %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes)
271 }
272 if opts.dryRun {
273 if opts.all {
274 fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(candidates)), directoryCount(len(candidates)))
275 } else {
276 fmt.Fprintln(out, "dry run: nothing selected; add --all to preview deletion of every match")
277 }
278 return nil
279 }
280
281 reader := bufio.NewReader(in)
282 selected := make([]int, 0, len(candidates))
283 if opts.all {
284 for idx := range candidates {
285 selected = append(selected, idx)
286 }
287 } else {
288 fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ")
289 line, err := reader.ReadString('\n')
290 if err != nil && !errors.Is(err, io.EOF) {
291 return fmt.Errorf("read selection: %w", err)
292 }
293 selected, err = parseSessionSelection(line, len(candidates))
294 if err != nil {
295 return err
296 }
297 if len(selected) == 0 {
298 fmt.Fprintln(out, "no sessions deleted")
299 return nil
300 }
301 }
302
303 selectedSessions := 0
304 for _, idx := range selected {
305 selectedSessions += len(candidates[idx].Paths)
306 }
307 if !opts.yes {
308 fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected)))
309 line, err := reader.ReadString('\n')
310 if err != nil && !errors.Is(err, io.EOF) {
311 return fmt.Errorf("read confirmation: %w", err)
312 }
313 answer := strings.ToLower(strings.TrimSpace(line))
314 if answer != "y" && answer != "yes" {
315 fmt.Fprintln(out, "no sessions deleted")
316 return nil
317 }
318 }
319
320 deletedSessions := 0
321 deletedGroups := 0
322 var deleteErrors []error
323 for _, idx := range selected {
324 group := candidates[idx]
325 if opts.olderThan == nil {
326 // Recheck just before deleting: a directory that reappeared
327 // (remount, restore) must not lose its sessions.
328 _, err := stat(group.CWD)
329 if err == nil {
330 fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD)
331 continue
332 }
333 if !errors.Is(err, fs.ErrNotExist) {
334 fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err)
335 continue
336 }
337 }
338
339 groupDeleted := 0
340 for _, path := range group.Paths {
341 if opts.olderThan != nil {
342 info, err := stat(path)
343 if err != nil {
344 fmt.Fprintf(errOut, "warning: preserving %s after recheck: %v\n", path, err)
345 continue
346 }
347 if !info.ModTime().Before(cutoff) {
348 fmt.Fprintf(errOut, "warning: preserving %s because it now has recent activity\n", path)
349 continue
350 }
351 }
352 if err := core.DeleteSession(path); err != nil {
353 deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
354 continue
355 }
356 groupDeleted++
357 }
358 deletedSessions += groupDeleted
359 if groupDeleted == len(group.Paths) {
360 deletedGroups++
361 }
362 }
363 fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups))
364 return errors.Join(deleteErrors...)
365}
366
367func parseSessionSelection(input string, count int) ([]int, error) {
368 input = strings.ToLower(strings.TrimSpace(input))
369 switch input {
370 case "", "none", "n":
371 return nil, nil
372 case "all", "a":
373 selected := make([]int, count)
374 for idx := range selected {
375 selected[idx] = idx
376 }
377 return selected, nil
378 }
379
380 seen := make(map[int]bool)
381 for _, field := range strings.Split(input, ",") {
382 field = strings.TrimSpace(field)
383 if field == "" {
384 return nil, fmt.Errorf("invalid empty selection")
385 }
386 startText, endText, isRange := strings.Cut(field, "-")
387 start, err := strconv.Atoi(strings.TrimSpace(startText))
388 if err != nil {
389 return nil, fmt.Errorf("invalid selection %q", field)
390 }
391 end := start
392 if isRange {
393 end, err = strconv.Atoi(strings.TrimSpace(endText))
394 if err != nil || end < start {
395 return nil, fmt.Errorf("invalid selection range %q", field)
396 }
397 }
398 if start < 1 || end > count {
399 return nil, fmt.Errorf("selection %q is outside 1-%d", field, count)
400 }
401 for value := start; value <= end; value++ {
402 seen[value-1] = true
403 }
404 }
405 selected := make([]int, 0, len(seen))
406 for idx := range seen {
407 selected = append(selected, idx)
408 }
409 sort.Ints(selected)
410 return selected, nil
411}
412
413func totalSessions(groups []core.StoredSessionGroup) int {
414 total := 0
415 for _, group := range groups {
416 total += len(group.Paths)
417 }
418 return total
419}
420
421func sessionCount(count int) string {
422 if count == 1 {
423 return "1 session"
424 }
425 return fmt.Sprintf("%d sessions", count)
426}
427
428func directoryCount(count int) string {
429 if count == 1 {
430 return "1 directory"
431 }
432 return fmt.Sprintf("%d directories", count)
433}