Skip to content

Commit 3c96b6a

Browse files
committed
v0.5.9: session cleanup command
Add 'kode session cleanup <days>' to delete all sessions older than the given number of days. - Store.Cleanup(before time.Time) method scans sessions by UpdatedAt - CLI handler with validation for non-negative integer days - 4 new tests: basic cleanup, empty store, zero-days, idempotent - Updated usage text and test coverage
1 parent df6f34d commit 3c96b6a

4 files changed

Lines changed: 148 additions & 3 deletions

File tree

cmd/kode/main.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"path/filepath"
1212
"runtime/debug"
1313
"strings"
14+
"time"
1415

1516
"github.com/BackendStack21/kode"
1617
"github.com/BackendStack21/kode/internal/config"
@@ -213,15 +214,15 @@ func printUsage() {
213214
kode run [flags] <task>
214215
kode run --session [flags] <task>
215216
kode continue [--id <id>] <task>
216-
kode session <list|show [id]|trim <id> <n>|delete <id>>
217+
kode session <list|show [id]|trim <id> <n>|delete <id>|cleanup <days>>
217218
kode init [--global | -g] [--force | -f]
218219
kode version
219220
220221
Commands:
221222
run Execute a task with the agent loop
222223
run --session Execute and save conversation as a session
223224
continue Continue the most recent session (or by --id)
224-
session Manage sessions: list, show, delete
225+
session Manage sessions: list, show, delete, trim, cleanup
225226
init Create a config file (default: ./kode.json)
226227
version Print version and exit
227228
@@ -871,8 +872,10 @@ func sessionCmd(args []string) error {
871872
return deleteSession(store, args[1:])
872873
case "trim":
873874
return trimSession(store, args[1:])
875+
case "cleanup":
876+
return cleanupSessions(store, args[1:])
874877
default:
875-
return fmt.Errorf("unknown session command %q (use list, show, trim, delete)", args[0])
878+
return fmt.Errorf("unknown session command %q (use list, show, trim, delete, cleanup)", args[0])
876879
}
877880
}
878881

@@ -1012,6 +1015,30 @@ func trimSession(store *session.Store, args []string) error {
10121015
return nil
10131016
}
10141017

1018+
// cleanupSessions deletes all sessions older than the given number of days.
1019+
// Usage: kode session cleanup <days>
1020+
func cleanupSessions(store *session.Store, args []string) error {
1021+
if len(args) == 0 {
1022+
return fmt.Errorf("usage: kode session cleanup <days>")
1023+
}
1024+
var days int
1025+
if _, err := fmt.Sscanf(args[0], "%d", &days); err != nil || days < 0 {
1026+
return fmt.Errorf("invalid days %q — must be a non-negative integer", args[0])
1027+
}
1028+
1029+
before := time.Now().UTC().AddDate(0, 0, -days)
1030+
count, err := store.Cleanup(before)
1031+
if err != nil {
1032+
return fmt.Errorf("cleanup sessions: %w", err)
1033+
}
1034+
if count == 0 {
1035+
fmt.Println("No sessions to clean up.")
1036+
} else {
1037+
fmt.Printf("Cleaned up %d session(s) older than %d days.\n", count, days)
1038+
}
1039+
return nil
1040+
}
1041+
10151042
// countUserTurnsUpTo counts user messages up to (but not including) index n.
10161043
func countUserTurnsUpTo(messages []llm.Message, n int) int {
10171044
count := 0

cmd/kode/main_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ func TestPrintUsage(t *testing.T) {
226226
"kode continue",
227227
"kode session",
228228
"trim",
229+
"cleanup",
229230
"kode init",
230231
"kode version",
231232
"Commands:",

internal/session/session.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,34 @@ func (s *Store) Delete(id string) error {
228228
return err
229229
}
230230

231+
// Cleanup deletes all sessions whose UpdatedAt is before the given time.
232+
// Returns the count of deleted sessions. Idempotent — nonexistent files
233+
// are skipped silently (os.Remove already handles this via Delete).
234+
func (s *Store) Cleanup(before time.Time) (int, error) {
235+
entries, err := os.ReadDir(s.dir)
236+
if err != nil {
237+
return 0, fmt.Errorf("session: list: %w", err)
238+
}
239+
240+
var deleted int
241+
for _, e := range entries {
242+
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
243+
continue
244+
}
245+
sess, err := s.Load(idFromPath(e.Name()))
246+
if err != nil {
247+
continue // skip unreadable files
248+
}
249+
if sess.UpdatedAt.Before(before) {
250+
if err := s.Delete(sess.ID); err != nil {
251+
return deleted, fmt.Errorf("session: delete %q: %w", sess.ID, err)
252+
}
253+
deleted++
254+
}
255+
}
256+
return deleted, nil
257+
}
258+
231259
// ── Helpers ────────────────────────────────────────────────────────────
232260

233261
// countUserTurns returns the number of user messages in a slice.

internal/session/session_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"os"
55
"strings"
66
"testing"
7+
"time"
78

89
"github.com/BackendStack21/kode/internal/llm"
910
)
@@ -155,6 +156,94 @@ func TestStore_Delete(t *testing.T) {
155156
}
156157
}
157158

