From 83cc43c87c77edb667f332ec634ac6864b800810 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Fri, 17 Jul 2026 18:15:16 -0700 Subject: [PATCH] fix(cli): harden swarm commands found by max-quality review Deep review of the swarm CLI commands (open, watch, stats, prune, tag) surfaced correctness, safety, and injection issues. Fix them all with regression tests. - open: scoped resume now picks the most-recently-active match instead of the oldest (it reused stats' created-ascending ordering); add a guard so `open --current` errors out instead of resuming an unrelated session when no repo/branch/folder can be detected. - watch: count AttentionInterrupted sessions in their own bucket instead of silently tallying them as idle while listing them as interrupted. - stats: neutralize CSV/spreadsheet formula injection (CWE-1236) in repository/branch/host labels via csvSafe, and surface csv writer flush errors instead of swallowing them in a deferred Flush. - prune: enumerate all session IDs (including empty sessions) so config metadata for real-but-empty sessions is not treated as stale; refuse to --apply against an empty store, which would otherwise wipe every config entry. - tag: resolve an ID or unique prefix via the store (matches the other commands and finds empty sessions the filtered list omits). - data: add Store.AllSessionIDs for existence checks that must include empty sessions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 524cf702-8922-4cfd-b23f-4069b734e09b --- cmd/dispatch/open.go | 30 +++++++++++++++++++-- cmd/dispatch/open_test.go | 55 ++++++++++++++++++++++++++++++++++++++ cmd/dispatch/prune.go | 39 ++++++++++++++++++++++----- cmd/dispatch/prune_test.go | 35 ++++++++++++++++++++---- cmd/dispatch/stats.go | 28 ++++++++++++++++--- cmd/dispatch/stats_test.go | 42 +++++++++++++++++++++++++++++ cmd/dispatch/tag.go | 45 ++++++++++++++++++++++--------- cmd/dispatch/tag_test.go | 43 +++++++++++++++++++++++++---- cmd/dispatch/watch.go | 26 +++++++++++------- cmd/dispatch/watch_test.go | 29 ++++++++++++++++++++ internal/data/store.go | 27 +++++++++++++++++++ 11 files changed, 353 insertions(+), 46 deletions(-) diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go index aecd791..d2d3d88 100644 --- a/cmd/dispatch/open.go +++ b/cmd/dispatch/open.go @@ -23,7 +23,8 @@ var ( openGetLastSessionFn = defaultOpenGetLastSession openLaunchFn = defaultOpenLaunch openResumeCmdFn = platform.BuildResumeCommandString - openListSessionsFn = defaultStatsListSessions + openListSessionsFn = defaultOpenListScopedSessions + openDetectGitFn = detectGitContext // openStdin is the reader used by --stdin batch resume. It is a package // variable so tests can feed it a fixed list of IDs. @@ -314,7 +315,7 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool, Folder: folder, } if current { - detectedRepo, detectedBranch, cErr := detectGitContext() + detectedRepo, detectedBranch, cErr := openDetectGitFn() if cErr != nil { return "", "", false, false, false, launchOverrides{}, nil, fmt.Errorf("--current: %w", cErr) } @@ -324,6 +325,13 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool, if filter.Branch == "" { filter.Branch = detectedBranch } + // Guard against an empty scope: without a detected repo/branch (no + // origin remote, detached HEAD) the filter would match every + // session and resume an unrelated one. + if filter.Repository == "" && filter.Branch == "" && filter.Folder == "" { + return "", "", false, false, false, launchOverrides{}, nil, + errors.New("--current: could not detect a repository or branch from the current directory") + } } scopeFilter = &filter default: @@ -424,6 +432,24 @@ func defaultOpenGetLastSession() (*data.Session, error) { return &sessions[0], nil } +// defaultOpenListScopedSessions lists sessions matching a scope filter, ordered +// most-recently-active first (the same ordering --last uses), so the scoped +// resume path can pick sessions[0] as the most recent match. +func defaultOpenListScopedSessions(filter data.FilterOptions) ([]data.Session, error) { + store, err := data.Open() + if err != nil { + return nil, fmt.Errorf("opening session store: %w", err) + } + defer store.Close() //nolint:errcheck // read-only, best-effort close + + sortOpts := data.SortOptions{Field: data.SortByUpdated, Order: data.Descending} + sessions, err := store.ListSessions(context.Background(), filter, sortOpts, statsQueryLimit) + if err != nil { + return nil, fmt.Errorf("listing sessions: %w", err) + } + return sessions, nil +} + // defaultOpenLaunch resumes the session using the resolved launch mode. For // in-place mode it runs the Copilot CLI in the current terminal and waits for // it to exit. For tab, window, and pane modes it delegates to the platform diff --git a/cmd/dispatch/open_test.go b/cmd/dispatch/open_test.go index 0c66aa9..9d5f4c3 100644 --- a/cmd/dispatch/open_test.go +++ b/cmd/dispatch/open_test.go @@ -596,6 +596,61 @@ func TestRunOpen_ScopedResume(t *testing.T) { } } +// TestRunOpen_ScopedResumePicksMostRecent verifies the scoped resume selects the +// first session returned by the lister — which lists most-recently-active first, +// so the newest matching session is resumed (regression: it used to take the +// oldest via a created-ascending sort). +func TestRunOpen_ScopedResumePicksMostRecent(t *testing.T) { + cfg := config.Default() + newest := data.Session{ID: "newest", Repository: "x/y"} + older := data.Session{ID: "older", Repository: "x/y"} + capture := withOpenStubs(t, cfg, &newest, nil) + + origList := openListSessionsFn + // Most-recently-active first, as defaultOpenListScopedSessions returns. + openListSessionsFn = func(data.FilterOptions) ([]data.Session, error) { + return []data.Session{newest, older}, nil + } + t.Cleanup(func() { openListSessionsFn = origList }) + + if err := runOpen(io.Discard, []string{"open", "--repo", "x/y"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capture.session == nil || capture.session.ID != "newest" { + t.Errorf("resumed %+v, want the most recent session 'newest'", capture.session) + } +} + +// TestRunOpen_CurrentNoDetection verifies --current errors instead of resuming +// an unrelated session when no repo/branch can be detected (no origin remote / +// detached HEAD), which would otherwise leave an all-matching empty filter. +func TestRunOpen_CurrentNoDetection(t *testing.T) { + withOpenStubs(t, config.Default(), nil, nil) + + origDetect := openDetectGitFn + openDetectGitFn = func() (string, string, error) { return "", "", nil } + t.Cleanup(func() { openDetectGitFn = origDetect }) + + origList := openListSessionsFn + listCalled := false + openListSessionsFn = func(data.FilterOptions) ([]data.Session, error) { + listCalled = true + return nil, nil + } + t.Cleanup(func() { openListSessionsFn = origList }) + + err := runOpen(io.Discard, []string{"open", "--current"}) + if err == nil { + t.Fatal("expected an error when nothing is detected, got nil") + } + if !strings.Contains(err.Error(), "could not detect") { + t.Errorf("error = %q, want a 'could not detect' message", err.Error()) + } + if listCalled { + t.Error("session lister should not run when detection is empty") + } +} + func TestRunOpen_ScopedResumeNoMatch(t *testing.T) { withOpenStubs(t, config.Default(), nil, nil) diff --git a/cmd/dispatch/prune.go b/cmd/dispatch/prune.go index 02858a2..e0c54cd 100644 --- a/cmd/dispatch/prune.go +++ b/cmd/dispatch/prune.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "io" @@ -11,9 +12,26 @@ import ( "github.com/jongio/dispatch/internal/data" ) -// pruneListSessionsFn loads sessions for the prune command. It is a package -// variable so tests can substitute a fixed set of sessions. -var pruneListSessionsFn = defaultStatsListSessions +// pruneAllSessionIDsFn returns the IDs of every session in the store, including +// empty ones. It is a package variable so tests can substitute a fixed set. It +// must NOT use the filtered session list (which drops empty sessions), or prune +// would delete config metadata for real-but-empty sessions. +var pruneAllSessionIDsFn = defaultPruneAllSessionIDs + +// defaultPruneAllSessionIDs loads all session IDs from the default store. +func defaultPruneAllSessionIDs() ([]string, error) { + store, err := data.Open() + if err != nil { + return nil, fmt.Errorf("opening session store: %w", err) + } + defer store.Close() //nolint:errcheck // read-only, best-effort close + + ids, err := store.AllSessionIDs(context.Background()) + if err != nil { + return nil, fmt.Errorf("listing session IDs: %w", err) + } + return ids, nil +} // pruneCategory describes stale entries found in one config section. type pruneCategory struct { @@ -53,19 +71,26 @@ func runPrune(w io.Writer, args []string) error { return fmt.Errorf("loading config: %w", err) } - sessions, err := pruneListSessionsFn(data.FilterOptions{}) + ids, err := pruneAllSessionIDsFn() if err != nil { return err } - liveIDs := make(map[string]bool, len(sessions)) - for _, s := range sessions { - liveIDs[s.ID] = true + liveIDs := make(map[string]bool, len(ids)) + for _, id := range ids { + liveIDs[id] = true } report := buildPruneReport(cfg, liveIDs) report.Applied = opts.apply + // Safety guard: an empty store would classify every config entry as stale + // and wipe all of it. That is almost always a misconfiguration (wrong DB + // path) rather than genuine intent, so refuse to apply. + if opts.apply && len(liveIDs) == 0 && report.TotalStale > 0 { + return fmt.Errorf("refusing to prune: the session store has no sessions, so applying would remove all %d config entries; if this is intentional, edit the config file directly", report.TotalStale) + } + if opts.apply && report.TotalStale > 0 { applyPrune(cfg, liveIDs) if err := configSaveFn(cfg); err != nil { diff --git a/cmd/dispatch/prune_test.go b/cmd/dispatch/prune_test.go index f1b515c..4a0ea58 100644 --- a/cmd/dispatch/prune_test.go +++ b/cmd/dispatch/prune_test.go @@ -20,11 +20,15 @@ func withPruneSeams(t *testing.T, cfg *config.Config, sessions []data.Session) { configSaveFn = func(c *config.Config) error { return nil } t.Cleanup(func() { configLoadFn = prevLoad; configSaveFn = prevSave }) - prevList := pruneListSessionsFn - pruneListSessionsFn = func(data.FilterOptions) ([]data.Session, error) { - return sessions, nil - } - t.Cleanup(func() { pruneListSessionsFn = prevList }) + prevList := pruneAllSessionIDsFn + pruneAllSessionIDsFn = func() ([]string, error) { + ids := make([]string, len(sessions)) + for i, s := range sessions { + ids[i] = s.ID + } + return ids, nil + } + t.Cleanup(func() { pruneAllSessionIDsFn = prevList }) } func makePruneConfig() *config.Config { @@ -93,6 +97,27 @@ func TestRunPrune_Apply(t *testing.T) { } } +// TestRunPrune_EmptyStoreGuard verifies that --apply refuses to wipe all config +// entries when the store reports zero sessions (a likely misconfiguration), +// rather than silently deleting everything. +func TestRunPrune_EmptyStoreGuard(t *testing.T) { + cfg := makePruneConfig() + withPruneSeams(t, cfg, nil) // empty store: no session IDs + + var buf bytes.Buffer + err := runPrune(&buf, []string{"prune", "--apply"}) + if err == nil { + t.Fatal("expected a guard error when applying against an empty store") + } + if !bytes.Contains([]byte(err.Error()), []byte("refusing to prune")) { + t.Errorf("error = %q, want a 'refusing to prune' guard message", err.Error()) + } + // Nothing should have been deleted. + if _, ok := cfg.SessionAliases["gone-1"]; !ok { + t.Error("guard must not delete any config entries") + } +} + func TestRunPrune_JSON(t *testing.T) { cfg := makePruneConfig() sessions := []data.Session{{ID: "live-1"}} diff --git a/cmd/dispatch/stats.go b/cmd/dispatch/stats.go index 6d736ca..f8eba51 100644 --- a/cmd/dispatch/stats.go +++ b/cmd/dispatch/stats.go @@ -351,7 +351,6 @@ func writeStatsJSON(w io.Writer, report statsReport) error { // writeStatsCSV prints the report as RFC 4180 CSV rows. func writeStatsCSV(w io.Writer, report statsReport) error { cw := csv.NewWriter(w) - defer cw.Flush() if err := cw.Write([]string{"section", "label", "count"}); err != nil { return err @@ -367,17 +366,17 @@ func writeStatsCSV(w io.Writer, report statsReport) error { } for _, e := range report.ByRepository { - if err := cw.Write([]string{"repository", e.Label, strconv.Itoa(e.Count)}); err != nil { + if err := cw.Write([]string{"repository", csvSafe(e.Label), strconv.Itoa(e.Count)}); err != nil { return err } } for _, e := range report.ByBranch { - if err := cw.Write([]string{"branch", e.Label, strconv.Itoa(e.Count)}); err != nil { + if err := cw.Write([]string{"branch", csvSafe(e.Label), strconv.Itoa(e.Count)}); err != nil { return err } } for _, e := range report.ByHostType { - if err := cw.Write([]string{"host_type", e.Label, strconv.Itoa(e.Count)}); err != nil { + if err := cw.Write([]string{"host_type", csvSafe(e.Label), strconv.Itoa(e.Count)}); err != nil { return err } } @@ -390,9 +389,30 @@ func writeStatsCSV(w io.Writer, report statsReport) error { } } + cw.Flush() + if err := cw.Error(); err != nil { + return err + } return nil } +// csvSafe neutralizes spreadsheet formula injection (CWE-1236). encoding/csv +// only applies RFC 4180 quoting, not formula-trigger escaping, so a field +// beginning with =, +, -, @, tab, or CR would be evaluated as a formula when +// the CSV is opened in Excel/Sheets/LibreOffice. Prefix such values with a +// single quote so they render as literal text. +func csvSafe(s string) string { + if s == "" { + return s + } + switch s[0] { + case '=', '+', '-', '@', '\t', '\r': + return "'" + s + default: + return s + } +} + // writeStatsText prints the report in a plain, human-readable layout. func writeStatsText(w io.Writer, report statsReport) { fmt.Fprintln(w, "Dispatch stats") diff --git a/cmd/dispatch/stats_test.go b/cmd/dispatch/stats_test.go index bcb5d78..5784f72 100644 --- a/cmd/dispatch/stats_test.go +++ b/cmd/dispatch/stats_test.go @@ -319,3 +319,45 @@ func TestRunStatsCSV(t *testing.T) { t.Errorf("CSV missing repo breakdown, got:\n%s", out) } } + +// TestRunStatsCSV_FormulaInjection verifies a repo/branch label starting with a +// spreadsheet formula trigger is neutralized (prefixed with ') so it can't +// execute as a formula when opened in Excel/Sheets (CWE-1236). +func TestRunStatsCSV_FormulaInjection(t *testing.T) { + withStatsList(t, func(data.FilterOptions) ([]data.Session, error) { + return []data.Session{ + {ID: "s1", Repository: "=cmd|' /c calc'!A1", Branch: "main"}, + {ID: "s2", Repository: "=cmd|' /c calc'!A1", Branch: "main"}, + }, nil + }) + + var buf bytes.Buffer + if err := runStats(&buf, []string{"stats", "--csv"}); err != nil { + t.Fatalf("runStats --csv: %v", err) + } + out := buf.String() + if strings.Contains(out, "\n=cmd") || strings.Contains(out, ",=cmd") { + t.Errorf("CSV emitted an unescaped formula label, got:\n%s", out) + } + if !strings.Contains(out, "'=cmd") { + t.Errorf("CSV should prefix a formula-triggering label with a quote, got:\n%s", out) + } +} + +func TestCsvSafe(t *testing.T) { + cases := map[string]string{ + "": "", + "jongio/repo": "jongio/repo", + "main": "main", + "=1+1": "'=1+1", + "+cmd": "'+cmd", + "-2": "'-2", + "@ref": "'@ref", + "\ttab": "'\ttab", + } + for in, want := range cases { + if got := csvSafe(in); got != want { + t.Errorf("csvSafe(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/cmd/dispatch/tag.go b/cmd/dispatch/tag.go index 512fcd8..d3c6df8 100644 --- a/cmd/dispatch/tag.go +++ b/cmd/dispatch/tag.go @@ -1,7 +1,10 @@ package main import ( + "context" + "database/sql" "encoding/json" + "errors" "fmt" "io" "sort" @@ -11,9 +14,30 @@ import ( "github.com/jongio/dispatch/internal/data" ) -// tagListSessionsFn loads sessions for the tag command. It is a package -// variable so tests can substitute a fixed set of sessions. -var tagListSessionsFn = defaultStatsListSessions +// tagResolveIDFn resolves a session ID or unique prefix to a full session ID. +// It is a package variable so tests can substitute a fixed resolver. +var tagResolveIDFn = defaultTagResolveID + +// defaultTagResolveID resolves an ID or unique prefix to a full session ID via +// the store. It returns ("", nil) when no session matches — including empty +// sessions, which the filtered session list would miss — and an error for an +// ambiguous prefix or a store failure. +func defaultTagResolveID(id string) (string, error) { + store, err := data.Open() + if err != nil { + return "", fmt.Errorf("opening session store: %w", err) + } + defer store.Close() //nolint:errcheck // read-only, best-effort close + + fullID, err := store.ResolveIDPrefix(context.Background(), id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return "", err + } + return fullID, nil +} // tagResult is the JSON output after a tag mutation. type tagResult struct { @@ -49,21 +73,16 @@ func runTag(w io.Writer, args []string) error { sessionID = resolved } - // Verify the session exists in the store. - sessions, err := tagListSessionsFn(data.FilterOptions{}) + // Resolve the ID or unique prefix to a full session ID. This also confirms + // the session exists (including empty sessions the filtered list omits). + fullID, err := tagResolveIDFn(sessionID) if err != nil { return err } - found := false - for _, s := range sessions { - if s.ID == sessionID { - found = true - break - } - } - if !found { + if fullID == "" { return fmt.Errorf("session %q not found", opts.id) } + sessionID = fullID // Apply mutation if requested. mutated := false diff --git a/cmd/dispatch/tag_test.go b/cmd/dispatch/tag_test.go index b763baa..bcea2db 100644 --- a/cmd/dispatch/tag_test.go +++ b/cmd/dispatch/tag_test.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "errors" + "fmt" + "strings" "testing" "github.com/jongio/dispatch/internal/config" @@ -20,11 +22,25 @@ func withTagSeams(t *testing.T, cfg *config.Config, sessions []data.Session) { configSaveFn = func(c *config.Config) error { return nil } t.Cleanup(func() { configLoadFn = prevLoad; configSaveFn = prevSave }) - prevList := tagListSessionsFn - tagListSessionsFn = func(data.FilterOptions) ([]data.Session, error) { - return sessions, nil - } - t.Cleanup(func() { tagListSessionsFn = prevList }) + prevResolve := tagResolveIDFn + tagResolveIDFn = func(id string) (string, error) { + // Exact match wins; otherwise resolve a unique prefix — mirroring the + // store's ResolveIDPrefix so tests can exercise prefix resolution. + var match string + for _, s := range sessions { + if s.ID == id { + return s.ID, nil + } + if strings.HasPrefix(s.ID, id) { + if match != "" { + return "", fmt.Errorf("ambiguous prefix %q", id) + } + match = s.ID + } + } + return match, nil + } + t.Cleanup(func() { tagResolveIDFn = prevResolve }) } func TestRunTag_ListTags(t *testing.T) { @@ -83,6 +99,23 @@ func TestRunTag_Add(t *testing.T) { } } +// TestRunTag_ResolvesPrefix verifies tag accepts a unique ID prefix (like the +// other commands) and keys the tags by the resolved full ID (regression: +// it used to require an exact match). +func TestRunTag_ResolvesPrefix(t *testing.T) { + cfg := &config.Config{} + sessions := []data.Session{{ID: "ses-abc123"}} + withTagSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runTag(&buf, []string{"tag", "ses-a", "--add", "work"}); err != nil { + t.Fatalf("prefix should resolve, got error: %v", err) + } + if got := cfg.SessionTags["ses-abc123"]; len(got) != 1 || got[0] != "work" { + t.Errorf("tags keyed by full ID = %v, want [work] on ses-abc123", cfg.SessionTags) + } +} + func TestRunTag_AddDeduplicates(t *testing.T) { cfg := &config.Config{ SessionTags: map[string][]string{ diff --git a/cmd/dispatch/watch.go b/cmd/dispatch/watch.go index df92b7e..a170690 100644 --- a/cmd/dispatch/watch.go +++ b/cmd/dispatch/watch.go @@ -40,16 +40,17 @@ type watchOptions struct { // watchSnapshot is the JSON representation of attention state at a point in time. type watchSnapshot struct { - Timestamp string `json:"timestamp"` - Total int `json:"total"` - Waiting int `json:"waiting"` - Working int `json:"working"` - Thinking int `json:"thinking"` - Active int `json:"active"` - Stale int `json:"stale"` - Idle int `json:"idle"` - Compacting int `json:"compacting"` - Sessions []watchSessionEntry `json:"sessions,omitempty"` + Timestamp string `json:"timestamp"` + Total int `json:"total"` + Waiting int `json:"waiting"` + Working int `json:"working"` + Thinking int `json:"thinking"` + Active int `json:"active"` + Stale int `json:"stale"` + Idle int `json:"idle"` + Compacting int `json:"compacting"` + Interrupted int `json:"interrupted"` + Sessions []watchSessionEntry `json:"sessions,omitempty"` } // watchSessionEntry describes one session in the watch output. @@ -297,6 +298,8 @@ func buildWatchSnapshot(opts watchOptions) (watchSnapshot, error) { snap.Stale++ case data.AttentionCompacting: snap.Compacting++ + case data.AttentionInterrupted: + snap.Interrupted++ default: snap.Idle++ } @@ -349,6 +352,9 @@ func writeWatchSnapshotText(w io.Writer, snap watchSnapshot) { if snap.Compacting > 0 { parts = append(parts, fmt.Sprintf("%d compacting", snap.Compacting)) } + if snap.Interrupted > 0 { + parts = append(parts, fmt.Sprintf("%d interrupted", snap.Interrupted)) + } if snap.Idle > 0 { parts = append(parts, fmt.Sprintf("%d idle", snap.Idle)) } diff --git a/cmd/dispatch/watch_test.go b/cmd/dispatch/watch_test.go index e5ed760..9179758 100644 --- a/cmd/dispatch/watch_test.go +++ b/cmd/dispatch/watch_test.go @@ -56,6 +56,35 @@ func TestRunWatch_OnceText(t *testing.T) { } } +// TestRunWatch_InterruptedNotIdle verifies an interrupted session is counted as +// interrupted, not silently bucketed into the idle total (regression). +func TestRunWatch_InterruptedNotIdle(t *testing.T) { + attention := map[string]data.AttentionStatus{ + "ses-1": data.AttentionInterrupted, + "ses-2": data.AttentionIdle, + } + sessions := []data.Session{ + {ID: "ses-1", Summary: "Crashed"}, + {ID: "ses-2", Summary: "Quiet"}, + } + withWatchSeams(t, attention, sessions) + + var buf bytes.Buffer + if err := runWatch(&buf, []string{"watch", "--once", "--json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got watchSnapshot + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal snapshot: %v", err) + } + if got.Interrupted != 1 { + t.Errorf("Interrupted = %d, want 1", got.Interrupted) + } + if got.Idle != 1 { + t.Errorf("Idle = %d, want 1 (only the truly idle session)", got.Idle) + } +} + func TestRunWatch_OnceJSON(t *testing.T) { attention := map[string]data.AttentionStatus{ "ses-1": data.AttentionWaiting, diff --git a/internal/data/store.go b/internal/data/store.go index 7732a32..37f2d18 100644 --- a/internal/data/store.go +++ b/internal/data/store.go @@ -605,6 +605,33 @@ func (s *Store) ListSessionsByIDs(ctx context.Context, ids []string) ([]Session, return result, nil } +// AllSessionIDs returns the IDs of every session in the store, including +// "empty" sessions (no turns/files/checkpoints/refs) that ListSessions filters +// out. Callers that need to know whether a session still exists on disk — such +// as `dispatch prune`, which deletes config metadata for vanished sessions — +// must use this rather than ListSessions, otherwise metadata for real-but-empty +// sessions would be wrongly treated as stale. +func (s *Store) AllSessionIDs(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, "SELECT id FROM sessions") + if err != nil { + return nil, fmt.Errorf("querying session IDs: %w", err) + } + defer closeRows(rows) + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scanning session ID: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating session IDs: %w", err) + } + return ids, nil +} + // GetSession loads a single session and all of its related turns, // checkpoints, files, and refs. func (s *Store) GetSession(ctx context.Context, id string) (*SessionDetail, error) {