Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion internal/cmd/helpcmd/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,9 @@ func buildManifest() manifest {
{Command: "retask sandbox session update", Description: "Partial update a session", Flags: []string{"--name", "--seed-nrn", "--seed-prompt"}, Example: "retask sandbox session update <id> --name \"My Session\""},
{Command: "retask sandbox session stop", Description: "Stop a session", Example: "retask sandbox session stop <session-id>"},
{Command: "retask sandbox session delete", Description: "Delete a session", Example: "retask sandbox session delete <session-id>"},
{Command: "retask sandbox connect", Description: "Connect this machine as a Private VM sandbox (long-running). Logs go to the TUI (stderr when headless) and to retask.log in the current folder, which rotates into retask.log.1 ... retask.log.N", Flags: []string{"--mode", "--auto-open", "--no-auto-respond", "--session-buffer", "--log-file", "--no-log-file", "--log-max-size", "--log-backups", "--no-log-path"}, Example: "retask sandbox connect <sandbox-id>"},
{Command: "retask sandbox connect", Description: "Connect this machine as a Private VM sandbox (long-running). Logs go to the TUI (stderr when headless) and to retask.log in the current folder, which rotates into retask.log.1 ... retask.log.N. Session folders are created in the current directory and recorded in sandbox_<sandbox-id>.json. Stopping a session, the sandbox, or this command leaves folders on disk; --retention deletes those older than its window (checked hourly), and \"off\" disables it. Live sessions are never deleted", Flags: []string{"--mode", "--auto-open", "--no-auto-respond", "--retention", "--session-buffer", "--log-file", "--no-log-file", "--log-max-size", "--log-backups", "--no-log-path"}, Example: "retask sandbox connect <sandbox-id> --retention 30d"},
{Command: "retask sandbox attach", Description: "Attach terminal to a running local session", Example: "retask sandbox attach <session-id>"},
{Command: "retask sandbox cleanup", Description: "Delete session folders left behind by stopped sessions, in the current directory. Only folders recorded in a sandbox_<sandbox-id>.json session log are considered; anything else is left alone. With no argument every session log in the directory is swept; pass a sandbox id to narrow it. --older-than 0 deletes everything and prompts first unless --yes", Flags: []string{"--older-than", "--dry-run", "--yes"}, Example: "retask sandbox cleanup --older-than 7d"},
{Command: "retask agent list", Description: "List agents", Flags: []string{"--role"}, Example: "retask agent list --role ROLE_TASK_PROCESSOR"},
{Command: "retask agent get", Description: "Get an agent by ID", Example: "retask agent get <agent-id>"},
{Command: "retask agent create", Description: "Create an agent", Flags: []string{"--name", "--role", "--description", "--sandbox-template-id"}, Example: "retask agent create --name 'Task Bot' --role ROLE_TASK_PROCESSOR"},
Expand Down
162 changes: 162 additions & 0 deletions internal/cmd/sandbox/cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// internal/cmd/sandbox/cleanup.go
package sandbox

import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/nwebxyz/retask-cli/internal/flags"
)

func newCleanupCommand(gf *flags.Global) *cobra.Command {
var olderThan string
var dryRun bool
var yes bool

cmd := &cobra.Command{
Use: "cleanup [sandbox-id]",
Short: "Delete old session folders in the current directory",
Long: `Delete session folders left behind by stopped or disconnected sessions.

Only folders recorded in a sandbox_<sandbox-id>.json session log are considered; any
other directory is left alone. With no argument, every session log in the
current directory is swept.

Usage example:
retask sandbox cleanup
retask sandbox cleanup --older-than 7d
retask sandbox cleanup <sandbox-id> --older-than 7d
retask sandbox cleanup --older-than 0 --yes
retask sandbox cleanup --dry-run

Flags:
--older-than string Delete folders older than this. Values: 30d, 12h, 0 (0 = everything) (default: 30d)
--dry-run Print what would be deleted and exit
--yes Skip the confirmation prompt for --older-than 0`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) (err error) {
window, err := parseDuration(olderThan)
if err != nil {
return err
}
baseDir, err := os.Getwd()
if err != nil {
return err
}

var logs []*sessionLog
if len(args) == 1 {
logs = []*sessionLog{newSessionLog(baseDir, args[0])}
} else if logs, err = discoverSessionLogs(baseDir); err != nil {
return err
}

out := cmd.OutOrStdout()

// Dry-run first, so both --dry-run and the prompt report real counts.
planned := map[*sessionLog][]string{}
total := 0
for _, l := range logs {
ids, sweepErr := l.sweep(baseDir, time.Now(), window, nil, true)
if sweepErr != nil {
fmt.Fprintf(out, "skipping %s: %v\n", filepath.Base(l.path), sweepErr)
continue
}
if len(ids) > 0 {
planned[l] = ids
total += len(ids)
}
}

if total == 0 {
fmt.Fprintln(out, "Nothing to clean up.")
return nil
}

for _, l := range logs {
for _, id := range planned[l] {
fmt.Fprintf(out, "%s %s\n", l.sandboxID, id)
}
}

if dryRun {
fmt.Fprintf(out, "\n%d session folder(s) would be deleted (--dry-run).\n", total)
return nil
}

// A separate process cannot know which sessions are live elsewhere,
// so wiping everything asks first.
if window == 0 && !yes {
prompt := fmt.Sprintf("\nThis will delete %d session folder(s) across %d sandbox(es). Continue? [y/N]: ", total, len(planned))
if !confirm(cmd.InOrStdin(), out, prompt) {
fmt.Fprintln(out, "Aborted.")
return nil
}
}

deletedTotal := 0
for _, l := range logs {
if len(planned[l]) == 0 {
continue
}
deleted, sweepErr := l.sweep(baseDir, time.Now(), window, nil, false)
deletedTotal += len(deleted)
if sweepErr != nil {
err = errors.Join(err, sweepErr)
}
}
fmt.Fprintf(out, "\nDeleted %d session folder(s).\n", deletedTotal)
return err
},
}

