diff --git a/DOCS.md b/DOCS.md index 3ff9428a..06435284 100644 --- a/DOCS.md +++ b/DOCS.md @@ -745,7 +745,7 @@ For monorepos, detection now honors the **nearest** `.engram/config.json` at or ### Admin tools -`mem_delete` is ID-based and requires `id`; optional `hard_delete=true` permanently deletes the observation. It does not accept or auto-detect `project`. +`mem_delete` is ID-based and requires `id`; it always soft-deletes by setting `deleted_at` while preserving the original content and metadata. It does not accept or auto-detect `project`, and it does not expose permanent deletion. `mem_merge_projects` requires `from` (comma-separated source project names) and `to` (canonical target project name). It does not accept or auto-detect `project`. @@ -816,7 +816,7 @@ Suggest a stable `topic_key` from `type + title` (or content fallback). Uses fam ### mem_delete -Delete an observation by ID. Uses soft-delete by default (`deleted_at`); optional hard-delete for permanent removal. +Soft-delete an observation by ID. The MCP tool always sets `deleted_at` while preserving the original content and metadata; permanent removal is handled by local curation flows such as the TUI Recycle Bin, not by MCP. ### mem_save_prompt @@ -1149,12 +1149,15 @@ Interactive Bubbletea-based terminal UI. Launch with `engram tui`. | **Timeline** | Chronological context around an observation (before/after) | | **Sessions** | Browse all sessions | | **Session Detail** | Observations within a specific session | +| **Recycle Bin** | Deleted memories with restore and permanent-delete actions | ### Navigation - `j/k` or arrow keys — Navigate lists - `Enter` — Select / drill into detail - `c` — Copy observation content to clipboard (OSC 52; works in search results, recent list, detail, and session views) +- `r` — Restore a deleted observation from deleted-memory views +- `d` or `delete` — Delete the selected item after confirmation; deleted observations can be permanently removed from deleted-memory views - `t` — View timeline for selected observation - `s` or `/` — Quick search from any screen - `Esc` or `q` — Go back / quit @@ -1164,6 +1167,7 @@ Interactive Bubbletea-based terminal UI. Launch with `engram tui`. - **Catppuccin Mocha** color palette - **`(active)` badge** — shown next to sessions and observations from active sessions, sorted to top +- **Deleted-memory rows** — Search memories, Recent observations, Browse sessions, and Recycle Bin can include deleted observations; they render red with `[deleted]` - **Scroll indicators** — position in long lists (e.g. "showing 1-20 of 50") - **2-line items** — each observation shows title + content preview diff --git a/README.md b/README.md index aa845adf..6d562d30 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ engram tui **Navigation**: `j/k` vim keys, `Enter` to drill in, `c` to copy content to clipboard (OSC 52), `/` to search, `Esc` back. Catppuccin Mocha theme. +The TUI can show deleted memories in Search memories, Recent observations, Browse sessions, and Recycle Bin; deleted rows render red with `[deleted]`, can be restored with `r`, and can be permanently removed with `d` after confirmation. + ## Git Sync Share memories across machines. Uses compressed chunks — no merge conflicts, no huge files. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2044637f..19adaaa5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -55,7 +55,7 @@ Next session starts → Previous session context is injected automatically |------|---------| | `mem_save` | Save a structured observation (decision, bugfix, pattern, etc.); best-effort captures process-local current prompt context when available unless `capture_prompt=false` | | `mem_update` | Update an existing observation by ID | -| `mem_delete` | Delete an observation (soft-delete by default, hard-delete optional) | +| `mem_delete` | Soft-delete an observation by setting `deleted_at` while preserving content | | `mem_suggest_topic_key` | Suggest a stable `topic_key` for evolving topics before saving | | `mem_search` | Full-text search across all memories | | `mem_session_summary` | Save end-of-session summary | @@ -95,8 +95,9 @@ Token-efficient memory retrieval — don't dump everything, drill in: - Exact dedupe prevents repeated inserts in a rolling window (hash + project + scope + type + title) - Duplicates update metadata (`duplicate_count`, `last_seen_at`, `updated_at`) instead of creating new rows - Topic upserts increment `revision_count` so evolving decisions stay in one memory -- `mem_delete` uses soft-delete by default (`deleted_at`), with optional hard delete -- `mem_search`, `mem_context`, recent lists, and timeline ignore soft-deleted observations +- MCP `mem_delete` soft-deletes by setting `deleted_at` while preserving content and metadata +- `mem_search`, `mem_context`, recent lists, and timeline ignore soft-deleted observations by default; TUI browse paths can opt into deleted rows for restore workflows +- The TUI Recycle Bin can restore deleted observations or permanently purge them after confirmation --- @@ -185,7 +186,7 @@ architecture/auth/model/detail ✗ three levels — flatten to two ### Lifecycle and pruning -Topic keys are not pruned automatically. An observation updated via upsert keeps a single row with the latest content and an incremented `revision_count`. Use `mem_delete` to remove an observation (soft-delete by default) when a topic is no longer relevant. Soft-deleted observations are excluded from search and context but their IDs remain in the store for audit purposes. Use `--hard` to remove them permanently. +Topic keys are not pruned automatically. An observation updated via upsert keeps a single row with the latest content and an incremented `revision_count`. Use `mem_delete` to soft-delete an observation when a topic is no longer relevant. Soft-deleted observations are excluded from default search and context, while IDs and content remain in the store for audit and restore workflows. The TUI Recycle Bin can restore them or permanently purge them after confirmation. ### Scope interaction diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index e4958c29..c6351380 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -426,7 +426,7 @@ Examples: if shouldRegister("mem_delete", allowlist) { srv.AddTool( mcp.NewTool("mem_delete", - mcp.WithDescription("Delete an observation by ID. Soft-delete by default; set hard_delete=true for permanent deletion."), + mcp.WithDescription("Soft-delete an observation by ID, moving it to the recycle bin while preserving content for TUI restoration."), mcp.WithDeferLoading(true), mcp.WithTitleAnnotation("Delete Memory"), mcp.WithReadOnlyHintAnnotation(false), @@ -437,9 +437,6 @@ Examples: mcp.Required(), mcp.Description("Observation ID to delete"), ), - mcp.WithBoolean("hard_delete", - mcp.Description("If true, permanently deletes the observation"), - ), ), queuedWriteHandler(writeQueue, handleDelete(s)), ) @@ -1312,16 +1309,11 @@ func handleDelete(s *store.Store) server.ToolHandlerFunc { return mcp.NewToolResultError("id is required"), nil } - hardDelete := boolArg(req, "hard_delete", false) - if err := s.DeleteObservation(id, hardDelete); err != nil { + if err := s.DeleteObservation(id, false); err != nil { return mcp.NewToolResultError("Failed to delete memory: " + err.Error()), nil } - mode := "soft-deleted" - if hardDelete { - mode = "permanently deleted" - } - return mcp.NewToolResultText(fmt.Sprintf("Memory #%d %s", id, mode)), nil + return mcp.NewToolResultText(fmt.Sprintf("Memory #%d soft-deleted and moved to the recycle bin", id)), nil } } diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index c063f54c..86e30ed5 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -843,8 +844,69 @@ func TestHandleSearchAndCRUDHandlers(t *testing.T) { if delRes.IsError { t.Fatalf("unexpected delete error: %s", callResultText(t, delRes)) } - if !strings.Contains(callResultText(t, delRes), "permanently deleted") { - t.Fatalf("expected hard delete message") + deleteText := callResultText(t, delRes) + if !strings.Contains(deleteText, "soft-deleted") { + t.Fatalf("expected soft delete message, got %q", deleteText) + } + if strings.Contains(deleteText, "permanently deleted") { + t.Fatalf("MCP delete must not report permanent deletion, got %q", deleteText) + } +} + +func TestHandleDeleteAlwaysSoftDeletesAndPreservesContent(t *testing.T) { + s := newMCPTestStore(t) + if err := s.CreateSession("s-delete", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + obsID, err := s.AddObservation(store.AddObservationParams{ + SessionID: "s-delete", + Type: "decision", + Title: "Preserve me", + Content: "Original content must remain available in the recycle bin", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + + deleteHandler := handleDelete(s) + req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "id": float64(obsID), + "hard_delete": true, + }}} + res, err := deleteHandler(context.Background(), req) + if err != nil { + t.Fatalf("delete handler error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected delete error: %s", callResultText(t, res)) + } + deleteText := callResultText(t, res) + if !strings.Contains(deleteText, "soft-deleted") || !strings.Contains(deleteText, "recycle bin") { + t.Fatalf("expected recycle-bin soft delete message, got %q", deleteText) + } + if strings.Contains(deleteText, "permanently deleted") { + t.Fatalf("MCP delete must never report permanent deletion, got %q", deleteText) + } + + if _, err := s.GetObservation(obsID); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("GetObservation after soft delete err = %v, want sql.ErrNoRows", err) + } + + deleted, err := s.GetObservationIncludingDeleted(obsID) + if err != nil { + t.Fatalf("GetObservationIncludingDeleted: %v", err) + } + if deleted.DeletedAt == nil || *deleted.DeletedAt == "" { + t.Fatalf("expected deleted_at to be set, got %#v", deleted.DeletedAt) + } + if deleted.Title != "Preserve me" { + t.Fatalf("title after delete = %q, want %q", deleted.Title, "Preserve me") + } + if deleted.Content != "Original content must remain available in the recycle bin" { + t.Fatalf("content after delete = %q", deleted.Content) } } @@ -2476,7 +2538,7 @@ func TestExplicitSessionIDBypassesDefault(t *testing.T) { } } -func TestDestructiveToolAnnotation(t *testing.T) { +func TestNewServerToolAnnotations(t *testing.T) { s := newMCPTestStore(t) srv := NewServer(s) tools := srv.ListTools() @@ -2492,6 +2554,22 @@ func TestDestructiveToolAnnotation(t *testing.T) { if ann.ReadOnlyHint == nil || *ann.ReadOnlyHint { t.Error("mem_delete should NOT be marked readOnly") } + if !tool.Tool.DeferLoading { + t.Error("mem_delete should have DeferLoading=true") + } + props := tool.Tool.InputSchema.Properties + if _, ok := props["id"]; !ok { + t.Fatal("mem_delete schema must include id") + } + if _, ok := props["hard_delete"]; ok { + t.Fatal("mem_delete schema must not advertise hard_delete") + } + if strings.Contains(tool.Tool.Description, "hard_delete") || strings.Contains(tool.Tool.Description, "permanent") { + t.Fatalf("mem_delete description must not advertise hard delete, got %q", tool.Tool.Description) + } + if !strings.Contains(tool.Tool.Description, "recycle bin") { + t.Fatalf("mem_delete description should mention recycle bin semantics, got %q", tool.Tool.Description) + } } // ─── Phase 3: MCPConfig, Default Project, Normalization, Similar Warnings ──── diff --git a/internal/store/store.go b/internal/store/store.go index 8000bee9..669a35e0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -153,10 +153,18 @@ type TimelineResult struct { } type SearchOptions struct { - Type string `json:"type,omitempty"` - Project string `json:"project,omitempty"` - Scope string `json:"scope,omitempty"` - Limit int `json:"limit,omitempty"` + Type string `json:"type,omitempty"` + Project string `json:"project,omitempty"` + Scope string `json:"scope,omitempty"` + Limit int `json:"limit,omitempty"` + IncludeDeleted bool `json:"include_deleted,omitempty"` +} + +type ObservationListOptions struct { + Project string + Scope string + Limit int + IncludeDeleted bool } type AddObservationParams struct { @@ -1007,6 +1015,9 @@ func (s *Store) migrate() error { if err := s.migrateFTSTopicKey(); err != nil { return err } + if err := s.repairObservationFTSDeletedRows(); err != nil { + return err + } // Prompts FTS triggers (separate idempotent check) var promptTrigger string @@ -1951,8 +1962,7 @@ func (s *Store) migrateFTSTopicKey() error { ); INSERT INTO observations_fts(rowid, title, content, tool_name, type, project, topic_key) SELECT id, title, content, tool_name, type, project, topic_key - FROM observations - WHERE deleted_at IS NULL; + FROM observations; CREATE TRIGGER obs_fts_insert AFTER INSERT ON observations BEGIN INSERT INTO observations_fts(rowid, title, content, tool_name, type, project, topic_key) @@ -1976,6 +1986,20 @@ func (s *Store) migrateFTSTopicKey() error { return nil } +func (s *Store) repairObservationFTSDeletedRows() error { + var deletedCount int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM observations WHERE deleted_at IS NOT NULL`).Scan(&deletedCount); err != nil { + return fmt.Errorf("repair observations fts: count deleted rows: %w", err) + } + if deletedCount == 0 { + return nil + } + if _, err := s.execHook(s.db, `INSERT INTO observations_fts(observations_fts) VALUES('rebuild')`); err != nil { + return fmt.Errorf("repair observations fts: %w", err) + } + return nil +} + // ─── Sessions ──────────────────────────────────────────────────────────────── func (s *Store) CreateSession(id, project, directory string) error { @@ -2144,7 +2168,7 @@ func (s *Store) AllSessions(project string, limit int) ([]SessionSummary, error) SELECT s.id, s.project, s.started_at, s.ended_at, s.summary, COUNT(o.id) as observation_count FROM sessions s - LEFT JOIN observations o ON o.session_id = s.id AND o.deleted_at IS NULL + LEFT JOIN observations o ON o.session_id = s.id WHERE 1=1 ` args := []any{} @@ -2176,35 +2200,74 @@ func (s *Store) AllSessions(project string, limit int) ([]SessionSummary, error) // AllObservations returns recent observations ordered by most recent first (for TUI browsing). func (s *Store) AllObservations(project, scope string, limit int) ([]Observation, error) { - if limit <= 0 { - limit = s.cfg.MaxContextResults + return s.AllObservationsWithOptions(ObservationListOptions{ + Project: project, + Scope: scope, + Limit: limit, + }) +} + +func (s *Store) AllObservationsWithOptions(opts ObservationListOptions) ([]Observation, error) { + if opts.Limit <= 0 { + opts.Limit = s.cfg.MaxContextResults } + opts.Project, _ = NormalizeProject(opts.Project) query := ` SELECT o.id, ifnull(o.sync_id, '') as sync_id, o.session_id, o.type, o.title, o.content, o.tool_name, o.project, o.scope, o.topic_key, o.revision_count, o.duplicate_count, o.last_seen_at, o.created_at, o.updated_at, o.deleted_at FROM observations o - WHERE o.deleted_at IS NULL + WHERE 1=1 ` args := []any{} - if project != "" { - query += " AND o.project = ?" - args = append(args, project) + if !opts.IncludeDeleted { + query += " AND o.deleted_at IS NULL" } - if scope != "" { + if opts.Project != "" { + query += " AND LOWER(o.project) = ?" + args = append(args, opts.Project) + } + if opts.Scope != "" { query += " AND o.scope = ?" - args = append(args, normalizeScope(scope)) + args = append(args, normalizeScope(opts.Scope)) } query += " ORDER BY datetime(o.created_at) DESC, o.id DESC LIMIT ?" - args = append(args, limit) + args = append(args, opts.Limit) return s.queryObservations(query, args...) } // SessionObservations returns all observations for a specific session. func (s *Store) SessionObservations(sessionID string, limit int) ([]Observation, error) { + return s.SessionObservationsWithOptions(sessionID, ObservationListOptions{Limit: limit}) +} + +func (s *Store) SessionObservationsWithOptions(sessionID string, opts ObservationListOptions) ([]Observation, error) { + if opts.Limit <= 0 { + opts.Limit = 200 + } + + query := ` + SELECT id, ifnull(sync_id, '') as sync_id, session_id, type, title, content, tool_name, project, + scope, topic_key, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at + FROM observations + WHERE session_id = ? + ` + args := []any{sessionID} + + if !opts.IncludeDeleted { + query += " AND deleted_at IS NULL" + } + + query += " ORDER BY created_at ASC LIMIT ?" + args = append(args, opts.Limit) + + return s.queryObservations(query, args...) +} + +func (s *Store) DeletedObservations(limit int) ([]Observation, error) { if limit <= 0 { limit = 200 } @@ -2213,11 +2276,11 @@ func (s *Store) SessionObservations(sessionID string, limit int) ([]Observation, SELECT id, ifnull(sync_id, '') as sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at FROM observations - WHERE session_id = ? AND deleted_at IS NULL - ORDER BY created_at ASC + WHERE deleted_at IS NOT NULL + ORDER BY datetime(deleted_at) DESC, id DESC LIMIT ? ` - return s.queryObservations(query, sessionID, limit) + return s.queryObservations(query, limit) } // ─── Observations ──────────────────────────────────────────────────────────── @@ -2576,6 +2639,13 @@ func (s *Store) SearchPrompts(query string, project string, limit int) ([]Prompt // ─── Delete Session ────────────────────────────────────────────────────────── +type DeleteSessionCascadeResult struct { + SessionID string `json:"session_id"` + ObservationsDeleted int64 `json:"observations_deleted"` + PromptsDeleted int64 `json:"prompts_deleted"` + SessionsDeleted int64 `json:"sessions_deleted"` +} + // DeleteSession hard-deletes a session and its prompts. // It returns ErrSessionHasObservations if the session has any observations // (including soft-deleted ones) to prevent orphaned rows. @@ -2652,6 +2722,121 @@ func (s *Store) DeleteSession(id string) error { }) } +func (s *Store) DeleteSessionCascade(id string) (*DeleteSessionCascadeResult, error) { + result := &DeleteSessionCascadeResult{SessionID: id} + err := s.withTx(func(tx *sql.Tx) error { + var project string + if err := tx.QueryRow(`SELECT project FROM sessions WHERE id = ?`, id).Scan(&project); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: %q", ErrSessionNotFound, id) + } + return fmt.Errorf("delete session cascade: load session: %w", err) + } + + var enrolled int + if err := tx.QueryRow(`SELECT 1 FROM sync_enrolled_projects WHERE project = ? LIMIT 1`, project).Scan(&enrolled); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("delete session cascade: check enrollment: %w", err) + } + } + + rows, err := s.queryItHook(tx, ` + SELECT id, ifnull(sync_id, '') as sync_id, session_id, type, title, content, tool_name, project, + scope, topic_key, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at + FROM observations + WHERE session_id = ? + ORDER BY id ASC + `, id) + if err != nil { + return fmt.Errorf("delete session cascade: list observations: %w", err) + } + var observations []Observation + for rows.Next() { + var obs Observation + if err := rows.Scan( + &obs.ID, &obs.SyncID, &obs.SessionID, &obs.Type, &obs.Title, &obs.Content, + &obs.ToolName, &obs.Project, &obs.Scope, &obs.TopicKey, &obs.RevisionCount, &obs.DuplicateCount, &obs.LastSeenAt, + &obs.CreatedAt, &obs.UpdatedAt, &obs.DeletedAt, + ); err != nil { + return closeRowsWithError(rows, fmt.Errorf("delete session cascade: scan observation: %w", err)) + } + observations = append(observations, obs) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("delete session cascade: close observations: %w", err) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("delete session cascade: list observations: %w", err) + } + + now := Now() + for _, obs := range observations { + if strings.TrimSpace(obs.SyncID) == "" { + continue + } + if _, err := s.execHook(tx, ` + UPDATE memory_relations + SET judgment_status = 'orphaned', + updated_at = datetime('now') + WHERE source_id = ? OR target_id = ? + `, obs.SyncID, obs.SyncID); err != nil { + return fmt.Errorf("delete session cascade: orphan memory_relations: %w", err) + } + deletedAt := now + if obs.DeletedAt != nil && strings.TrimSpace(*obs.DeletedAt) != "" { + deletedAt = *obs.DeletedAt + } + if err := s.enqueueSyncMutationTx(tx, SyncEntityObservation, obs.SyncID, SyncOpDelete, syncObservationPayload{ + SyncID: obs.SyncID, + SessionID: obs.SessionID, + Project: obs.Project, + Deleted: true, + DeletedAt: &deletedAt, + HardDelete: true, + }); err != nil { + return fmt.Errorf("delete session cascade: enqueue observation hard-delete: %w", err) + } + } + + res, err := s.execHook(tx, `DELETE FROM observations WHERE session_id = ?`, id) + if err != nil { + return fmt.Errorf("delete session cascade: delete observations: %w", err) + } + result.ObservationsDeleted, _ = res.RowsAffected() + + res, err = s.execHook(tx, `DELETE FROM user_prompts WHERE session_id = ?`, id) + if err != nil { + return fmt.Errorf("delete session cascade: remove prompts: %w", err) + } + result.PromptsDeleted, _ = res.RowsAffected() + + res, err = s.execHook(tx, `DELETE FROM sessions WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete session cascade: delete session: %w", err) + } + result.SessionsDeleted, _ = res.RowsAffected() + if result.SessionsDeleted == 0 { + return fmt.Errorf("%w: %q", ErrSessionNotFound, id) + } + + if enrolled == 1 { + if err := s.enqueueSyncMutationTx(tx, SyncEntitySession, id, SyncOpDelete, syncSessionPayload{ + ID: id, + Project: project, + DeletedAt: &now, + }); err != nil { + return fmt.Errorf("delete session cascade: enqueue session mutation: %w", err) + } + } + + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + // ─── Delete Prompt ─────────────────────────────────────────────────────────── // DeletePrompt hard-deletes a single prompt by ID and records a sync tombstone. @@ -2717,6 +2902,23 @@ func (s *Store) GetObservation(id int64) (*Observation, error) { return &o, nil } +func (s *Store) GetObservationIncludingDeleted(id int64) (*Observation, error) { + row := s.db.QueryRow( + `SELECT id, ifnull(sync_id, '') as sync_id, session_id, type, title, content, tool_name, project, + scope, topic_key, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at + FROM observations WHERE id = ?`, id, + ) + var o Observation + if err := row.Scan( + &o.ID, &o.SyncID, &o.SessionID, &o.Type, &o.Title, &o.Content, + &o.ToolName, &o.Project, &o.Scope, &o.TopicKey, &o.RevisionCount, &o.DuplicateCount, &o.LastSeenAt, + &o.CreatedAt, &o.UpdatedAt, &o.DeletedAt, + ); err != nil { + return nil, err + } + return &o, nil +} + func (s *Store) UpdateObservation(id int64, p UpdateObservationParams) (*Observation, error) { var updated *Observation err := s.withTx(func(tx *sql.Tx) error { @@ -2845,6 +3047,81 @@ func (s *Store) DeleteObservation(id int64, hardDelete bool) error { }) } +func (s *Store) RestoreObservation(id int64) error { + return s.withTx(func(tx *sql.Tx) error { + if _, err := s.getObservationByIDTx(tx, id, true); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrObservationNotFound + } + return err + } + if _, err := s.execHook(tx, ` + UPDATE observations + SET deleted_at = NULL, + updated_at = datetime('now') + WHERE id = ? + `, id); err != nil { + return err + } + + restored, err := s.getObservationTx(tx, id) + if err != nil { + return err + } + if strings.TrimSpace(restored.SyncID) == "" { + return nil + } + payload := observationPayloadFromObservation(restored) + payload.Deleted = false + payload.DeletedAt = nil + payload.HardDelete = false + return s.enqueueSyncMutationTx(tx, SyncEntityObservation, restored.SyncID, SyncOpUpsert, payload) + }) +} + +func (s *Store) PurgeObservation(id int64) error { + return s.withTx(func(tx *sql.Tx) error { + obs, err := s.getObservationByIDTx(tx, id, true) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrObservationNotFound + } + return err + } + + if strings.TrimSpace(obs.SyncID) != "" { + if _, err := s.execHook(tx, ` + UPDATE memory_relations + SET judgment_status = 'orphaned', + updated_at = datetime('now') + WHERE source_id = ? OR target_id = ? + `, obs.SyncID, obs.SyncID); err != nil { + return fmt.Errorf("orphan memory_relations after purge: %w", err) + } + } + + if _, err := s.execHook(tx, `DELETE FROM observations WHERE id = ?`, id); err != nil { + return err + } + + if strings.TrimSpace(obs.SyncID) == "" { + return nil + } + deletedAt := Now() + if obs.DeletedAt != nil && strings.TrimSpace(*obs.DeletedAt) != "" { + deletedAt = *obs.DeletedAt + } + return s.enqueueSyncMutationTx(tx, SyncEntityObservation, obs.SyncID, SyncOpDelete, syncObservationPayload{ + SyncID: obs.SyncID, + SessionID: obs.SessionID, + Project: obs.Project, + Deleted: true, + DeletedAt: &deletedAt, + HardDelete: true, + }) + }) +} + // ─── Timeline ──────────────────────────────────────────────────────────────── // // Timeline provides chronological context around a specific observation. @@ -2973,10 +3250,13 @@ func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) SELECT id, ifnull(sync_id, '') as sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at FROM observations - WHERE topic_key = ? AND deleted_at IS NULL + WHERE topic_key = ? ` tkArgs := []any{query} + if !opts.IncludeDeleted { + tkSQL += " AND deleted_at IS NULL" + } if opts.Type != "" { tkSQL += " AND type = ?" tkArgs = append(tkArgs, opts.Type) @@ -3020,10 +3300,13 @@ func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) fts.rank FROM observations_fts fts JOIN observations o ON o.id = fts.rowid - WHERE observations_fts MATCH ? AND o.deleted_at IS NULL + WHERE observations_fts MATCH ? ` args := []any{ftsQuery} + if !opts.IncludeDeleted { + sqlQ += " AND o.deleted_at IS NULL" + } if opts.Type != "" { sqlQ += " AND o.type = ?" args = append(args, opts.Type) @@ -4588,11 +4871,11 @@ func (s *Store) PruneProject(project string) (*PruneResult, error) { // DeleteProjectResult summarises a cascade project deletion. type DeleteProjectResult struct { - Project string `json:"project"` - ObservationsDeleted int64 `json:"observations_deleted"` - PromptsDeleted int64 `json:"prompts_deleted"` - SessionsDeleted int64 `json:"sessions_deleted"` - HardDelete bool `json:"hard_delete"` + Project string `json:"project"` + ObservationsDeleted int64 `json:"observations_deleted"` + PromptsDeleted int64 `json:"prompts_deleted"` + SessionsDeleted int64 `json:"sessions_deleted"` + HardDelete bool `json:"hard_delete"` } // DeleteProject removes all data associated with a project in a single @@ -5426,11 +5709,17 @@ func decodeSyncPayload(payload []byte, dest any) error { } func (s *Store) getObservationTx(tx *sql.Tx, id int64) (*Observation, error) { - row := tx.QueryRow( - `SELECT id, ifnull(sync_id, '') as sync_id, session_id, type, title, content, tool_name, project, + return s.getObservationByIDTx(tx, id, false) +} + +func (s *Store) getObservationByIDTx(tx *sql.Tx, id int64, includeDeleted bool) (*Observation, error) { + query := `SELECT id, ifnull(sync_id, '') as sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at - FROM observations WHERE id = ? AND deleted_at IS NULL`, id, - ) + FROM observations WHERE id = ?` + if !includeDeleted { + query += ` AND deleted_at IS NULL` + } + row := tx.QueryRow(query, id) var o Observation if err := row.Scan(&o.ID, &o.SyncID, &o.SessionID, &o.Type, &o.Title, &o.Content, &o.ToolName, &o.Project, &o.Scope, &o.TopicKey, &o.RevisionCount, &o.DuplicateCount, &o.LastSeenAt, &o.CreatedAt, &o.UpdatedAt, &o.DeletedAt); err != nil { return nil, err @@ -5600,6 +5889,16 @@ func (s *Store) applyObservationDeleteTx(tx *sql.Tx, payload syncObservationPayl return err } if payload.HardDelete { + if strings.TrimSpace(existing.SyncID) != "" { + if _, err := s.execHook(tx, ` + UPDATE memory_relations + SET judgment_status = 'orphaned', + updated_at = datetime('now') + WHERE source_id = ? OR target_id = ? + `, existing.SyncID, existing.SyncID); err != nil { + return fmt.Errorf("orphan memory_relations after pulled hard-delete: %w", err) + } + } _, err = s.execHook(tx, `DELETE FROM observations WHERE id = ?`, existing.ID) return err } @@ -5723,6 +6022,10 @@ func (s *Store) queryObservations(query string, args ...any) ([]Observation, err return results, rows.Err() } +func IsObservationDeleted(o Observation) bool { + return o.DeletedAt != nil && strings.TrimSpace(*o.DeletedAt) != "" +} + func (s *Store) addColumnIfNotExists(tableName, columnName, definition string) error { rows, err := s.queryItHook(s.db, fmt.Sprintf("PRAGMA table_info(%s)", tableName)) if err != nil { @@ -5969,8 +6272,7 @@ func (s *Store) migrateLegacyObservationsTable() error { ); INSERT INTO observations_fts(rowid, title, content, tool_name, type, project, topic_key) SELECT id, title, content, tool_name, type, project, topic_key - FROM observations - WHERE deleted_at IS NULL; + FROM observations; `); err != nil { return fmt.Errorf("migrate legacy observations: rebuild fts: %w", err) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index e64bd8bd..a79f62a3 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -263,6 +263,186 @@ func TestUpdateAndSoftDeleteExcludedFromSearchAndTimeline(t *testing.T) { } } +func TestObservationDeletedDerivedFromDeletedAtAndContentPreserved(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("sess-deleted-derived", "engram", "/tmp/engram"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + id, err := s.AddObservation(AddObservationParams{ + SessionID: "sess-deleted-derived", + Type: "decision", + Title: "Recoverable memory", + Content: "recover me exactly", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("AddObservation: %v", err) + } + + if err := s.DeleteObservation(id, false); err != nil { + t.Fatalf("DeleteObservation soft: %v", err) + } + + var content string + var deletedAt *string + if err := s.db.QueryRow(`SELECT content, deleted_at FROM observations WHERE id = ?`, id).Scan(&content, &deletedAt); err != nil { + t.Fatalf("scan observation row: %v", err) + } + if content != "recover me exactly" { + t.Fatalf("soft delete changed content: got %q", content) + } + if deletedAt == nil || strings.TrimSpace(*deletedAt) == "" { + t.Fatalf("soft delete did not set deleted_at") + } + + obs, err := s.GetObservationIncludingDeleted(id) + if err != nil { + t.Fatalf("GetObservationIncludingDeleted: %v", err) + } + if obs.DeletedAt == nil || strings.TrimSpace(*obs.DeletedAt) == "" { + t.Fatalf("DeletedAt = %v, want non-empty deleted_at", obs.DeletedAt) + } + if obs.Content != "recover me exactly" { + t.Fatalf("include-deleted observation content = %q", obs.Content) + } +} + +func TestTUIIncludeDeletedObservationQueries(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("sess-include-deleted", "engram", "/tmp/engram"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + activeID, err := s.AddObservation(AddObservationParams{ + SessionID: "sess-include-deleted", + Type: "decision", + Title: "Active memory", + Content: "active memory content", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("AddObservation active: %v", err) + } + deletedID, err := s.AddObservation(AddObservationParams{ + SessionID: "sess-include-deleted", + Type: "bugfix", + Title: "Deleted memory", + Content: "deleted memory content", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("AddObservation deleted: %v", err) + } + if err := s.DeleteObservation(deletedID, false); err != nil { + t.Fatalf("DeleteObservation soft: %v", err) + } + + defaultObs, err := s.AllObservations("engram", "project", 10) + if err != nil { + t.Fatalf("AllObservations: %v", err) + } + if len(defaultObs) != 1 || defaultObs[0].ID != activeID { + t.Fatalf("default observations = %+v, want only active id %d", defaultObs, activeID) + } + + allObs, err := s.AllObservationsWithOptions(ObservationListOptions{ + Project: "engram", + Scope: "project", + Limit: 10, + IncludeDeleted: true, + }) + if err != nil { + t.Fatalf("AllObservationsWithOptions: %v", err) + } + if len(allObs) != 2 { + t.Fatalf("include-deleted observations len = %d, want 2: %+v", len(allObs), allObs) + } + foundDeleted := false + for _, obs := range allObs { + if obs.ID == deletedID { + foundDeleted = IsObservationDeleted(obs) + } + } + if !foundDeleted { + t.Fatalf("deleted observation did not have deleted_at set: %+v", allObs) + } + + sessionObs, err := s.SessionObservationsWithOptions("sess-include-deleted", ObservationListOptions{ + Limit: 10, + IncludeDeleted: true, + }) + if err != nil { + t.Fatalf("SessionObservationsWithOptions: %v", err) + } + if len(sessionObs) != 2 { + t.Fatalf("include-deleted session observations len = %d, want 2: %+v", len(sessionObs), sessionObs) + } +} + +func TestSearchIncludeDeletedIsOptIn(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("sess-search-deleted", "engram", "/tmp/engram"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + id, err := s.AddObservation(AddObservationParams{ + SessionID: "sess-search-deleted", + Type: "decision", + Title: "Needle deleted memory", + Content: "needle deleted content", + Project: "engram", + Scope: "project", + TopicKey: "recoverable/needle", + }) + if err != nil { + t.Fatalf("AddObservation: %v", err) + } + if err := s.DeleteObservation(id, false); err != nil { + t.Fatalf("DeleteObservation soft: %v", err) + } + + defaultResults, err := s.Search("needle deleted", SearchOptions{Project: "engram", Limit: 10}) + if err != nil { + t.Fatalf("Search default: %v", err) + } + if len(defaultResults) != 0 { + t.Fatalf("default search returned deleted results: %+v", defaultResults) + } + + includeDeletedResults, err := s.Search("needle deleted", SearchOptions{ + Project: "engram", + Limit: 10, + IncludeDeleted: true, + }) + if err != nil { + t.Fatalf("Search include deleted: %v", err) + } + if len(includeDeletedResults) != 1 || includeDeletedResults[0].ID != id || !IsObservationDeleted(includeDeletedResults[0].Observation) { + t.Fatalf("include-deleted search results = %+v, want deleted id %d", includeDeletedResults, id) + } + + defaultTopicResults, err := s.Search("recoverable/needle", SearchOptions{Project: "engram", Limit: 10}) + if err != nil { + t.Fatalf("Search topic default: %v", err) + } + if len(defaultTopicResults) != 0 { + t.Fatalf("default topic-key search returned deleted results: %+v", defaultTopicResults) + } + + includeDeletedTopicResults, err := s.Search("recoverable/needle", SearchOptions{ + Project: "engram", + Limit: 10, + IncludeDeleted: true, + }) + if err != nil { + t.Fatalf("Search topic include deleted: %v", err) + } + if len(includeDeletedTopicResults) != 1 || includeDeletedTopicResults[0].ID != id || !IsObservationDeleted(includeDeletedTopicResults[0].Observation) { + t.Fatalf("include-deleted topic search results = %+v, want deleted id %d", includeDeletedTopicResults, id) + } +} + func TestTopicKeyUpsertUpdatesSameTopicWithoutCreatingNewRow(t *testing.T) { s := newTestStore(t) @@ -456,6 +636,173 @@ func TestNewMigratesLegacyObservationIDSchema(t *testing.T) { } } +func TestSearchIncludeDeletedFindsLegacySoftDeletedObservationAfterFTSRebuild(t *testing.T) { + dataDir := t.TempDir() + dbPath := filepath.Join(dataDir, "engram.db") + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open legacy db: %v", err) + } + + _, err = db.Exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + project TEXT NOT NULL, + directory TEXT NOT NULL, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + ended_at TEXT, + summary TEXT + ); + CREATE TABLE observations ( + id INT, + session_id TEXT, + type TEXT, + title TEXT, + content TEXT, + tool_name TEXT, + project TEXT, + scope TEXT, + topic_key TEXT, + normalized_hash TEXT, + revision_count INTEGER, + duplicate_count INTEGER, + last_seen_at TEXT, + created_at TEXT, + updated_at TEXT, + deleted_at TEXT + ); + INSERT INTO sessions (id, project, directory) VALUES ('legacy-deleted-sess', 'engram', '/tmp/engram'); + INSERT INTO observations ( + id, session_id, type, title, content, project, scope, topic_key, + normalized_hash, revision_count, duplicate_count, created_at, updated_at, deleted_at + ) + VALUES ( + 42, 'legacy-deleted-sess', 'decision', 'Legacy deleted memory', + 'legacy recoverable deleted content', 'engram', 'project', 'legacy/recoverable', + ?, 1, 1, '2024-01-03 12:00:00', '2024-01-03 12:00:00', '2024-01-04 12:00:00' + ); + `, hashNormalized("legacy recoverable deleted content")) + if err != nil { + _ = db.Close() + t.Fatalf("seed legacy db: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close legacy db: %v", err) + } + + cfg := mustDefaultConfig(t) + cfg.DataDir = dataDir + + s, err := New(cfg) + if err != nil { + t.Fatalf("new store after legacy schema: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + defaultResults, err := s.Search("recoverable deleted", SearchOptions{Project: "engram", Limit: 10}) + if err != nil { + t.Fatalf("Search default: %v", err) + } + if len(defaultResults) != 0 { + t.Fatalf("default search returned deleted legacy result: %+v", defaultResults) + } + + includeDeletedResults, err := s.Search("recoverable deleted", SearchOptions{ + Project: "engram", + Limit: 10, + IncludeDeleted: true, + }) + if err != nil { + t.Fatalf("Search include deleted: %v", err) + } + if len(includeDeletedResults) != 1 || includeDeletedResults[0].Content != "legacy recoverable deleted content" || !IsObservationDeleted(includeDeletedResults[0].Observation) { + t.Fatalf("include-deleted legacy search results = %+v, want deleted migrated row", includeDeletedResults) + } +} + +func TestSearchIncludeDeletedRepairsCurrentFTSMissingDeletedRows(t *testing.T) { + dataDir := t.TempDir() + cfg := mustDefaultConfig(t) + cfg.DataDir = dataDir + cfg.DedupeWindow = time.Hour + + s, err := New(cfg) + if err != nil { + t.Fatalf("new store: %v", err) + } + if err := s.CreateSession("current-fts-repair-sess", "engram", "/tmp/engram"); err != nil { + _ = s.Close() + t.Fatalf("CreateSession: %v", err) + } + id, err := s.AddObservation(AddObservationParams{ + SessionID: "current-fts-repair-sess", + Type: "decision", + Title: "Current deleted memory", + Content: "current schema recoverable deleted content", + Project: "engram", + Scope: "project", + }) + if err != nil { + _ = s.Close() + t.Fatalf("AddObservation: %v", err) + } + if err := s.DeleteObservation(id, false); err != nil { + _ = s.Close() + t.Fatalf("DeleteObservation soft: %v", err) + } + + if _, err := s.db.Exec(` + DROP TABLE observations_fts; + CREATE VIRTUAL TABLE observations_fts USING fts5( + title, + content, + tool_name, + type, + project, + topic_key, + content='observations', + content_rowid='id' + ); + INSERT INTO observations_fts(rowid, title, content, tool_name, type, project, topic_key) + SELECT id, title, content, tool_name, type, project, topic_key + FROM observations + WHERE deleted_at IS NULL; + `); err != nil { + _ = s.Close() + t.Fatalf("simulate active-only fts rebuild: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("close store before reopen: %v", err) + } + + reopened, err := New(cfg) + if err != nil { + t.Fatalf("reopen store: %v", err) + } + t.Cleanup(func() { _ = reopened.Close() }) + + defaultResults, err := reopened.Search("recoverable deleted", SearchOptions{Project: "engram", Limit: 10}) + if err != nil { + t.Fatalf("Search default: %v", err) + } + if len(defaultResults) != 0 { + t.Fatalf("default search returned deleted current-schema result: %+v", defaultResults) + } + + includeDeletedResults, err := reopened.Search("recoverable deleted", SearchOptions{ + Project: "engram", + Limit: 10, + IncludeDeleted: true, + }) + if err != nil { + t.Fatalf("Search include deleted: %v", err) + } + if len(includeDeletedResults) != 1 || includeDeletedResults[0].ID != id || !IsObservationDeleted(includeDeletedResults[0].Observation) { + t.Fatalf("include-deleted current-schema search results = %+v, want repaired deleted id %d", includeDeletedResults, id) + } +} + func TestNewMigratesLegacyUserPromptsSyncIDSchema(t *testing.T) { dataDir := t.TempDir() dbPath := filepath.Join(dataDir, "engram.db") @@ -1177,6 +1524,39 @@ func TestSessionsOrderedByMostRecentActivity(t *testing.T) { } } +func TestAllSessionsCountsSoftDeletedObservations(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s-deleted-only", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + obsID, err := s.AddObservation(AddObservationParams{ + SessionID: "s-deleted-only", + Type: "decision", + Title: "Deleted-only memory", + Content: "Still permanently deleted by session cascade", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + if err := s.DeleteObservation(obsID, false); err != nil { + t.Fatalf("soft delete observation: %v", err) + } + + sessions, err := s.AllSessions("engram", 10) + if err != nil { + t.Fatalf("all sessions: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("sessions = %+v, want one session", sessions) + } + if sessions[0].ObservationCount != 1 { + t.Fatalf("ObservationCount = %d, want 1 for soft-deleted observation", sessions[0].ObservationCount) + } +} + func TestSessionObservationsAddPromptImportAndSyncChunks(t *testing.T) { s := newTestStore(t) @@ -2460,6 +2840,63 @@ func TestApplyRemoteMutationIdempotent(t *testing.T) { } } +func TestApplyPulledObservationHardDeleteOrphansRelations(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("remote-hard-delete-session", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + obsID, err := s.AddObservation(AddObservationParams{ + SessionID: "remote-hard-delete-session", + Type: "decision", + Title: "Remote hard delete", + Content: "relation should become audit history", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + obs, err := s.GetObservation(obsID) + if err != nil { + t.Fatalf("get observation: %v", err) + } + relSyncID := "rel-" + obs.SyncID + if _, err := s.db.Exec(` + INSERT INTO memory_relations (sync_id, source_id, target_id, relation, judgment_status, created_at, updated_at) + VALUES (?, ?, 'other-remote-sync-id', 'related', 'accepted', datetime('now'), datetime('now')) + `, relSyncID, obs.SyncID); err != nil { + t.Fatalf("insert relation: %v", err) + } + + mutation := SyncMutation{ + Seq: 44, + TargetKey: DefaultSyncTargetKey, + Entity: SyncEntityObservation, + EntityKey: obs.SyncID, + Op: SyncOpDelete, + Payload: fmt.Sprintf(`{"sync_id":"%s","deleted":true,"hard_delete":true}`, obs.SyncID), + } + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, mutation); err != nil { + t.Fatalf("apply pulled hard delete: %v", err) + } + + var obsCount int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM observations WHERE sync_id = ?`, obs.SyncID).Scan(&obsCount); err != nil { + t.Fatalf("count observations: %v", err) + } + if obsCount != 0 { + t.Fatalf("hard-deleted observation count = %d, want 0", obsCount) + } + + var status string + if err := s.db.QueryRow(`SELECT judgment_status FROM memory_relations WHERE sync_id = ?`, relSyncID).Scan(&status); err != nil { + t.Fatalf("scan relation status: %v", err) + } + if status != "orphaned" { + t.Fatalf("relation status = %q, want orphaned", status) + } +} + func TestApplyPulledMutationClearsDegradedReasonFields(t *testing.T) { s := newTestStore(t) if err := s.MarkSyncBlocked(DefaultSyncTargetKey, "blocked_unenrolled", "project not enrolled"); err != nil { @@ -2940,6 +3377,90 @@ func TestDeleteObservationHardDeleteDerivesProjectFromSessionWhenEntityProjectEm } } +func TestRestoreObservationClearsDeletedAtAndPreservesContent(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("sess-restore", "engram", "/tmp/engram"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + id, err := s.AddObservation(AddObservationParams{ + SessionID: "sess-restore", + Type: "decision", + Title: "Restore memory", + Content: "restore this content", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("AddObservation: %v", err) + } + if err := s.DeleteObservation(id, false); err != nil { + t.Fatalf("DeleteObservation soft: %v", err) + } + if err := s.RestoreObservation(id); err != nil { + t.Fatalf("RestoreObservation: %v", err) + } + + obs, err := s.GetObservation(id) + if err != nil { + t.Fatalf("GetObservation after restore: %v", err) + } + if obs.DeletedAt != nil { + t.Fatalf("restored observation still deleted: %+v", obs) + } + if obs.Content != "restore this content" { + t.Fatalf("restored content = %q", obs.Content) + } +} + +func TestPurgeObservationRemovesDeletedObservationAndOrphansRelations(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("sess-purge", "engram", "/tmp/engram"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + id, err := s.AddObservation(AddObservationParams{ + SessionID: "sess-purge", + Type: "decision", + Title: "Purge memory", + Content: "purge this content", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("AddObservation: %v", err) + } + obs, err := s.GetObservation(id) + if err != nil { + t.Fatalf("GetObservation: %v", err) + } + if _, err := s.db.Exec(` + INSERT INTO memory_relations (sync_id, source_id, target_id, relation, judgment_status, created_at, updated_at) + VALUES (?, ?, 'other-sync-id', 'related', 'pending', datetime('now'), datetime('now')) + `, "rel-"+obs.SyncID, obs.SyncID); err != nil { + t.Fatalf("insert relation: %v", err) + } + if err := s.DeleteObservation(id, false); err != nil { + t.Fatalf("DeleteObservation soft: %v", err) + } + if err := s.PurgeObservation(id); err != nil { + t.Fatalf("PurgeObservation: %v", err) + } + + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM observations WHERE id = ?`, id).Scan(&count); err != nil { + t.Fatalf("count observation: %v", err) + } + if count != 0 { + t.Fatalf("purged observation count = %d, want 0", count) + } + var status string + if err := s.db.QueryRow(`SELECT judgment_status FROM memory_relations WHERE sync_id = ?`, "rel-"+obs.SyncID).Scan(&status); err != nil { + t.Fatalf("scan relation: %v", err) + } + if status != "orphaned" { + t.Fatalf("relation status = %q, want orphaned", status) + } +} + func TestDeleteObservationNotFound(t *testing.T) { s := newTestStore(t) err := s.DeleteObservation(999999, false) @@ -6878,6 +7399,73 @@ func TestDeleteSession_HasSoftDeletedObservations(t *testing.T) { } } +func TestDeleteSessionCascadePermanentlyDeletesSessionMemories(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("sess-cascade", "engram", "/tmp/engram"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + firstID, err := s.AddObservation(AddObservationParams{ + SessionID: "sess-cascade", + Type: "decision", + Title: "First cascade memory", + Content: "first cascade content", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("AddObservation first: %v", err) + } + secondID, err := s.AddObservation(AddObservationParams{ + SessionID: "sess-cascade", + Type: "bugfix", + Title: "Second cascade memory", + Content: "second cascade content", + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("AddObservation second: %v", err) + } + if _, err := s.AddPrompt(AddPromptParams{ + SessionID: "sess-cascade", + Content: "cascade prompt", + Project: "engram", + }); err != nil { + t.Fatalf("AddPrompt: %v", err) + } + if err := s.DeleteObservation(secondID, false); err != nil { + t.Fatalf("DeleteObservation soft: %v", err) + } + + result, err := s.DeleteSessionCascade("sess-cascade") + if err != nil { + t.Fatalf("DeleteSessionCascade: %v", err) + } + if result.SessionID != "sess-cascade" { + t.Fatalf("SessionID = %q, want sess-cascade", result.SessionID) + } + if result.ObservationsDeleted != 2 { + t.Fatalf("ObservationsDeleted = %d, want 2", result.ObservationsDeleted) + } + if result.PromptsDeleted != 1 { + t.Fatalf("PromptsDeleted = %d, want 1", result.PromptsDeleted) + } + if result.SessionsDeleted != 1 { + t.Fatalf("SessionsDeleted = %d, want 1", result.SessionsDeleted) + } + + var obsCount int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM observations WHERE id IN (?, ?)`, firstID, secondID).Scan(&obsCount); err != nil { + t.Fatalf("count observations: %v", err) + } + if obsCount != 0 { + t.Fatalf("cascade observation count = %d, want 0", obsCount) + } + if _, err := s.GetSession("sess-cascade"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("GetSession after cascade err = %v, want sql.ErrNoRows", err) + } +} + func TestDeleteSession_DeletesPromptsAlso(t *testing.T) { s := newTestStore(t) diff --git a/internal/tui/model.go b/internal/tui/model.go index 144240d5..3e06675d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -33,9 +33,29 @@ const ( ScreenTimeline ScreenSessions ScreenSessionDetail + ScreenTrash + ScreenConfirmDelete ScreenSetup ) +type confirmAction int + +const ( + confirmNone confirmAction = iota + confirmPurgeObservation + confirmDeleteSession +) + +type confirmState struct { + Action confirmAction + Title string + Body string + ObservationID int64 + SessionID string + ReturnScreen Screen + ObservationCount int +} + // ─── Custom Messages ───────────────────────────────────────────────────────── type updateCheckMsg struct { @@ -78,6 +98,32 @@ type sessionObservationsMsg struct { err error } +type trashObservationsMsg struct { + observations []store.Observation + err error +} + +type observationDeletedMsg struct { + id int64 + err error +} + +type observationPurgedMsg struct { + id int64 + err error +} + +type observationRestoredMsg struct { + id int64 + err error +} + +type sessionDeletedMsg struct { + sessionID string + result *store.DeleteSessionCascadeResult + err error +} + type setupInstallMsg struct { result *setup.Result err error @@ -113,6 +159,12 @@ type Model struct { // Recent observations RecentObservations []store.Observation + // Recycle bin + TrashObservations []store.Observation + + // Destructive action confirmation + Confirm confirmState + // Observation detail SelectedObservation *store.Observation DetailScroll int @@ -188,21 +240,21 @@ func loadStats(s *store.Store) tea.Cmd { func searchMemories(s *store.Store, query string) tea.Cmd { return func() tea.Msg { - results, err := s.Search(query, store.SearchOptions{Limit: 50}) + results, err := s.Search(query, store.SearchOptions{Limit: 50, IncludeDeleted: true}) return searchResultsMsg{results: results, query: query, err: err} } } func loadRecentObservations(s *store.Store) tea.Cmd { return func() tea.Msg { - obs, err := s.AllObservations("", "", 50) + obs, err := s.AllObservationsWithOptions(store.ObservationListOptions{Limit: 50, IncludeDeleted: true}) return recentObservationsMsg{observations: obs, err: err} } } func loadObservationDetail(s *store.Store, id int64) tea.Cmd { return func() tea.Msg { - obs, err := s.GetObservation(id) + obs, err := s.GetObservationIncludingDeleted(id) return observationDetailMsg{observation: obs, err: err} } } @@ -210,10 +262,105 @@ func loadObservationDetail(s *store.Store, id int64) tea.Cmd { func loadTimeline(s *store.Store, obsID int64) tea.Cmd { return func() tea.Msg { tl, err := s.Timeline(obsID, 10, 10) + if err != nil { + if deletedTimeline, deletedErr := loadDeletedFocusTimeline(s, obsID, 10, 10); deletedErr == nil { + tl = deletedTimeline + err = nil + } + } return timelineMsg{timeline: tl, err: err} } } +const deletedFocusTimelineSessionLimit = 10000 + +func loadDeletedFocusTimeline(s *store.Store, obsID int64, before, after int) (*store.TimelineResult, error) { + focus, err := s.GetObservationIncludingDeleted(obsID) + if err != nil { + return nil, err + } + if !store.IsObservationDeleted(*focus) { + return nil, store.ErrObservationNotFound + } + + session, _ := s.GetSession(focus.SessionID) + observations, err := s.SessionObservationsWithOptions(focus.SessionID, store.ObservationListOptions{ + Limit: deletedFocusTimelineSessionLimit, + IncludeDeleted: true, + }) + if err != nil { + return nil, err + } + + focusIdx := -1 + for i, obs := range observations { + if obs.ID == obsID { + focusIdx = i + break + } + } + + beforeEntries := make([]store.TimelineEntry, 0, before) + if focusIdx > 0 { + for i := focusIdx - 1; i >= 0 && len(beforeEntries) < before; i-- { + obs := observations[i] + if store.IsObservationDeleted(obs) { + continue + } + beforeEntries = append(beforeEntries, timelineEntryFromObservation(obs)) + } + for i, j := 0, len(beforeEntries)-1; i < j; i, j = i+1, j-1 { + beforeEntries[i], beforeEntries[j] = beforeEntries[j], beforeEntries[i] + } + } + + afterEntries := make([]store.TimelineEntry, 0, after) + if focusIdx >= 0 { + for i := focusIdx + 1; i < len(observations) && len(afterEntries) < after; i++ { + obs := observations[i] + if store.IsObservationDeleted(obs) { + continue + } + afterEntries = append(afterEntries, timelineEntryFromObservation(obs)) + } + } + + totalActive := 0 + for _, obs := range observations { + if !store.IsObservationDeleted(obs) { + totalActive++ + } + } + + return &store.TimelineResult{ + Focus: *focus, + Before: beforeEntries, + After: afterEntries, + SessionInfo: session, + TotalInRange: totalActive, + }, nil +} + +func timelineEntryFromObservation(obs store.Observation) store.TimelineEntry { + return store.TimelineEntry{ + ID: obs.ID, + SessionID: obs.SessionID, + Type: obs.Type, + Title: obs.Title, + Content: obs.Content, + ToolName: obs.ToolName, + Project: obs.Project, + Scope: obs.Scope, + TopicKey: obs.TopicKey, + RevisionCount: obs.RevisionCount, + DuplicateCount: obs.DuplicateCount, + LastSeenAt: obs.LastSeenAt, + CreatedAt: obs.CreatedAt, + UpdatedAt: obs.UpdatedAt, + DeletedAt: obs.DeletedAt, + } +} + func loadRecentSessions(s *store.Store) tea.Cmd { return func() tea.Msg { sessions, err := s.AllSessions("", 50) @@ -223,11 +370,43 @@ func loadRecentSessions(s *store.Store) tea.Cmd { func loadSessionObservations(s *store.Store, sessionID string) tea.Cmd { return func() tea.Msg { - obs, err := s.SessionObservations(sessionID, 200) + obs, err := s.SessionObservationsWithOptions(sessionID, store.ObservationListOptions{Limit: 200, IncludeDeleted: true}) return sessionObservationsMsg{observations: obs, err: err} } } +func loadTrashObservations(s *store.Store) tea.Cmd { + return func() tea.Msg { + obs, err := s.DeletedObservations(200) + return trashObservationsMsg{observations: obs, err: err} + } +} + +func deleteObservationCmd(s *store.Store, id int64) tea.Cmd { + return func() tea.Msg { + return observationDeletedMsg{id: id, err: s.DeleteObservation(id, false)} + } +} + +func purgeObservationCmd(s *store.Store, id int64) tea.Cmd { + return func() tea.Msg { + return observationPurgedMsg{id: id, err: s.PurgeObservation(id)} + } +} + +func restoreObservationCmd(s *store.Store, id int64) tea.Cmd { + return func() tea.Msg { + return observationRestoredMsg{id: id, err: s.RestoreObservation(id)} + } +} + +func deleteSessionCascadeCmd(s *store.Store, sessionID string) tea.Cmd { + return func() tea.Msg { + result, err := s.DeleteSessionCascade(sessionID) + return sessionDeletedMsg{sessionID: sessionID, result: result, err: err} + } +} + func installAgent(agentName string) tea.Cmd { return func() tea.Msg { result, err := installAgentFn(agentName) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index d7b28bd1..a78f042b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -166,6 +166,24 @@ func TestDataLoadingCommands(t *testing.T) { } }) + t.Run("loadDeletedObservationDetail", func(t *testing.T) { + deletedFx := newTestFixture(t) + if err := deletedFx.store.DeleteObservation(deletedFx.secondObs, false); err != nil { + t.Fatalf("DeleteObservation: %v", err) + } + msg := loadObservationDetail(deletedFx.store, deletedFx.secondObs)() + loaded, ok := msg.(observationDetailMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected error: %v", loaded.err) + } + if loaded.observation == nil || loaded.observation.ID != deletedFx.secondObs || !store.IsObservationDeleted(*loaded.observation) { + t.Fatalf("unexpected deleted observation detail: %+v", loaded.observation) + } + }) + t.Run("loadTimeline", func(t *testing.T) { msg := loadTimeline(fx.store, fx.secondObs)() loaded, ok := msg.(timelineMsg) @@ -180,6 +198,27 @@ func TestDataLoadingCommands(t *testing.T) { } }) + t.Run("loadDeletedTimeline", func(t *testing.T) { + deletedFx := newTestFixture(t) + if err := deletedFx.store.DeleteObservation(deletedFx.secondObs, false); err != nil { + t.Fatalf("DeleteObservation: %v", err) + } + msg := loadTimeline(deletedFx.store, deletedFx.secondObs)() + loaded, ok := msg.(timelineMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected error: %v", loaded.err) + } + if loaded.timeline == nil || loaded.timeline.Focus.ID != deletedFx.secondObs || !store.IsObservationDeleted(loaded.timeline.Focus) { + t.Fatalf("unexpected deleted timeline focus: %+v", loaded.timeline) + } + if len(loaded.timeline.Before) == 0 || loaded.timeline.Before[0].ID != deletedFx.obsID { + t.Fatalf("deleted timeline before entries = %+v, want active neighbor %d", loaded.timeline.Before, deletedFx.obsID) + } + }) + t.Run("loadRecentSessions", func(t *testing.T) { msg := loadRecentSessions(fx.store)() loaded, ok := msg.(recentSessionsMsg) @@ -209,6 +248,99 @@ func TestDataLoadingCommands(t *testing.T) { }) } +func TestTUILoadersIncludeDeletedObservations(t *testing.T) { + fx := newTestFixture(t) + if err := fx.store.DeleteObservation(fx.secondObs, false); err != nil { + t.Fatalf("DeleteObservation: %v", err) + } + + recentMsg := loadRecentObservations(fx.store)() + recent, ok := recentMsg.(recentObservationsMsg) + if !ok { + t.Fatalf("recent message type = %T", recentMsg) + } + if recent.err != nil { + t.Fatalf("recent loader error: %v", recent.err) + } + foundDeleted := false + for _, obs := range recent.observations { + if obs.ID == fx.secondObs { + foundDeleted = store.IsObservationDeleted(obs) + } + } + if !foundDeleted { + t.Fatalf("recent loader did not include deleted observation id %d: %+v", fx.secondObs, recent.observations) + } + + sessionMsg := loadSessionObservations(fx.store, fx.sessionID)() + session, ok := sessionMsg.(sessionObservationsMsg) + if !ok { + t.Fatalf("session message type = %T", sessionMsg) + } + if session.err != nil { + t.Fatalf("session loader error: %v", session.err) + } + foundDeleted = false + for _, obs := range session.observations { + if obs.ID == fx.secondObs { + foundDeleted = store.IsObservationDeleted(obs) + } + } + if !foundDeleted { + t.Fatalf("session loader did not include deleted observation id %d: %+v", fx.secondObs, session.observations) + } + + searchMsg := searchMemories(fx.store, "timeline sibling")() + search, ok := searchMsg.(searchResultsMsg) + if !ok { + t.Fatalf("search message type = %T", searchMsg) + } + if search.err != nil { + t.Fatalf("search loader error: %v", search.err) + } + foundDeleted = false + for _, result := range search.results { + if result.ID == fx.secondObs { + foundDeleted = store.IsObservationDeleted(result.Observation) + } + } + if !foundDeleted { + t.Fatalf("search loader did not include deleted observation id %d: %+v", fx.secondObs, search.results) + } +} + +func TestLoadTrashObservationsReturnsDeletedRows(t *testing.T) { + fx := newTestFixture(t) + if err := fx.store.DeleteObservation(fx.obsID, false); err != nil { + t.Fatalf("DeleteObservation: %v", err) + } + + msg := loadTrashObservations(fx.store)() + loaded, ok := msg.(trashObservationsMsg) + if !ok { + t.Fatalf("trash message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("trash loader error: %v", loaded.err) + } + if len(loaded.observations) == 0 { + t.Fatal("trash loader returned no observations") + } + + foundDeleted := false + for _, obs := range loaded.observations { + if !store.IsObservationDeleted(obs) { + t.Fatalf("trash loader included active observation: %+v", obs) + } + if obs.ID == fx.obsID { + foundDeleted = true + } + } + if !foundDeleted { + t.Fatalf("trash loader did not include deleted observation id %d: %+v", fx.obsID, loaded.observations) + } +} + func TestInstallAgentCommand(t *testing.T) { original := installAgentFn t.Cleanup(func() { installAgentFn = original }) diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 7374ae43..dd7b5f9b 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -43,6 +43,12 @@ var ( Foreground(colorSubtext). MarginTop(1) + // Destructive action warning + warningStyle = lipgloss.NewStyle(). + Foreground(colorRed). + Bold(true). + Padding(0, 1) + // Error message errorStyle = lipgloss.NewStyle(). Foreground(colorRed). @@ -116,11 +122,27 @@ var ( Bold(true). PaddingLeft(1) + // Deleted list item (normal) + deletedItemStyle = lipgloss.NewStyle(). + Foreground(colorRed). + PaddingLeft(2) + + // Deleted list item (selected/cursor) + deletedSelectedStyle = lipgloss.NewStyle(). + Foreground(colorRed). + Bold(true). + PaddingLeft(1) + // Observation type badge typeBadgeStyle = lipgloss.NewStyle(). Foreground(colorPeach). Bold(true) + // Deleted observation marker + deletedBadgeStyle = lipgloss.NewStyle(). + Foreground(colorRed). + Bold(true) + // Observation ID idStyle = lipgloss.NewStyle(). Foreground(colorBlue) diff --git a/internal/tui/update.go b/internal/tui/update.go index 1c5cfe00..d837f891 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,9 +1,11 @@ package tui import ( + "fmt" "time" "github.com/Gentleman-Programming/engram/internal/setup" + "github.com/Gentleman-Programming/engram/internal/store" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" ) @@ -102,6 +104,50 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.SessionDetailScroll = 0 return m, nil + case trashObservationsMsg: + if msg.err != nil { + m.ErrorMsg = msg.err.Error() + return m, nil + } + m.TrashObservations = msg.observations + if m.Screen == ScreenTrash { + m.Cursor = 0 + m.Scroll = 0 + } + return m, nil + + case observationDeletedMsg: + if msg.err != nil { + m.ErrorMsg = msg.err.Error() + return m, nil + } + m.ErrorMsg = "" + return m.finishOperationRefresh() + + case observationPurgedMsg: + if msg.err != nil { + m.ErrorMsg = msg.err.Error() + return m, nil + } + m.ErrorMsg = "" + return m.finishOperationRefresh() + + case observationRestoredMsg: + if msg.err != nil { + m.ErrorMsg = msg.err.Error() + return m, nil + } + m.ErrorMsg = "" + return m.finishOperationRefresh() + + case sessionDeletedMsg: + if msg.err != nil { + m.ErrorMsg = msg.err.Error() + return m, nil + } + m.ErrorMsg = "" + return m.finishOperationRefresh() + case setupInstallMsg: m.SetupInstalling = false if msg.err != nil { @@ -168,6 +214,10 @@ func (m Model) handleKeyPress(key string) (tea.Model, tea.Cmd) { return m.handleSessionsKeys(key) case ScreenSessionDetail: return m.handleSessionDetailKeys(key) + case ScreenTrash: + return m.handleTrashKeys(key) + case ScreenConfirmDelete: + return m.handleConfirmDeleteKeys(key) case ScreenSetup: return m.handleSetupKeys(key) } @@ -180,6 +230,7 @@ var dashboardMenuItems = []string{ "Search memories", "Recent observations", "Browse sessions", + "Recycle bin", "Setup agent plugin", "Quit", } @@ -230,7 +281,13 @@ func (m Model) handleDashboardSelection() (tea.Model, tea.Cmd) { m.Cursor = 0 m.Scroll = 0 return m, loadRecentSessions(m.store) - case 3: // Setup + case 3: // Recycle bin + m.PrevScreen = ScreenDashboard + m.Screen = ScreenTrash + m.Cursor = 0 + m.Scroll = 0 + return m, loadTrashObservations(m.store) + case 4: // Setup m.PrevScreen = ScreenDashboard m.Screen = ScreenSetup m.Cursor = 0 @@ -241,7 +298,7 @@ func (m Model) handleDashboardSelection() (tea.Model, tea.Cmd) { m.SetupInstalling = false m.SetupInstallingName = "" return m, nil - case 4: // Quit + case 5: // Quit return m, tea.Quit } return m, nil @@ -326,6 +383,14 @@ func (m Model) handleSearchResultsKeys(key string) (tea.Model, tea.Cmd) { m.PrevScreen = ScreenSearchResults return m, loadTimeline(m.store, obsID) } + case "delete", "d": + if obs, ok := m.selectedSearchObservation(); ok { + return m.deleteOrPurgeObservation(obs, ScreenSearchResults) + } + case "r": + if obs, ok := m.selectedSearchObservation(); ok { + return m.restoreObservation(obs) + } case "/", "s": m.PrevScreen = ScreenSearchResults m.Screen = ScreenSearch @@ -381,6 +446,14 @@ func (m Model) handleRecentKeys(key string) (tea.Model, tea.Cmd) { m.PrevScreen = ScreenRecent return m, loadTimeline(m.store, obsID) } + case "delete", "d": + if obs, ok := m.selectedRecentObservation(); ok { + return m.deleteOrPurgeObservation(obs, ScreenRecent) + } + case "r": + if obs, ok := m.selectedRecentObservation(); ok { + return m.restoreObservation(obs) + } case "esc", "q": m.Screen = ScreenDashboard m.Cursor = 0 @@ -467,6 +540,24 @@ func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) { sessionID := m.Sessions[m.Cursor].ID return m, loadSessionObservations(m.store, sessionID) } + case "delete", "d": + if len(m.Sessions) > 0 && m.Cursor >= 0 && m.Cursor < len(m.Sessions) { + session := m.Sessions[m.Cursor] + body := fmt.Sprintf("This will permanently delete empty session %q. This operation cannot be recovered.", session.ID) + if session.ObservationCount > 0 { + body = fmt.Sprintf("This will permanently delete all %d memories in session %q. This operation cannot be recovered.", session.ObservationCount, session.ID) + } + m.Screen = ScreenConfirmDelete + m.Confirm = confirmState{ + Action: confirmDeleteSession, + Title: "Delete session", + Body: body, + SessionID: session.ID, + ReturnScreen: ScreenSessions, + ObservationCount: session.ObservationCount, + } + return m, nil + } case "esc", "q": m.Screen = ScreenDashboard m.Cursor = 0 @@ -515,6 +606,14 @@ func (m Model) handleSessionDetailKeys(key string) (tea.Model, tea.Cmd) { m.PrevScreen = ScreenSessionDetail return m, loadTimeline(m.store, obsID) } + case "delete", "d": + if obs, ok := m.selectedSessionObservation(); ok { + return m.deleteOrPurgeObservation(obs, ScreenSessionDetail) + } + case "r": + if obs, ok := m.selectedSessionObservation(); ok { + return m.restoreObservation(obs) + } case "esc", "q": m.Screen = ScreenSessions m.Cursor = m.SelectedSessionIdx @@ -524,6 +623,84 @@ func (m Model) handleSessionDetailKeys(key string) (tea.Model, tea.Cmd) { return m, nil } +// ─── Trash ─────────────────────────────────────────────────────────────────── + +func (m Model) handleTrashKeys(key string) (tea.Model, tea.Cmd) { + visibleItems := (m.Height - 8) / 2 + if visibleItems < 3 { + visibleItems = 3 + } + + switch key { + case "up", "k": + if m.Cursor > 0 { + m.Cursor-- + if m.Cursor < m.Scroll { + m.Scroll = m.Cursor + } + } + case "down", "j": + if m.Cursor < len(m.TrashObservations)-1 { + m.Cursor++ + if m.Cursor >= m.Scroll+visibleItems { + m.Scroll = m.Cursor - visibleItems + 1 + } + } + case "enter": + if obs, ok := m.selectedTrashObservation(); ok { + m.PrevScreen = ScreenTrash + return m, loadObservationDetail(m.store, obs.ID) + } + case "delete", "d": + if obs, ok := m.selectedTrashObservation(); ok { + return m.deleteOrPurgeObservation(obs, ScreenTrash) + } + case "r": + if obs, ok := m.selectedTrashObservation(); ok { + return m.restoreObservation(obs) + } + case "esc", "q": + m.Screen = ScreenDashboard + m.Cursor = 0 + m.Scroll = 0 + return m, loadStats(m.store) + } + return m, nil +} + +// ─── Confirm Delete ────────────────────────────────────────────────────────── + +func (m Model) handleConfirmDeleteKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "y", "Y", "enter": + action := m.Confirm.Action + observationID := m.Confirm.ObservationID + sessionID := m.Confirm.SessionID + returnScreen := m.Confirm.ReturnScreen + m.Confirm = confirmState{} + m.Screen = returnScreen + + switch action { + case confirmPurgeObservation: + if observationID == 0 { + return m, nil + } + return m, purgeObservationCmd(m.store, observationID) + case confirmDeleteSession: + if sessionID == "" { + return m, nil + } + return m, deleteSessionCascadeCmd(m.store, sessionID) + } + case "n", "N", "esc", "q": + returnScreen := m.Confirm.ReturnScreen + m.Confirm = confirmState{} + m.Screen = returnScreen + return m, nil + } + return m, nil +} + // ─── Setup ─────────────────────────────────────────────────────────────────── func (m Model) handleSetupKeys(key string) (tea.Model, tea.Cmd) { @@ -594,16 +771,86 @@ func (m Model) handleSetupKeys(key string) (tea.Model, tea.Cmd) { // ─── Helpers ───────────────────────────────────────────────────────────────── +func (m Model) selectedSearchObservation() (store.Observation, bool) { + if len(m.SearchResults) == 0 || m.Cursor < 0 || m.Cursor >= len(m.SearchResults) { + return store.Observation{}, false + } + return m.SearchResults[m.Cursor].Observation, true +} + +func (m Model) selectedRecentObservation() (store.Observation, bool) { + if len(m.RecentObservations) == 0 || m.Cursor < 0 || m.Cursor >= len(m.RecentObservations) { + return store.Observation{}, false + } + return m.RecentObservations[m.Cursor], true +} + +func (m Model) selectedSessionObservation() (store.Observation, bool) { + if len(m.SessionObservations) == 0 || m.Cursor < 0 || m.Cursor >= len(m.SessionObservations) { + return store.Observation{}, false + } + return m.SessionObservations[m.Cursor], true +} + +func (m Model) selectedTrashObservation() (store.Observation, bool) { + if len(m.TrashObservations) == 0 || m.Cursor < 0 || m.Cursor >= len(m.TrashObservations) { + return store.Observation{}, false + } + return m.TrashObservations[m.Cursor], true +} + +func (m Model) deleteOrPurgeObservation(obs store.Observation, returnScreen Screen) (tea.Model, tea.Cmd) { + if store.IsObservationDeleted(obs) { + m.Screen = ScreenConfirmDelete + m.Confirm = confirmState{ + Action: confirmPurgeObservation, + Title: "Delete memory forever", + Body: fmt.Sprintf("Permanently delete memory #%d. This operation cannot be recovered.", obs.ID), + ObservationID: obs.ID, + ReturnScreen: returnScreen, + } + return m, nil + } + return m, deleteObservationCmd(m.store, obs.ID) +} + +func (m Model) restoreObservation(obs store.Observation) (tea.Model, tea.Cmd) { + if !store.IsObservationDeleted(obs) { + return m, nil + } + return m, restoreObservationCmd(m.store, obs.ID) +} + +func (m Model) finishOperationRefresh() (tea.Model, tea.Cmd) { + if m.Screen == ScreenConfirmDelete { + m.Screen = m.Confirm.ReturnScreen + } + m.Confirm = confirmState{} + return m, m.refreshScreen(m.Screen) +} + // refreshScreen returns the appropriate data-loading Cmd for a given screen. // Used when navigating back so lists show fresh data from the DB. func (m Model) refreshScreen(screen Screen) tea.Cmd { switch screen { case ScreenDashboard: return loadStats(m.store) + case ScreenSearchResults: + if m.SearchQuery != "" { + return searchMemories(m.store, m.SearchQuery) + } + return nil case ScreenRecent: return loadRecentObservations(m.store) case ScreenSessions: return loadRecentSessions(m.store) + case ScreenSessionDetail: + if m.SelectedSessionIdx >= 0 && m.SelectedSessionIdx < len(m.Sessions) { + return loadSessionObservations(m.store, m.Sessions[m.SelectedSessionIdx].ID) + } + return nil + case ScreenTrash: + return loadTrashObservations(m.store) default: return nil } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 3bb15de7..552a01be 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -2,6 +2,7 @@ package tui import ( "errors" + "strings" "testing" "github.com/Gentleman-Programming/engram/internal/setup" @@ -185,6 +186,17 @@ func TestHandleDashboardAndSearchKeyPaths(t *testing.T) { m.Cursor = 3 updatedModel, cmd = m.handleDashboardSelection() updated = updatedModel.(Model) + if updated.Screen != ScreenTrash { + t.Fatalf("screen = %v, want %v", updated.Screen, ScreenTrash) + } + if cmd == nil { + t.Fatal("recycle bin selection should load trash observations") + } + + m = New(fx.store, "") + m.Cursor = 4 + updatedModel, cmd = m.handleDashboardSelection() + updated = updatedModel.(Model) if updated.Screen != ScreenSetup || len(updated.SetupAgents) == 0 { t.Fatal("setup selection should initialize setup screen") } @@ -302,6 +314,443 @@ func TestHandleRecentTimelineSessionsAndDetailKeyPaths(t *testing.T) { } } +func TestTUIDeleteRestoreAndPurgeKeysForObservationLists(t *testing.T) { + deletedAt := "2026-06-08 10:00:00" + + t.Run("active observations delete without confirmation", func(t *testing.T) { + for _, tc := range []struct { + name string + key string + model func(testFixture) Model + handle func(Model, string) (tea.Model, tea.Cmd) + }{ + { + name: "search results d", + key: "d", + model: func(fx testFixture) Model { + m := New(fx.store, "") + m.Screen = ScreenSearchResults + m.SearchResults = []store.SearchResult{{Observation: store.Observation{ID: fx.obsID}}} + return m + }, + handle: func(m Model, key string) (tea.Model, tea.Cmd) { + return m.handleSearchResultsKeys(key) + }, + }, + { + name: "recent delete", + key: "delete", + model: func(fx testFixture) Model { + m := New(fx.store, "") + m.Screen = ScreenRecent + m.RecentObservations = []store.Observation{{ID: fx.obsID}} + return m + }, + handle: func(m Model, key string) (tea.Model, tea.Cmd) { + return m.handleRecentKeys(key) + }, + }, + { + name: "session detail d", + key: "d", + model: func(fx testFixture) Model { + m := New(fx.store, "") + m.Screen = ScreenSessionDetail + m.SessionObservations = []store.Observation{{ID: fx.obsID}} + return m + }, + handle: func(m Model, key string) (tea.Model, tea.Cmd) { + return m.handleSessionDetailKeys(key) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + fx := newTestFixture(t) + m := tc.model(fx) + + updatedModel, cmd := tc.handle(m, tc.key) + updated := updatedModel.(Model) + if cmd == nil { + t.Fatal("active delete should return delete command") + } + if updated.Screen == ScreenConfirmDelete || updated.Confirm.Action != confirmNone { + t.Fatalf("active delete opened confirmation: screen=%v confirm=%+v", updated.Screen, updated.Confirm) + } + msg := cmd() + if _, ok := msg.(observationDeletedMsg); !ok { + t.Fatalf("delete command message type = %T", msg) + } + }) + } + }) + + t.Run("deleted observations delete opens purge confirmation", func(t *testing.T) { + for _, tc := range []struct { + name string + key string + returnScreen Screen + model func(testFixture) Model + handle func(Model, string) (tea.Model, tea.Cmd) + }{ + { + name: "search results delete", + key: "delete", + returnScreen: ScreenSearchResults, + model: func(fx testFixture) Model { + m := New(fx.store, "") + m.Screen = ScreenSearchResults + m.SearchResults = []store.SearchResult{{Observation: store.Observation{ID: fx.secondObs, DeletedAt: &deletedAt}}} + return m + }, + handle: func(m Model, key string) (tea.Model, tea.Cmd) { + return m.handleSearchResultsKeys(key) + }, + }, + { + name: "recent d", + key: "d", + returnScreen: ScreenRecent, + model: func(fx testFixture) Model { + m := New(fx.store, "") + m.Screen = ScreenRecent + m.RecentObservations = []store.Observation{{ID: fx.secondObs, DeletedAt: &deletedAt}} + return m + }, + handle: func(m Model, key string) (tea.Model, tea.Cmd) { + return m.handleRecentKeys(key) + }, + }, + { + name: "session detail delete", + key: "delete", + returnScreen: ScreenSessionDetail, + model: func(fx testFixture) Model { + m := New(fx.store, "") + m.Screen = ScreenSessionDetail + m.SessionObservations = []store.Observation{{ID: fx.secondObs, DeletedAt: &deletedAt}} + return m + }, + handle: func(m Model, key string) (tea.Model, tea.Cmd) { + return m.handleSessionDetailKeys(key) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + fx := newTestFixture(t) + m := tc.model(fx) + + updatedModel, cmd := tc.handle(m, tc.key) + updated := updatedModel.(Model) + if cmd != nil { + t.Fatal("deleted observation delete should not return command before confirmation") + } + if updated.Screen != ScreenConfirmDelete { + t.Fatalf("screen = %v, want %v", updated.Screen, ScreenConfirmDelete) + } + if updated.Confirm.Action != confirmPurgeObservation || updated.Confirm.ObservationID != fx.secondObs || updated.Confirm.ReturnScreen != tc.returnScreen { + t.Fatalf("confirm = %+v", updated.Confirm) + } + }) + } + }) + + t.Run("restore only runs for deleted observations", func(t *testing.T) { + for _, tc := range []struct { + name string + model func(testFixture, *string) Model + handle func(Model, string) (tea.Model, tea.Cmd) + }{ + { + name: "search results", + model: func(fx testFixture, deletedAt *string) Model { + m := New(fx.store, "") + m.Screen = ScreenSearchResults + m.SearchResults = []store.SearchResult{{Observation: store.Observation{ID: fx.secondObs, DeletedAt: deletedAt}}} + return m + }, + handle: func(m Model, key string) (tea.Model, tea.Cmd) { + return m.handleSearchResultsKeys(key) + }, + }, + { + name: "recent", + model: func(fx testFixture, deletedAt *string) Model { + m := New(fx.store, "") + m.Screen = ScreenRecent + m.RecentObservations = []store.Observation{{ID: fx.secondObs, DeletedAt: deletedAt}} + return m + }, + handle: func(m Model, key string) (tea.Model, tea.Cmd) { + return m.handleRecentKeys(key) + }, + }, + { + name: "session detail", + model: func(fx testFixture, deletedAt *string) Model { + m := New(fx.store, "") + m.Screen = ScreenSessionDetail + m.SessionObservations = []store.Observation{{ID: fx.secondObs, DeletedAt: deletedAt}} + return m + }, + handle: func(m Model, key string) (tea.Model, tea.Cmd) { + return m.handleSessionDetailKeys(key) + }, + }, + } { + t.Run(tc.name+" deleted", func(t *testing.T) { + fx := newTestFixture(t) + if err := fx.store.DeleteObservation(fx.secondObs, false); err != nil { + t.Fatalf("DeleteObservation: %v", err) + } + m := tc.model(fx, &deletedAt) + + _, cmd := tc.handle(m, "r") + if cmd == nil { + t.Fatal("restore should return command for deleted observation") + } + msg := cmd() + if _, ok := msg.(observationRestoredMsg); !ok { + t.Fatalf("restore command message type = %T", msg) + } + }) + + t.Run(tc.name+" active", func(t *testing.T) { + fx := newTestFixture(t) + m := tc.model(fx, nil) + + updatedModel, cmd := tc.handle(m, "r") + updated := updatedModel.(Model) + if cmd != nil { + t.Fatal("restore should no-op for active observation") + } + if updated.Screen == ScreenConfirmDelete || updated.Confirm.Action != confirmNone { + t.Fatalf("active restore changed confirmation state: screen=%v confirm=%+v", updated.Screen, updated.Confirm) + } + }) + } + }) +} + +func TestTUITrashKeysRestoreAndPurge(t *testing.T) { + deletedAt := "2026-06-08 10:00:00" + fx := newTestFixture(t) + if err := fx.store.DeleteObservation(fx.obsID, false); err != nil { + t.Fatalf("DeleteObservation: %v", err) + } + + m := New(fx.store, "") + m.Screen = ScreenTrash + m.Height = 14 + m.TrashObservations = []store.Observation{ + {ID: fx.obsID, DeletedAt: &deletedAt}, + {ID: fx.secondObs, DeletedAt: &deletedAt}, + {ID: 33, DeletedAt: &deletedAt}, + {ID: 44, DeletedAt: &deletedAt}, + } + m.Cursor = 2 + + updatedModel, _ := m.handleTrashKeys("down") + updated := updatedModel.(Model) + if updated.Cursor != 3 || updated.Scroll != 1 { + t.Fatalf("trash down cursor/scroll = %d/%d, want 3/1", updated.Cursor, updated.Scroll) + } + updatedModel, _ = updated.handleTrashKeys("up") + updated = updatedModel.(Model) + if updated.Cursor != 2 || updated.Scroll != 1 { + t.Fatalf("trash up cursor/scroll = %d/%d, want 2/1", updated.Cursor, updated.Scroll) + } + + m.Cursor = 0 + updatedModel, cmd := m.handleTrashKeys("r") + updated = updatedModel.(Model) + if cmd == nil { + t.Fatal("trash r should return restore command") + } + if updated.Screen != ScreenTrash { + t.Fatalf("trash r screen = %v, want %v", updated.Screen, ScreenTrash) + } + if _, ok := cmd().(observationRestoredMsg); !ok { + t.Fatalf("trash restore command message type unexpected") + } + + updatedModel, cmd = m.handleTrashKeys("delete") + updated = updatedModel.(Model) + if cmd != nil { + t.Fatal("trash delete should wait for confirmation") + } + if updated.Screen != ScreenConfirmDelete || updated.Confirm.Action != confirmPurgeObservation || updated.Confirm.ReturnScreen != ScreenTrash { + t.Fatalf("trash delete confirmation = screen %v confirm %+v", updated.Screen, updated.Confirm) + } + + updatedModel, cmd = m.handleTrashKeys("enter") + updated = updatedModel.(Model) + if updated.PrevScreen != ScreenTrash || cmd == nil { + t.Fatal("trash enter should request detail with previous screen set to trash") + } + + updatedModel, cmd = m.handleTrashKeys("esc") + updated = updatedModel.(Model) + if updated.Screen != ScreenDashboard || updated.Cursor != 0 || updated.Scroll != 0 || cmd == nil { + t.Fatal("trash esc should return dashboard and refresh stats") + } + m.Screen = ScreenTrash + updatedModel, cmd = m.handleTrashKeys("q") + updated = updatedModel.(Model) + if updated.Screen != ScreenDashboard || cmd == nil { + t.Fatal("trash q should return dashboard and refresh stats") + } +} + +func TestTUIConfirmDeleteKeys(t *testing.T) { + for _, key := range []string{"y", "Y", "enter"} { + t.Run("purge "+key, func(t *testing.T) { + fx := newTestFixture(t) + if err := fx.store.DeleteObservation(fx.obsID, false); err != nil { + t.Fatalf("DeleteObservation: %v", err) + } + m := New(fx.store, "") + m.Screen = ScreenConfirmDelete + m.Confirm = confirmState{ + Action: confirmPurgeObservation, + ObservationID: fx.obsID, + ReturnScreen: ScreenTrash, + } + + updatedModel, cmd := m.handleConfirmDeleteKeys(key) + updated := updatedModel.(Model) + if cmd == nil { + t.Fatalf("%s should return purge command", key) + } + if updated.Screen != ScreenTrash { + t.Fatalf("%s should leave confirmation for return screen, got %v", key, updated.Screen) + } + if updated.Confirm != (confirmState{}) { + t.Fatalf("%s should clear confirm state before command returns: %+v", key, updated.Confirm) + } + if _, ok := cmd().(observationPurgedMsg); !ok { + t.Fatalf("%s command should purge observation", key) + } + }) + + t.Run("delete session "+key, func(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenConfirmDelete + m.Confirm = confirmState{ + Action: confirmDeleteSession, + SessionID: fx.otherSession, + ReturnScreen: ScreenSessions, + } + + updatedModel, cmd := m.handleConfirmDeleteKeys(key) + updated := updatedModel.(Model) + if cmd == nil { + t.Fatalf("%s should return session cascade command", key) + } + if updated.Screen != ScreenSessions { + t.Fatalf("%s should leave confirmation for return screen, got %v", key, updated.Screen) + } + if updated.Confirm != (confirmState{}) { + t.Fatalf("%s should clear confirm state before command returns: %+v", key, updated.Confirm) + } + if _, ok := cmd().(sessionDeletedMsg); !ok { + t.Fatalf("%s command should delete session cascade", key) + } + }) + } + + for _, key := range []string{"n", "N", "esc", "q"} { + t.Run("cancel "+key, func(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenConfirmDelete + m.Confirm = confirmState{ + Action: confirmPurgeObservation, + ObservationID: 12, + ReturnScreen: ScreenTrash, + } + + updatedModel, cmd := m.handleConfirmDeleteKeys(key) + updated := updatedModel.(Model) + if cmd != nil { + t.Fatalf("%s cancel should not return command", key) + } + if updated.Screen != ScreenTrash { + t.Fatalf("%s cancel screen = %v, want %v", key, updated.Screen, ScreenTrash) + } + if updated.Confirm != (confirmState{}) { + t.Fatalf("%s cancel should clear confirm state: %+v", key, updated.Confirm) + } + }) + } + + t.Run("operation result returns from confirmation before refresh", func(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenConfirmDelete + m.Confirm = confirmState{Action: confirmPurgeObservation, ReturnScreen: ScreenTrash, ObservationID: fx.obsID} + + updatedModel, cmd := m.Update(observationPurgedMsg{id: fx.obsID}) + updated := updatedModel.(Model) + if updated.Screen != ScreenTrash || updated.Confirm != (confirmState{}) || cmd == nil { + t.Fatalf("purge result should return to trash, clear confirm, and refresh: screen=%v confirm=%+v cmd=%v", updated.Screen, updated.Confirm, cmd) + } + + m.Screen = ScreenConfirmDelete + m.Confirm = confirmState{Action: confirmDeleteSession, ReturnScreen: ScreenSessions, SessionID: fx.sessionID} + updatedModel, cmd = m.Update(sessionDeletedMsg{sessionID: fx.sessionID}) + updated = updatedModel.(Model) + if updated.Screen != ScreenSessions || updated.Confirm != (confirmState{}) || cmd == nil { + t.Fatalf("session delete result should return to sessions, clear confirm, and refresh: screen=%v confirm=%+v cmd=%v", updated.Screen, updated.Confirm, cmd) + } + }) +} + +func TestTUIDeleteNonEmptySessionRequiresConfirmation(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{ + {ID: fx.sessionID, ObservationCount: 2}, + {ID: fx.otherSession, ObservationCount: 0}, + } + + updatedModel, cmd := m.handleSessionsKeys("d") + updated := updatedModel.(Model) + if cmd != nil { + t.Fatal("session delete should not run command before confirmation") + } + if updated.Screen != ScreenConfirmDelete || updated.Confirm.Action != confirmDeleteSession || updated.Confirm.SessionID != fx.sessionID { + t.Fatalf("non-empty session confirmation = screen %v confirm %+v", updated.Screen, updated.Confirm) + } + if updated.Confirm.ReturnScreen != ScreenSessions || updated.Confirm.ObservationCount != 2 { + t.Fatalf("non-empty session return/count = %v/%d", updated.Confirm.ReturnScreen, updated.Confirm.ObservationCount) + } + if !strings.Contains(updated.Confirm.Body, "permanently delete all 2 memories") || !strings.Contains(updated.Confirm.Body, "cannot be recovered") { + t.Fatalf("warning body = %q", updated.Confirm.Body) + } + if _, err := fx.store.GetSession(fx.sessionID); err != nil { + t.Fatalf("session was deleted before confirmation: %v", err) + } + + m.Cursor = 1 + updatedModel, cmd = m.handleSessionsKeys("delete") + updated = updatedModel.(Model) + if cmd != nil { + t.Fatal("empty session delete should also wait for confirmation") + } + if updated.Screen != ScreenConfirmDelete || updated.Confirm.SessionID != fx.otherSession || updated.Confirm.ObservationCount != 0 { + t.Fatalf("empty session confirmation = screen %v confirm %+v", updated.Screen, updated.Confirm) + } + + _, cmd = updated.handleConfirmDeleteKeys("enter") + if cmd == nil { + t.Fatal("empty session confirmation should return cascade command") + } + if _, ok := cmd().(sessionDeletedMsg); !ok { + t.Fatalf("empty session confirmation command should cascade delete") + } +} + func TestRefreshScreen(t *testing.T) { m := New(newTestFixture(t).store, "") @@ -314,6 +763,9 @@ func TestRefreshScreen(t *testing.T) { if cmd := m.refreshScreen(ScreenSessions); cmd == nil { t.Fatal("sessions refresh should return sessions command") } + if cmd := m.refreshScreen(ScreenTrash); cmd == nil { + t.Fatal("trash refresh should return trash observations command") + } if cmd := m.refreshScreen(ScreenSearch); cmd != nil { t.Fatal("search refresh should not return command") } @@ -419,6 +871,64 @@ func TestUpdateDataMessageBranches(t *testing.T) { t.Fatal("session observations message should open session detail and reset cursor/scroll") } + m = New(nil, "") + m.Screen = ScreenDashboard + m.Cursor = 4 + m.Scroll = 3 + updatedModel, _ = m.Update(trashObservationsMsg{observations: obsList}) + updated = updatedModel.(Model) + if updated.Screen != ScreenDashboard { + t.Fatalf("delayed trash message should not change screen, got %v", updated.Screen) + } + if updated.Cursor != 4 || updated.Scroll != 3 { + t.Fatalf("delayed trash message changed cursor/scroll to %d/%d", updated.Cursor, updated.Scroll) + } + if len(updated.TrashObservations) != 1 { + t.Fatal("trash observations should still update when message arrives off-screen") + } + + m.Screen = ScreenTrash + m.Cursor = 5 + m.Scroll = 2 + updatedModel, _ = m.Update(trashObservationsMsg{observations: obsList}) + updated = updatedModel.(Model) + if updated.Screen != ScreenTrash || updated.Cursor != 0 || updated.Scroll != 0 { + t.Fatal("trash message on trash screen should keep trash screen and reset cursor/scroll") + } + + m = New(newTestFixture(t).store, "") + m.Screen = ScreenRecent + m.ErrorMsg = "old error" + updatedModel, cmd := m.Update(observationDeletedMsg{id: 1}) + updated = updatedModel.(Model) + if updated.ErrorMsg != "" || cmd == nil { + t.Fatal("successful observation delete message should clear error and refresh current screen") + } + + m.Screen = ScreenTrash + m.ErrorMsg = "old error" + updatedModel, cmd = m.Update(observationRestoredMsg{id: 1}) + updated = updatedModel.(Model) + if updated.ErrorMsg != "" || cmd == nil { + t.Fatal("successful observation restore message should clear error and refresh current screen") + } + + m.Screen = ScreenTrash + m.ErrorMsg = "old error" + updatedModel, cmd = m.Update(observationPurgedMsg{id: 1}) + updated = updatedModel.(Model) + if updated.ErrorMsg != "" || cmd == nil { + t.Fatal("successful observation purge message should clear error and refresh current screen") + } + + m.Screen = ScreenSessions + m.ErrorMsg = "old error" + updatedModel, cmd = m.Update(sessionDeletedMsg{sessionID: "s1"}) + updated = updatedModel.(Model) + if updated.ErrorMsg != "" || cmd == nil { + t.Fatal("successful session delete message should clear error and refresh current screen") + } + updated.SetupInstalling = true updatedModel, _ = updated.Update(setupInstallMsg{err: errors.New("setup err")}) updated = updatedModel.(Model) @@ -456,6 +966,7 @@ func TestHandleKeyPressRouterAndClearsError(t *testing.T) { ScreenTimeline, ScreenSessions, ScreenSessionDetail, + ScreenTrash, ScreenSetup, } { m.Screen = screen @@ -483,7 +994,7 @@ func TestHandleDashboardKeysAndSelectionRemainingBranches(t *testing.T) { t.Fatal("cursor should stay at bottom boundary") } - m.Cursor = 4 + m.Cursor = 5 _, cmd := m.handleDashboardKeys(" ") if cmd == nil { t.Fatal("space on quit item should return quit command") @@ -501,10 +1012,10 @@ func TestHandleDashboardKeysAndSelectionRemainingBranches(t *testing.T) { t.Fatal("cursor 0 selection should open search") } - m.Cursor = 4 + m.Cursor = 5 _, cmd = m.handleDashboardSelection() if cmd == nil { - t.Fatal("cursor 4 selection should quit") + t.Fatal("cursor 5 selection should quit") } m.Cursor = 99 diff --git a/internal/tui/view.go b/internal/tui/view.go index 68a1958b..97b762c2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/Gentleman-Programming/engram/internal/store" "github.com/Gentleman-Programming/engram/internal/timeutil" "github.com/Gentleman-Programming/engram/internal/version" "github.com/charmbracelet/lipgloss" @@ -77,6 +78,10 @@ func (m Model) View() string { content = m.viewSessions() case ScreenSessionDetail: content = m.viewSessionDetail() + case ScreenTrash: + content = m.viewTrash() + case ScreenConfirmDelete: + content = m.viewConfirmDelete() case ScreenSetup: content = m.viewSetup() default: @@ -222,7 +227,7 @@ func (m Model) viewSearchResults() string { for i := m.Scroll; i < end; i++ { r := m.SearchResults[i] - b.WriteString(m.renderObservationListItem(i, r.ID, r.Type, r.Title, r.Content, r.CreatedAt, r.Project)) + b.WriteString(m.renderObservationListItem(i, r.Observation)) } // Scroll indicator @@ -231,7 +236,7 @@ func (m Model) viewSearchResults() string { timestampStyle.Render(fmt.Sprintf("showing %d-%d of %d", m.Scroll+1, end, resultCount)))) } - b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • c copy • t timeline • / search • esc back")) + b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • c copy • t timeline • r restore • d delete • / search • esc back")) return b.String() } @@ -265,7 +270,7 @@ func (m Model) viewRecent() string { for i := m.Scroll; i < end; i++ { o := m.RecentObservations[i] - b.WriteString(m.renderObservationListItem(i, o.ID, o.Type, o.Title, o.Content, o.CreatedAt, o.Project)) + b.WriteString(m.renderObservationListItem(i, o)) } if count > visibleItems { @@ -273,7 +278,7 @@ func (m Model) viewRecent() string { timestampStyle.Render(fmt.Sprintf("showing %d-%d of %d", m.Scroll+1, end, count)))) } - b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • c copy • t timeline • esc back")) + b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • c copy • t timeline • r restore • d delete • esc back")) return b.String() } @@ -498,7 +503,7 @@ func (m Model) viewSessions() string { timestampStyle.Render(fmt.Sprintf("showing %d-%d of %d", m.Scroll+1, end, count)))) } - b.WriteString(helpStyle.Render("\n j/k navigate • enter view session • esc back")) + b.WriteString(helpStyle.Render("\n j/k navigate • enter view session • d delete • esc back")) return b.String() } @@ -550,7 +555,7 @@ func (m Model) viewSessionDetail() string { for i := m.SessionDetailScroll; i < end; i++ { o := m.SessionObservations[i] - b.WriteString(m.renderObservationListItem(i, o.ID, o.Type, o.Title, o.Content, o.CreatedAt, o.Project)) + b.WriteString(m.renderObservationListItem(i, o)) } if count > visibleItems { @@ -558,7 +563,67 @@ func (m Model) viewSessionDetail() string { timestampStyle.Render(fmt.Sprintf("showing %d-%d of %d", m.SessionDetailScroll+1, end, count)))) } - b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • c copy • t timeline • esc back")) + b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • c copy • t timeline • r restore • d delete • esc back")) + + return b.String() +} + +// ─── Recycle Bin ───────────────────────────────────────────────────────────── + +func (m Model) viewTrash() string { + var b strings.Builder + + count := len(m.TrashObservations) + header := fmt.Sprintf(" Recycle Bin - %d deleted", count) + b.WriteString(headerStyle.Render(header)) + b.WriteString("\n") + + if count == 0 { + b.WriteString(noResultsStyle.Render("No deleted memories.")) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render(" esc back")) + return b.String() + } + + visibleItems := (m.Height - 8) / 2 + if visibleItems < 3 { + visibleItems = 3 + } + + end := m.Scroll + visibleItems + if end > count { + end = count + } + + for i := m.Scroll; i < end; i++ { + b.WriteString(m.renderObservationListItem(i, m.TrashObservations[i])) + } + + if count > visibleItems { + b.WriteString(fmt.Sprintf("\n %s", + timestampStyle.Render(fmt.Sprintf("showing %d-%d of %d", m.Scroll+1, end, count)))) + } + + b.WriteString(helpStyle.Render("\n j/k navigate - enter detail - r restore - d delete forever - esc back")) + + return b.String() +} + +// ─── Confirm Delete ────────────────────────────────────────────────────────── + +func (m Model) viewConfirmDelete() string { + var b strings.Builder + + title := m.Confirm.Title + if strings.TrimSpace(title) == "" { + title = "Confirm delete" + } + + b.WriteString(headerStyle.Render(" " + title)) + b.WriteString("\n\n") + b.WriteString(warningStyle.Render(m.Confirm.Body)) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render(" y/enter confirm - n/esc cancel")) return b.String() } @@ -684,12 +749,28 @@ func (m Model) viewSetup() string { // ─── Shared Renderers ──────────────────────────────────────────────────────── -func (m Model) renderObservationListItem(index int, id int64, obsType, title, content, createdAt string, project *string) string { +func (m Model) renderObservationListItem(index int, o store.Observation) string { + id := o.ID + obsType := o.Type + title := o.Title + content := o.Content + createdAt := o.CreatedAt + project := o.Project + cursor := " " style := listItemStyle + previewStyle := contentPreviewStyle + deleted := store.IsObservationDeleted(o) + if deleted { + style = deletedItemStyle + previewStyle = deletedItemStyle.PaddingLeft(4) + } if index == m.Cursor { cursor = "▸ " style = listSelectedStyle + if deleted { + style = deletedSelectedStyle + } } proj := "" @@ -697,18 +778,24 @@ func (m Model) renderObservationListItem(index int, id int64, obsType, title, co proj = " " + projectStyle.Render(*project) } - line := fmt.Sprintf("%s%s %s %s%s %s\n", + deletedMarker := "" + if deleted { + deletedMarker = " " + deletedBadgeStyle.Render("[deleted]") + } + + line := fmt.Sprintf("%s%s %s %s%s%s %s\n", cursor, idStyle.Render(fmt.Sprintf("#%-5d", id)), typeBadgeStyle.Render(fmt.Sprintf("[%-12s]", obsType)), style.Render(truncateStr(title, 50)), + deletedMarker, proj, timestampStyle.Render(localTime(createdAt))) // Content preview on second line preview := truncateStr(content, 80) if preview != "" { - line += contentPreviewStyle.Render(preview) + "\n" + line += previewStyle.Render(preview) + "\n" } return line diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index f68aa2eb..a056693a 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -39,15 +39,14 @@ func TestRenderObservationListItem(t *testing.T) { m.Cursor = 1 project := "engram" - line := m.renderObservationListItem( - 1, - 42, - "bugfix", - "Title here", - "content line 1\ncontent line 2", - "2026-01-01", - &project, - ) + line := m.renderObservationListItem(1, store.Observation{ + ID: 42, + Type: "bugfix", + Title: "Title here", + Content: "content line 1\ncontent line 2", + CreatedAt: "2026-01-01", + Project: &project, + }) if !strings.Contains(line, "▸") { t.Fatal("selected item should include cursor marker") @@ -77,6 +76,137 @@ func TestViewRouterAndErrorRendering(t *testing.T) { } } +func TestViewConfirmDeleteSafetyPrompt(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenConfirmDelete + m.Confirm = confirmState{ + Title: "Delete session", + Body: "This will permanently delete all 2 memories in session \"session-1\". This operation cannot be recovered.", + } + + out := m.View() + if strings.Contains(out, "Unknown screen") { + t.Fatal("confirmation screen should not fall through to unknown screen") + } + if !strings.Contains(out, "Delete session") { + t.Fatal("confirmation title should be visible") + } + if !strings.Contains(out, "permanently delete all 2 memories") || !strings.Contains(out, "cannot be recovered") { + t.Fatalf("confirmation body should be visible, got %q", out) + } + if !strings.Contains(out, "y/enter confirm") || !strings.Contains(out, "n/esc cancel") { + t.Fatalf("confirmation help should show confirm/cancel keys, got %q", out) + } +} + +func TestViewRendersDeletedObservationStatus(t *testing.T) { + deletedAt := "2026-06-08 10:00:00" + project := "engram" + deletedObservation := store.Observation{ + ID: 7, + Type: "decision", + Title: "Deleted title", + Content: "deleted content survives", + CreatedAt: "2026-06-08", + Project: &project, + DeletedAt: &deletedAt, + } + + tests := []struct { + name string + view func(Model) string + }{ + { + name: "search results", + view: func(m Model) string { + m.Screen = ScreenSearchResults + m.SearchQuery = "deleted" + m.SearchResults = []store.SearchResult{{Observation: deletedObservation}} + return m.viewSearchResults() + }, + }, + { + name: "recent", + view: func(m Model) string { + m.Screen = ScreenRecent + m.RecentObservations = []store.Observation{deletedObservation} + return m.viewRecent() + }, + }, + { + name: "session detail", + view: func(m Model) string { + m.Screen = ScreenSessionDetail + m.Sessions = []store.SessionSummary{{ID: "session-1", Project: "engram", StartedAt: "2026-06-08"}} + m.SelectedSessionIdx = 0 + m.SessionObservations = []store.Observation{deletedObservation} + return m.viewSessionDetail() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New(nil, "") + m.Height = 14 + + out := tt.view(m) + if !strings.Contains(out, "[deleted]") { + t.Fatalf("deleted marker missing from %s view: %q", tt.name, out) + } + if !strings.Contains(out, "Deleted title") || !strings.Contains(out, "deleted content survives") { + t.Fatalf("deleted observation content should stay visible in %s view: %q", tt.name, out) + } + if !strings.Contains(out, "r restore") || !strings.Contains(out, "d delete") { + t.Fatalf("deleted observation help missing restore/delete keys in %s view: %q", tt.name, out) + } + }) + } +} + +func TestViewTrashAndConfirmation(t *testing.T) { + deletedAt := "2026-06-08 10:00:00" + m := New(nil, "") + m.Screen = ScreenTrash + m.Height = 14 + m.TrashObservations = []store.Observation{ + {ID: 9, Type: "bugfix", Title: "Trash memory", Content: "trash content remains", CreatedAt: "2026-06-08", DeletedAt: &deletedAt}, + } + + out := m.View() + if !strings.Contains(out, "Recycle Bin") || !strings.Contains(out, "[deleted]") { + t.Fatalf("trash view missing title or deleted marker: %q", out) + } + if !strings.Contains(out, "Trash memory") || !strings.Contains(out, "trash content remains") { + t.Fatalf("trash view should show original deleted memory content: %q", out) + } + if !strings.Contains(out, "r restore") || !strings.Contains(out, "d delete forever") { + t.Fatalf("trash view missing restore/delete help: %q", out) + } + + m.TrashObservations = nil + out = m.View() + if !strings.Contains(out, "No deleted memories") { + t.Fatalf("trash view missing empty state: %q", out) + } + + m.Screen = ScreenConfirmDelete + m.Confirm = confirmState{ + Title: "Delete session", + Body: "This will permanently delete all 3 memories in session \"sess-full\". This operation cannot be recovered.", + } + out = m.View() + if !strings.Contains(out, "Delete session") || !strings.Contains(out, "permanently delete all 3 memories") { + t.Fatalf("confirmation title/body missing: %q", out) + } + if !strings.Contains(out, "cannot be recovered") { + t.Fatalf("confirmation warning missing: %q", out) + } + if !strings.Contains(out, "y/enter confirm") || !strings.Contains(out, "n/esc cancel") { + t.Fatalf("confirmation help missing confirm/cancel keys: %q", out) + } +} + func TestViewSearchResultsAndScrollIndicator(t *testing.T) { m := New(nil, "") m.Screen = ScreenSearchResults @@ -312,6 +442,7 @@ func TestViewObservationDetailTimelineSessionsAndSessionDetail(t *testing.T) { func TestViewRouterCoversAllScreens(t *testing.T) { m := New(nil, "") + deletedAt := "2026-06-08 10:00:00" m.Stats = &store.Stats{} m.SearchResults = []store.SearchResult{{Observation: store.Observation{ID: 1, Type: "bugfix", Title: "t", Content: "c", CreatedAt: "now"}}} m.SearchQuery = "q" @@ -321,6 +452,8 @@ func TestViewRouterCoversAllScreens(t *testing.T) { m.Sessions = []store.SessionSummary{{ID: "s1", Project: "engram", StartedAt: "now", ObservationCount: 1}} m.SelectedSessionIdx = 0 m.SessionObservations = []store.Observation{{ID: 1, Type: "bugfix", Title: "t", Content: "c", CreatedAt: "now"}} + m.TrashObservations = []store.Observation{{ID: 2, Type: "bugfix", Title: "deleted t", Content: "deleted c", CreatedAt: "now", DeletedAt: &deletedAt}} + m.Confirm = confirmState{Title: "Delete memory forever", Body: "This operation cannot be recovered."} m.SetupAgents = []setup.Agent{{Name: "opencode", Description: "OpenCode", InstallDir: "/tmp"}} m.Height = 20 @@ -336,6 +469,8 @@ func TestViewRouterCoversAllScreens(t *testing.T) { {screen: ScreenTimeline, want: "Timeline"}, {screen: ScreenSessions, want: "Sessions"}, {screen: ScreenSessionDetail, want: "Session:"}, + {screen: ScreenTrash, want: "Recycle Bin"}, + {screen: ScreenConfirmDelete, want: "cannot be recovered"}, {screen: ScreenSetup, want: "Setup"}, }