diff --git a/cmd/engram/main.go b/cmd/engram/main.go index a3edf3f23..25e794f59 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -95,7 +95,8 @@ var ( storeDeleteProject = func(s *store.Store, name string, hard bool) (*store.DeleteProjectResult, error) { return s.DeleteProject(name, hard) } - storeTimeline = func(s *store.Store, observationID int64, before, after int) (*store.TimelineResult, error) { + storePruneProject = func(s *store.Store, name string) (*store.PruneResult, error) { return s.PruneProject(name) } + storeTimeline = func(s *store.Store, observationID int64, before, after int) (*store.TimelineResult, error) { return s.Timeline(observationID, before, after) } storeFormatContext = func(s *store.Store, project, scope string) (string, error) { return s.FormatContext(project, scope) } @@ -1861,7 +1862,7 @@ func cmdProjects(cfg store.Config) { fmt.Fprintf(os.Stderr, "unknown projects subcommand: %s\n", subCmd) fmt.Fprintln(os.Stderr, "usage: engram projects list") fmt.Fprintln(os.Stderr, " engram projects consolidate [--all] [--dry-run]") - fmt.Fprintln(os.Stderr, " engram projects prune [--dry-run]") + fmt.Fprintln(os.Stderr, " engram projects prune [--dry-run] [--paths-only]") exitFunc(1) } } @@ -1962,6 +1963,47 @@ func findNormalizationEquivalentProjects(name string, existing []string) []proje return matches } +// mergedRecordCount reports how many records a merge actually moved. The store +// validates every source against the canonical name and fail-closes on the ones +// it cannot prove normalization-equivalent, so a merge can succeed while moving +// nothing at all. Callers must report that outcome honestly instead of +// announcing a completed merge. +func mergedRecordCount(result *store.MergeResult) int64 { + if result == nil { + return 0 + } + return result.ObservationsUpdated + result.SessionsUpdated + result.PromptsUpdated +} + +// reportUnmergedSources names the selected sources the store left untouched, so +// a partially applied merge never reads as a complete one. +func reportUnmergedSources(sources []string, result *store.MergeResult) { + merged := make(map[string]bool, len(result.SourcesMerged)) + for _, name := range result.SourcesMerged { + merged[name] = true + } + + // SourcesMerged holds the trimmed spelling the store actually rewrote, while + // sources holds the raw spellings the operator selected. Only a source that + // is literally the canonical name was a no-op by request. + var skipped []string + seen := make(map[string]bool, len(sources)) + for _, source := range sources { + if strings.TrimSpace(source) == "" || source == result.Canonical { + continue + } + if merged[strings.TrimSpace(source)] || seen[source] { + continue + } + seen[source] = true + skipped = append(skipped, source) + } + if len(skipped) == 0 { + return + } + fmt.Printf(" Not merged (no records moved): %s\n", strings.Join(skipped, ", ")) +} + func cmdProjectsConsolidate(cfg store.Config) { doAll := false dryRun := false @@ -2073,10 +2115,17 @@ func cmdProjectsConsolidate(cfg store.Config) { fatal(err) } - fmt.Printf("Done! Merged into %q:\n", result.Canonical) + if mergedRecordCount(result) == 0 { + fmt.Printf("Nothing merged into %q: the store moved no records for the %d selected project(s).\n", + result.Canonical, len(sources)) + return + } + + fmt.Printf("Done! Merged %d project(s) into %q:\n", len(result.SourcesMerged), result.Canonical) fmt.Printf(" Observations: %d\n", result.ObservationsUpdated) fmt.Printf(" Sessions: %d\n", result.SessionsUpdated) fmt.Printf(" Prompts: %d\n", result.PromptsUpdated) + reportUnmergedSources(sources, result) return } @@ -2194,8 +2243,14 @@ func cmdProjectsConsolidate(cfg store.Config) { fmt.Println() continue } - fmt.Printf(" Merged: %d obs, %d sessions, %d prompts\n", - result.ObservationsUpdated, result.SessionsUpdated, result.PromptsUpdated) + if mergedRecordCount(result) == 0 { + fmt.Printf(" Nothing merged into %q: the store moved no records for the %d selected project(s).\n", + mergeCanonical, len(sources)) + } else { + fmt.Printf(" Merged: %d obs, %d sessions, %d prompts\n", + result.ObservationsUpdated, result.SessionsUpdated, result.PromptsUpdated) + reportUnmergedSources(sources, result) + } if renameTarget != "" && renameTarget != mergeCanonical { migrateResult, err := s.MigrateProject(mergeCanonical, renameTarget) @@ -2214,9 +2269,13 @@ func cmdProjectsConsolidate(cfg store.Config) { func cmdProjectsPrune(cfg store.Config) { dryRun := false + pathsOnly := false for i := 3; i < len(os.Args); i++ { - if os.Args[i] == "--dry-run" { + switch os.Args[i] { + case "--dry-run": dryRun = true + case "--paths-only": + pathsOnly = true } } @@ -2231,15 +2290,21 @@ func cmdProjectsPrune(cfg store.Config) { fatal(err) } - // Find projects with 0 observations + // Find projects with 0 observations. var candidates []store.ProjectStats for _, ps := range allStats { - if ps.ObservationCount == 0 { - candidates = append(candidates, ps) + if ps.ObservationCount != 0 || (pathsOnly && !isPathLikeProjectName(ps.Name)) { + continue } + candidates = append(candidates, ps) } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name < candidates[j].Name }) if len(candidates) == 0 { + if pathsOnly { + fmt.Println("No path-named projects to prune.") + return + } fmt.Println("No empty projects to prune.") return } @@ -2286,17 +2351,23 @@ func cmdProjectsPrune(cfg store.Config) { totalSessions := int64(0) totalPrompts := int64(0) + successful := 0 for _, ps := range selected { - result, err := s.PruneProject(ps.Name) + result, err := storePruneProject(s, ps.Name) if err != nil { fmt.Fprintf(os.Stderr, "Error pruning %q: %v\n", ps.Name, err) continue } + successful++ totalSessions += result.SessionsDeleted totalPrompts += result.PromptsDeleted } - fmt.Printf("\nPruned %d project(s): %d sessions, %d prompts removed.\n", len(selected), totalSessions, totalPrompts) + fmt.Printf("\nPruned %d project(s): %d sessions, %d prompts removed.\n", successful, totalSessions, totalPrompts) +} + +func isPathLikeProjectName(name string) bool { + return strings.ContainsAny(name, `/\`) } // cmdSetup classifies os.Args[2:] with a two-pass, order-independent @@ -2645,6 +2716,10 @@ Commands: Merge similar project names into one canonical name --all Scan ALL projects for similar name groups --dry-run Preview what would be merged (no changes) + projects prune [--dry-run] [--paths-only] + Remove projects with no observations + --dry-run Preview projects without removing data + --paths-only Limit pruning to project names containing / or \ setup [agent] Install/setup agent integration (opencode, pi, claude-code, gemini-cli, codex, antigravity-cli, windsurf, qwen, kiro, cursor, vscode-copilot, kilocode) diff --git a/cmd/engram/main_test.go b/cmd/engram/main_test.go index 352966b22..78be9c631 100644 --- a/cmd/engram/main_test.go +++ b/cmd/engram/main_test.go @@ -1022,7 +1022,7 @@ func TestCmdProjectsConsolidateSingleProject(t *testing.T) { if stderr != "" { t.Fatalf("expected no stderr, got: %q", stderr) } - if !strings.Contains(stdout, "Merged into") { + if !strings.Contains(stdout, `Merged 1 project(s) into "engram"`) { t.Fatalf("expected merge result, got: %q", stdout) } @@ -1062,6 +1062,146 @@ func TestCmdProjectsConsolidateAllDryRun(t *testing.T) { } } +func TestCmdProjectsPrunePathsOnlyDryRun(t *testing.T) { + cfg := testConfig(t) + pathProject := `c:\workspace\orphan` + mustSeedSession(t, cfg, "s-path", pathProject) + mustSeedSession(t, cfg, "s-ordinary", "ordinary-empty") + mustSeedObservation(t, cfg, "s-active", "active-project", "note", "active", "content", "project") + + withArgs(t, "engram", "projects", "prune", "--paths-only", "--dry-run") + stdout, stderr := captureOutput(t, func() { cmdProjectsPrune(cfg) }) + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } + if !strings.Contains(stdout, pathProject) || strings.Contains(stdout, "ordinary-empty") || strings.Contains(stdout, "active-project") { + t.Fatalf("paths-only candidates = %q", stdout) + } + + s, err := store.New(cfg) + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + stats, err := s.ListProjectsWithStats() + if err != nil { + t.Fatalf("ListProjectsWithStats: %v", err) + } + if len(stats) != 3 { + t.Fatalf("dry-run mutated projects: %+v", stats) + } +} + +func TestCmdProjectsPrunePathsOnly(t *testing.T) { + cfg := testConfig(t) + forwardSlashProject := "/tmp/orphan" + backslashProject := `c:\workspace\orphan` + mustSeedPrompt(t, cfg, "s-forward-slash", forwardSlashProject) + mustSeedPrompt(t, cfg, "s-backslash", backslashProject) + mustSeedSession(t, cfg, "s-ordinary", "ordinary-empty") + mustSeedObservation(t, cfg, "s-active", "active-project", "note", "active", "content", "project") + + oldScan := scanInputLine + scanInputLine = func(a ...any) (int, error) { + *a[0].(*string) = "all" + return 1, nil + } + t.Cleanup(func() { scanInputLine = oldScan }) + + withArgs(t, "engram", "projects", "prune", "--paths-only") + stdout, stderr := captureOutput(t, func() { cmdProjectsPrune(cfg) }) + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } + for _, project := range []string{forwardSlashProject, backslashProject} { + if !strings.Contains(stdout, project) { + t.Fatalf("paths-only output missing %q: %q", project, stdout) + } + } + if strings.Contains(stdout, "ordinary-empty") || strings.Contains(stdout, "active-project") { + t.Fatalf("paths-only output included a retained project: %q", stdout) + } + if !strings.Contains(stdout, "Pruned 2 project(s): 2 sessions, 2 prompts removed.") { + t.Fatalf("prune result = %q", stdout) + } + + s, err := store.New(cfg) + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + for _, sessionID := range []string{"s-forward-slash", "s-backslash"} { + if _, err := s.GetSession(sessionID); err == nil { + t.Fatalf("pruned session %q still exists", sessionID) + } + } + stats, err := s.ListProjectsWithStats() + if err != nil { + t.Fatalf("ListProjectsWithStats: %v", err) + } + remaining := make(map[string]store.ProjectStats, len(stats)) + for _, ps := range stats { + remaining[ps.Name] = ps + } + if _, ok := remaining[forwardSlashProject]; ok { + t.Fatalf("pruned project %q still has data: %+v", forwardSlashProject, remaining[forwardSlashProject]) + } + if _, ok := remaining[backslashProject]; ok { + t.Fatalf("pruned project %q still has data: %+v", backslashProject, remaining[backslashProject]) + } + if ordinary, ok := remaining["ordinary-empty"]; !ok || ordinary.SessionCount != 1 { + t.Fatalf("ordinary empty project = %+v, want one retained session", ordinary) + } + if active, ok := remaining["active-project"]; !ok || active.ObservationCount != 1 || active.SessionCount != 1 { + t.Fatalf("active project = %+v, want one retained observation and session", active) + } +} + +func TestCmdProjectsPruneWithoutPathsOnlyKeepsOrdinaryBehavior(t *testing.T) { + cfg := testConfig(t) + mustSeedSession(t, cfg, "s-path", `c:\workspace\orphan`) + mustSeedSession(t, cfg, "s-ordinary", "ordinary-empty") + + withArgs(t, "engram", "projects", "prune", "--dry-run") + stdout, stderr := captureOutput(t, func() { cmdProjectsPrune(cfg) }) + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } + if !strings.Contains(stdout, `c:\workspace\orphan`) || !strings.Contains(stdout, "ordinary-empty") { + t.Fatalf("ordinary prune candidates = %q", stdout) + } +} + +func TestCmdProjectsPruneReportsOnlySuccessfulProjects(t *testing.T) { + cfg := testConfig(t) + mustSeedSession(t, cfg, "s-success", "success-empty") + mustSeedSession(t, cfg, "s-failure", "failure-empty") + + oldPrune := storePruneProject + storePruneProject = func(s *store.Store, project string) (*store.PruneResult, error) { + if project == "failure-empty" { + return nil, errors.New("forced failure") + } + return oldPrune(s, project) + } + t.Cleanup(func() { storePruneProject = oldPrune }) + oldScan := scanInputLine + scanInputLine = func(a ...any) (int, error) { + *a[0].(*string) = "all" + return 1, nil + } + t.Cleanup(func() { scanInputLine = oldScan }) + + withArgs(t, "engram", "projects", "prune") + stdout, stderr := captureOutput(t, func() { cmdProjectsPrune(cfg) }) + if !strings.Contains(stderr, `Error pruning "failure-empty": forced failure`) { + t.Fatalf("stderr = %q", stderr) + } + if !strings.Contains(stdout, "Pruned 1 project(s): 1 sessions, 0 prompts removed.") { + t.Fatalf("stdout = %q", stdout) + } +} + func TestCmdProjectsConsolidateAllRenameMigratesMergedIdentity(t *testing.T) { cfg := testConfig(t) @@ -1148,6 +1288,279 @@ func TestGroupSimilarProjectsUsesNormalizationEquivalenceAndNormalizedCanonical( } } +// projectRecordCounts reports how many observations, sessions and prompts are +// stored under an exact project spelling, so tests can compare the counts the +// CLI printed against the records that actually moved. +func projectRecordCounts(t *testing.T, cfg store.Config, project string) (observations, sessions, prompts int) { + t.Helper() + + db, err := sql.Open("sqlite", filepath.Join(cfg.DataDir, "engram.db")) + if err != nil { + t.Fatalf("open database: %v", err) + } + defer db.Close() + + queries := []struct { + query string + dest *int + }{ + {`SELECT COUNT(*) FROM observations WHERE project = ? AND deleted_at IS NULL`, &observations}, + {`SELECT COUNT(*) FROM sessions WHERE project = ?`, &sessions}, + {`SELECT COUNT(*) FROM user_prompts WHERE project = ?`, &prompts}, + } + for _, q := range queries { + if err := db.QueryRow(q.query, project).Scan(q.dest); err != nil { + t.Fatalf("count %q rows: %v", project, err) + } + } + return observations, sessions, prompts +} + +func TestCmdProjectsConsolidateCaseOnlyVariantReportsMovedRecords(t *testing.T) { + cfg := testConfig(t) + + // A case-only legacy spelling must actually move its records, and the + // printed counts must match what moved. + mustSeedObservation(t, cfg, "s-eng", "engram", "note", "eng note", "content", "project") + mustSeedObservation(t, cfg, "s-legacy", "legacy-source", "note", "legacy note", "content", "project") + mustSeedPrompt(t, cfg, "s-legacy", "legacy-source") + rewriteLegacyProjectName(t, cfg, "legacy-source", "ENGRAM") + + old := detectProject + detectProject = func(string) string { return "engram" } + t.Cleanup(func() { detectProject = old }) + + oldScan := scanInputLine + t.Cleanup(func() { scanInputLine = oldScan }) + scanInputLine = func(a ...any) (int, error) { + if ptr, ok := a[0].(*string); ok { + *ptr = "all" + } + return 1, nil + } + + withArgs(t, "engram", "projects", "consolidate") + stdout, stderr := captureOutput(t, func() { cmdProjectsConsolidate(cfg) }) + if stderr != "" { + t.Fatalf("expected no stderr, got: %q", stderr) + } + + for _, want := range []string{ + `Done! Merged 1 project(s) into "engram"`, + "Observations: 1", + "Sessions: 1", + "Prompts: 1", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("expected %q in merge report, got: %q", want, stdout) + } + } + + // The reported counts must match the records that actually moved. + if obs, sessions, prompts := projectRecordCounts(t, cfg, "ENGRAM"); obs+sessions+prompts != 0 { + t.Fatalf("legacy spelling still holds records: obs=%d sessions=%d prompts=%d", obs, sessions, prompts) + } + obs, sessions, prompts := projectRecordCounts(t, cfg, "engram") + if obs != 2 || sessions != 2 || prompts != 1 { + t.Fatalf("canonical records = obs:%d sessions:%d prompts:%d, want 2/2/1", obs, sessions, prompts) + } +} + +func TestCmdProjectsConsolidateReportsNothingMergedWhenNoRecordsMove(t *testing.T) { + cfg := testConfig(t) + + // " engram " normalizes to the canonical name, so it is offered as a + // candidate, but the store fail-closes on it because its trimmed spelling + // is the canonical name itself. The CLI must not announce completion. + mustSeedObservation(t, cfg, "s-legacy", "legacy-source", "note", "legacy note", "content", "project") + rewriteLegacyProjectName(t, cfg, "legacy-source", " engram ") + + old := detectProject + detectProject = func(string) string { return "engram" } + t.Cleanup(func() { detectProject = old }) + + oldScan := scanInputLine + t.Cleanup(func() { scanInputLine = oldScan }) + scanInputLine = func(a ...any) (int, error) { + if ptr, ok := a[0].(*string); ok { + *ptr = "all" + } + return 1, nil + } + + withArgs(t, "engram", "projects", "consolidate") + stdout, stderr := captureOutput(t, func() { cmdProjectsConsolidate(cfg) }) + if stderr != "" { + t.Fatalf("expected no stderr, got: %q", stderr) + } + if strings.Contains(stdout, "Done!") { + t.Fatalf("completion reported without moving records: %q", stdout) + } + if !strings.Contains(stdout, "Nothing merged") { + t.Fatalf("expected an honest no-op report, got: %q", stdout) + } + + // The records must still be reachable under their original spelling. + if obs, sessions, _ := projectRecordCounts(t, cfg, " engram "); obs != 1 || sessions != 1 { + t.Fatalf("legacy records lost: obs=%d sessions=%d", obs, sessions) + } +} + +func TestCmdProjectsConsolidateAllCaseOnlyVariantReportsMovedRecords(t *testing.T) { + cfg := testConfig(t) + + mustSeedObservation(t, cfg, "s-eng", "engram", "note", "eng note", "content", "project") + mustSeedObservation(t, cfg, "s-legacy", "legacy-source", "note", "legacy note", "content", "project") + mustSeedPrompt(t, cfg, "s-legacy", "legacy-source") + rewriteLegacyProjectName(t, cfg, "legacy-source", "ENGRAM") + + oldScan := scanInputLine + t.Cleanup(func() { scanInputLine = oldScan }) + scanInputLine = func(a ...any) (int, error) { + if ptr, ok := a[0].(*string); ok { + *ptr = "all" + } + return 1, nil + } + + withArgs(t, "engram", "projects", "consolidate", "--all") + stdout, stderr := captureOutput(t, func() { cmdProjectsConsolidate(cfg) }) + if stderr != "" { + t.Fatalf("expected no stderr, got: %q", stderr) + } + if !strings.Contains(stdout, "Merged: 1 obs, 1 sessions, 1 prompts") { + t.Fatalf("expected counts matching the moved records, got: %q", stdout) + } + if obs, sessions, prompts := projectRecordCounts(t, cfg, "ENGRAM"); obs+sessions+prompts != 0 { + t.Fatalf("legacy spelling still holds records: obs=%d sessions=%d prompts=%d", obs, sessions, prompts) + } + obs, sessions, prompts := projectRecordCounts(t, cfg, "engram") + if obs != 2 || sessions != 2 || prompts != 1 { + t.Fatalf("canonical records = obs:%d sessions:%d prompts:%d, want 2/2/1", obs, sessions, prompts) + } +} + +func TestCmdProjectsConsolidateAllReportsNothingMergedWhenNoRecordsMove(t *testing.T) { + cfg := testConfig(t) + + mustSeedObservation(t, cfg, "s-eng", "engram", "note", "eng note", "content", "project") + mustSeedObservation(t, cfg, "s-legacy", "legacy-source", "note", "legacy note", "content", "project") + rewriteLegacyProjectName(t, cfg, "legacy-source", " engram ") + + oldScan := scanInputLine + t.Cleanup(func() { scanInputLine = oldScan }) + scanInputLine = func(a ...any) (int, error) { + if ptr, ok := a[0].(*string); ok { + *ptr = "all" + } + return 1, nil + } + + withArgs(t, "engram", "projects", "consolidate", "--all") + stdout, stderr := captureOutput(t, func() { cmdProjectsConsolidate(cfg) }) + if stderr != "" { + t.Fatalf("expected no stderr, got: %q", stderr) + } + if strings.Contains(stdout, "Merged:") { + t.Fatalf("merge reported without moving records: %q", stdout) + } + if !strings.Contains(stdout, "Nothing merged") { + t.Fatalf("expected an honest no-op report, got: %q", stdout) + } + if obs, sessions, _ := projectRecordCounts(t, cfg, " engram "); obs != 1 || sessions != 1 { + t.Fatalf("legacy records lost: obs=%d sessions=%d", obs, sessions) + } +} + +func TestCmdProjectsConsolidateAllNamesSourcesTheStoreLeftUntouched(t *testing.T) { + cfg := testConfig(t) + + // "ENGRAM" moves; " engram " is fail-closed by the store because its + // trimmed spelling is the canonical name. A partial merge must say so. + mustSeedObservation(t, cfg, "s-eng", "engram", "note", "eng note", "content", "project") + mustSeedObservation(t, cfg, "s-upper", "upper-source", "note", "upper note", "content", "project") + mustSeedObservation(t, cfg, "s-padded", "padded-source", "note", "padded note", "content", "project") + rewriteLegacyProjectName(t, cfg, "upper-source", "ENGRAM") + rewriteLegacyProjectName(t, cfg, "padded-source", " engram ") + + oldScan := scanInputLine + t.Cleanup(func() { scanInputLine = oldScan }) + scanInputLine = func(a ...any) (int, error) { + if ptr, ok := a[0].(*string); ok { + *ptr = "all" + } + return 1, nil + } + + withArgs(t, "engram", "projects", "consolidate", "--all") + stdout, stderr := captureOutput(t, func() { cmdProjectsConsolidate(cfg) }) + if stderr != "" { + t.Fatalf("expected no stderr, got: %q", stderr) + } + if !strings.Contains(stdout, "Merged: 1 obs, 1 sessions, 0 prompts") { + t.Fatalf("expected counts for the single moved source, got: %q", stdout) + } + if !strings.Contains(stdout, "Not merged (no records moved): engram ") { + t.Fatalf("expected the untouched source to be named, got: %q", stdout) + } + if obs, sessions, _ := projectRecordCounts(t, cfg, " engram "); obs != 1 || sessions != 1 { + t.Fatalf("untouched source lost records: obs=%d sessions=%d", obs, sessions) + } +} + +func TestCmdProjectsConsolidateLeavesFuzzyMatchesUnmerged(t *testing.T) { + // Substring and Levenshtein neighbours are not normalization-equivalent, so + // neither cleanup route may merge them or touch their records. + tests := []struct { + name string + canonical string + candidate string + }{ + {name: "substring", canonical: "engram", candidate: "engram-memory"}, + {name: "levenshtein", canonical: "engram", candidate: "engramm"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, args := range [][]string{ + {"engram", "projects", "consolidate"}, + {"engram", "projects", "consolidate", "--all"}, + } { + cfg := testConfig(t) + mustSeedObservation(t, cfg, "s-canonical", tt.canonical, "note", "canonical", "content", "project") + mustSeedObservation(t, cfg, "s-candidate", tt.candidate, "note", "candidate", "content", "project") + + old := detectProject + detectProject = func(string) string { return tt.canonical } + t.Cleanup(func() { detectProject = old }) + + oldScan := scanInputLine + t.Cleanup(func() { scanInputLine = oldScan }) + scanInputLine = func(a ...any) (int, error) { + if ptr, ok := a[0].(*string); ok { + *ptr = "all" + } + return 1, nil + } + + withArgs(t, args...) + stdout, stderr := captureOutput(t, func() { cmdProjectsConsolidate(cfg) }) + if stderr != "" { + t.Fatalf("%v: expected no stderr, got: %q", args, stderr) + } + if !strings.Contains(stdout, "No similar") { + t.Fatalf("%v: fuzzy candidate offered for merge: %q", args, stdout) + } + for _, project := range []string{tt.canonical, tt.candidate} { + if obs, sessions, _ := projectRecordCounts(t, cfg, project); obs != 1 || sessions != 1 { + t.Fatalf("%v: %q records changed: obs=%d sessions=%d", args, project, obs, sessions) + } + } + } + }) + } +} + func TestCmdMCPDetectsProjectFromFlag(t *testing.T) { cfg := testConfig(t) diff --git a/internal/store/store.go b/internal/store/store.go index 28d3df9f6..a7b89efcc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5198,9 +5198,9 @@ type PruneResult struct { PromptsDeleted int64 `json:"prompts_deleted"` } -// PruneProject removes all sessions and prompts for a project that has zero -// (non-deleted) observations. Returns an error if the project still has -// observations — the caller must verify first. +// PruneProject removes prompts and sessions without observations for a project +// that has zero active observations. Soft-deleted observations and their +// sessions are retained. func (s *Store) PruneProject(project string) (*PruneResult, error) { if project == "" { return nil, fmt.Errorf("project name must not be empty") @@ -5224,7 +5224,9 @@ func (s *Store) PruneProject(project string) (*PruneResult, error) { } result.PromptsDeleted, _ = res.RowsAffected() - res, err = s.execHook(tx, `DELETE FROM sessions WHERE project = ?`, project) + res, err = s.execHook(tx, `DELETE FROM sessions + WHERE project = ? + AND NOT EXISTS (SELECT 1 FROM observations WHERE observations.session_id = sessions.id)`, project) if err != nil { return fmt.Errorf("prune sessions: %w", err) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 678d297f2..50cc1d42c 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -8032,6 +8032,75 @@ func TestCountObservationsForProject(t *testing.T) { } } +func TestPruneProjectPreservesSoftDeletedObservationSession(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("referenced", "empty-project", "/work"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + observationID, err := s.AddObservation(AddObservationParams{SessionID: "referenced", Type: "note", Title: "deleted", Content: "deleted content", Project: "empty-project", Scope: "project"}) + if err != nil { + t.Fatalf("AddObservation: %v", err) + } + if err := s.DeleteObservation(observationID, false); err != nil { + t.Fatalf("DeleteObservation: %v", err) + } + if _, err := s.AddPrompt(AddPromptParams{SessionID: "referenced", Content: "remove me", Project: "empty-project"}); err != nil { + t.Fatalf("AddPrompt: %v", err) + } + + result, err := s.PruneProject("empty-project") + if err != nil { + t.Fatalf("PruneProject: %v", err) + } + if result.PromptsDeleted != 1 || result.SessionsDeleted != 0 { + t.Fatalf("PruneResult = %+v, want one prompt and no sessions", result) + } + var sessions, observations, prompts int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM sessions WHERE id = 'referenced'`).Scan(&sessions); err != nil { + t.Fatalf("count sessions: %v", err) + } + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM observations WHERE id = ? AND deleted_at IS NOT NULL`, observationID).Scan(&observations); err != nil { + t.Fatalf("count observations: %v", err) + } + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM user_prompts WHERE project = 'empty-project'`).Scan(&prompts); err != nil { + t.Fatalf("count prompts: %v", err) + } + if sessions != 1 || observations != 1 || prompts != 0 { + t.Fatalf("rows after prune: sessions=%d observations=%d prompts=%d", sessions, observations, prompts) + } +} + +func TestPruneProjectDeletesOnlyUnreferencedSessions(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("referenced", "empty-project", "/work"); err != nil { + t.Fatalf("CreateSession referenced: %v", err) + } + if err := s.CreateSession("unreferenced", "empty-project", "/work"); err != nil { + t.Fatalf("CreateSession unreferenced: %v", err) + } + id, err := s.AddObservation(AddObservationParams{SessionID: "referenced", Type: "note", Title: "deleted", Content: "deleted content", Project: "empty-project", Scope: "project"}) + if err != nil { + t.Fatalf("AddObservation: %v", err) + } + if err := s.DeleteObservation(id, false); err != nil { + t.Fatalf("DeleteObservation: %v", err) + } + + result, err := s.PruneProject("empty-project") + if err != nil { + t.Fatalf("PruneProject: %v", err) + } + if result.SessionsDeleted != 1 || result.PromptsDeleted != 0 { + t.Fatalf("PruneResult = %+v, want one session and no prompts", result) + } + var referenced, unreferenced int + _ = s.DB().QueryRow(`SELECT COUNT(*) FROM sessions WHERE id = 'referenced'`).Scan(&referenced) + _ = s.DB().QueryRow(`SELECT COUNT(*) FROM sessions WHERE id = 'unreferenced'`).Scan(&unreferenced) + if referenced != 1 || unreferenced != 0 { + t.Fatalf("sessions after prune: referenced=%d unreferenced=%d", referenced, unreferenced) + } +} + // ─── DeleteSession tests ───────────────────────────────────────────────────── func TestRecentObservationsOrderByCreatedAtBeforeID(t *testing.T) {