cmd.Flags().StringVar(&olderThan, "older-than", "30d", "Delete folders older than this (e.g. 30d, 12h); 0 deletes everything")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print what would be deleted and exit")
cmd.Flags().BoolVar(&yes, "yes", false, "Skip the confirmation prompt for --older-than 0")
return cmd
}

// discoverSessionLogs returns every valid session log in baseDir. A working
// directory holds ordinary JSON (package.json, tsconfig.json); anything failing
// the schema check is skipped, so cleanup can never act on it.
func discoverSessionLogs(baseDir string) (logs []*sessionLog, err error) {
matches, err := filepath.Glob(filepath.Join(baseDir, "*.json"))
if err != nil {
return nil, err
}
sort.Strings(matches)
for _, p := range matches {
d, loadErr := loadSessionLogFile(p)
if loadErr != nil {
if errors.Is(loadErr, errNewerLog) {
continue // written by a newer CLI — not ours to rewrite
}
return nil, loadErr
}
if d == nil {
continue // not a session log
}
logs = append(logs, newSessionLog(baseDir, d.SandboxID))
}
return logs, nil
}

// confirm reads a y/N answer. Anything other than y/yes is a no.
func confirm(in io.Reader, out io.Writer, prompt string) bool {
fmt.Fprint(out, prompt)
line, err := bufio.NewReader(in).ReadString('\n')
if err != nil && line == "" {
return false
}
answer := strings.ToLower(strings.TrimSpace(line))
return answer == "y" || answer == "yes"
}
199 changes: 199 additions & 0 deletions internal/cmd/sandbox/cleanup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package sandbox

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDiscoverSessionLogsSkipsForeignJSON(t *testing.T) {
dir := t.TempDir()

// Two real logs...
a := newSessionLog(dir, "sb-a")
require.NoError(t, a.record("s1", "s1", "session-s1", time.Now().UTC()))
b := newSessionLog(dir, "sb-b")
require.NoError(t, b.record("s2", "s2", "session-s2", time.Now().UTC()))

// ...and ordinary files that must be ignored.
require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "tsconfig.json"), []byte(`{"compilerOptions":{}}`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "broken.json"), []byte(`not json`), 0o644))

logs, err := discoverSessionLogs(dir)
require.NoError(t, err)

var ids []string
for _, l := range logs {
ids = append(ids, l.sandboxID)
}
assert.ElementsMatch(t, []string{"sb-a", "sb-b"}, ids, "only real session logs are discovered")
}

func TestDiscoverSessionLogsEmptyDir(t *testing.T) {
logs, err := discoverSessionLogs(t.TempDir())
require.NoError(t, err)
assert.Empty(t, logs)
}

func TestConfirmAcceptsYes(t *testing.T) {
for _, in := range []string{"y\n", "Y\n", "yes\n", "YES\n"} {
var out bytes.Buffer
assert.True(t, confirm(strings.NewReader(in), &out, "delete? "), "in=%q", in)
}
}

func TestConfirmRejectsAnythingElse(t *testing.T) {
for _, in := range []string{"n\n", "\n", "no\n", "maybe\n", ""} {
var out bytes.Buffer
assert.False(t, confirm(strings.NewReader(in), &out, "delete? "), "in=%q", in)
}
}

func TestCleanupDryRunDeletesNothing(t *testing.T) {
dir := t.TempDir()
l := newSessionLog(dir, "sb-1")
sess := filepath.Join(dir, "session-old")
require.NoError(t, os.MkdirAll(sess, 0o755))
require.NoError(t, l.record("old", "old", "session-old", time.Now().Add(-40*24*time.Hour)))

out := runCleanup(t, dir, []string{"--dry-run"})

_, err := os.Stat(sess)
assert.NoError(t, err, "--dry-run must not delete")
assert.Contains(t, out, "old", "dry run reports what it would delete")
}