159+
func TestStore_Cleanup(t *testing.T) {
160+
store := newTestStore(t)
161+
162+
// Create a "current" session
163+
msgs := []llm.Message{{Role: "user", Content: "current"}}
164+
current, err := store.Create(msgs, "m", "current")
165+
if err != nil {
166+
t.Fatal(err)
167+
}
168+
169+
// Create an "old" session by rewriting its UpdatedAt
170+
msgs2 := []llm.Message{{Role: "user", Content: "old"}}
171+
oldSess, err := store.Create(msgs2, "m", "old")
172+
if err != nil {
173+
t.Fatal(err)
174+
}
175+
oldSess.UpdatedAt = oldSess.UpdatedAt.AddDate(0, 0, -30) // 30 days ago
176+
if err := store.Save(oldSess); err != nil {
177+
t.Fatal(err)
178+
}
179+
180+
// Cleanup sessions older than 7 days
181+
deleted, err := store.Cleanup(time.Now().UTC().AddDate(0, 0, -7))
182+
if err != nil {
183+
t.Fatalf("Cleanup() error: %v", err)
184+
}
185+
if deleted != 1 {
186+
t.Errorf("Cleanup() deleted %d, want 1", deleted)
187+
}
188+
189+
// Current session should still exist
190+
if _, err := store.Load(current.ID); err != nil {
191+
t.Errorf("current session should survive cleanup: %v", err)
192+
}
193+
194+
// Old session should be gone
195+
if _, err := store.Load(oldSess.ID); err == nil {
196+
t.Error("old session should have been deleted")
197+
}
198+
}
199+
200+
func TestStore_Cleanup_EmptyStore(t *testing.T) {
201+
store := newTestStore(t)
202+
deleted, err := store.Cleanup(time.Now().UTC())
203+
if err != nil {
204+
t.Fatalf("Cleanup() on empty store: %v", err)
205+
}
206+
if deleted != 0 {
207+
t.Errorf("Cleanup() deleted %d, want 0", deleted)
208+
}
209+
}
210+
211+
func TestStore_Cleanup_ZeroDays(t *testing.T) {
212+
store := newTestStore(t)
213+
214+
msgs := []llm.Message{{Role: "user", Content: "anything"}}
215+
sess, err := store.Create(msgs, "m", "test")
216+
if err != nil {
217+
t.Fatal(err)
218+
}
219+
220+
// cleanup with 0 days = delete everything (all sessions are older than "right now" since UpdatedAt is from Create)
221+
deleted, err := store.Cleanup(time.Now().UTC())
222+
if err != nil {
223+
t.Fatalf("Cleanup() error: %v", err)
224+
}
225+
if deleted != 1 {
226+
t.Errorf("Cleanup() deleted %d, want 1", deleted)
227+
}
228+
if _, err := store.Load(sess.ID); err == nil {
229+
t.Error("session should have been deleted")
230+
}
231+
}
232+
233+
func TestStore_Cleanup_Idempotent(t *testing.T) {
234+
store := newTestStore(t)
235+
236+
// Cleanup empty store twice — should not error
237+
_, err := store.Cleanup(time.Now().UTC())
238+
if err != nil {
239+
t.Fatalf("first Cleanup: %v", err)
240+
}
241+
_, err = store.Cleanup(time.Now().UTC())
242+
if err != nil {
243+
t.Fatalf("second Cleanup (idempotent): %v", err)
244+
}
245+
}
246+
158247
func TestGenerateID(t *testing.T) {
159248
id := generateID()
160249
if !strings.Contains(id, "-") {

0 commit comments

Comments
 (0)