func TestCleanupDeletesAged(t *testing.T) {
dir := t.TempDir()
l := newSessionLog(dir, "sb-1")
old := filepath.Join(dir, "session-old")
fresh := filepath.Join(dir, "session-fresh")
require.NoError(t, os.MkdirAll(old, 0o755))
require.NoError(t, os.MkdirAll(fresh, 0o755))
require.NoError(t, l.record("old", "old", "session-old", time.Now().Add(-40*24*time.Hour)))
require.NoError(t, l.record("fresh", "fresh", "session-fresh", time.Now()))

runCleanup(t, dir, nil)

_, err := os.Stat(old)
assert.True(t, os.IsNotExist(err), "default 30d window reaps a 40-day-old folder")
_, err = os.Stat(fresh)
assert.NoError(t, err, "recent folder survives")
}

func TestCleanupNothingToDo(t *testing.T) {
dir := t.TempDir()
l := newSessionLog(dir, "sb-1")
require.NoError(t, os.MkdirAll(filepath.Join(dir, "session-fresh"), 0o755))
require.NoError(t, l.record("fresh", "fresh", "session-fresh", time.Now()))

out := runCleanup(t, dir, nil)
assert.Contains(t, out, "Nothing to clean up.")
}

func TestCleanupOlderThanZeroPromptsAndAborts(t *testing.T) {
dir := t.TempDir()
l := newSessionLog(dir, "sb-1")
sess := filepath.Join(dir, "session-a")
require.NoError(t, os.MkdirAll(sess, 0o755))
require.NoError(t, l.record("a", "a", "session-a", time.Now()))

cmd := newCleanupCommand(nil)
cmd.SetArgs([]string{"--older-than", "0"})
cmd.SetIn(strings.NewReader("n\n"))
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
withWd(t, dir, func() { require.NoError(t, cmd.Execute()) })

_, err := os.Stat(sess)
assert.NoError(t, err, "answering n must abort")
assert.Contains(t, out.String(), "Aborted")
}

func TestCleanupOlderThanZeroWithYesTakesEverything(t *testing.T) {
dir := t.TempDir()
l := newSessionLog(dir, "sb-1")
sess := filepath.Join(dir, "session-a")
require.NoError(t, os.MkdirAll(sess, 0o755))
require.NoError(t, l.record("a", "a", "session-a", time.Now()))

runCleanup(t, dir, []string{"--older-than", "0", "--yes"})

_, err := os.Stat(sess)
assert.True(t, os.IsNotExist(err), "--older-than 0 --yes deletes everything")
}

func TestCleanupSandboxArgNarrowsScope(t *testing.T) {
dir := t.TempDir()
a := newSessionLog(dir, "sb-a")
b := newSessionLog(dir, "sb-b")
aDir := filepath.Join(dir, "session-a")
bDir := filepath.Join(dir, "session-b")
require.NoError(t, os.MkdirAll(aDir, 0o755))
require.NoError(t, os.MkdirAll(bDir, 0o755))
require.NoError(t, a.record("a", "a", "session-a", time.Now().Add(-40*24*time.Hour)))
require.NoError(t, b.record("b", "b", "session-b", time.Now().Add(-40*24*time.Hour)))

runCleanup(t, dir, []string{"sb-a"})

_, err := os.Stat(aDir)
assert.True(t, os.IsNotExist(err), "named sandbox is swept")
_, err = os.Stat(bDir)
assert.NoError(t, err, "other sandboxes are untouched when an id is given")
}

func TestCleanupIgnoresUnloggedFolders(t *testing.T) {
dir := t.TempDir()
orphan := filepath.Join(dir, "session-orphan")
require.NoError(t, os.MkdirAll(orphan, 0o755))

runCleanup(t, dir, []string{"--older-than", "0", "--yes"})

_, err := os.Stat(orphan)
assert.NoError(t, err, "log-only: a folder with no entry is never deleted")
}

func TestCleanupRejectsBadOlderThan(t *testing.T) {
dir := t.TempDir()
cmd := newCleanupCommand(nil)
cmd.SetArgs([]string{"--older-than", "off"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
withWd(t, dir, func() {
assert.Error(t, cmd.Execute(), `"off" is retention-only, not valid for --older-than`)
})
}

// --- helpers ---

// withWd runs fn with the process working directory set to dir.
func withWd(t *testing.T, dir string, fn func()) {
t.Helper()
orig, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(dir))
defer func() { require.NoError(t, os.Chdir(orig)) }()
fn()
}

// runCleanup executes the cleanup command in dir and returns its output.
func runCleanup(t *testing.T, dir string, args []string) string {
t.Helper()
cmd := newCleanupCommand(nil)
cmd.SetArgs(args)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetIn(strings.NewReader(""))
withWd(t, dir, func() { require.NoError(t, cmd.Execute()) })
return out.String()
}
1 change: 1 addition & 0 deletions internal/cmd/sandbox/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func NewCommand(gf *flags.Global) *cobra.Command {
newSessionCommand(gf),
newConnectCommand(gf),
newAttachCommand(gf),
newCleanupCommand(gf),
)
return cmd
}
Expand Down
Loading