diff --git a/docs/INDEX.md b/docs/INDEX.md index 16e4e247..0edb00b7 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -34,6 +34,7 @@ If the docs themselves feel stale or scattered, also read `docs/reference/DOCS_R - Settings panel: `docs/features/settings.md` - Settings sync: `docs/features/settings-sync.md` - Agent Chat (Beta-gated chat pane, providers, sidecar, attachments): `docs/features/agent-chat.md` +- Agent run checkpoints (issue #80 — opt-in background rollback snapshot at run start + restore): `docs/features/agent-chat.md` (§ Run checkpoints); design note at `docs/plans/agent-run-checkpoint.md` - Multi-provider chat (Step 12): `docs/features/multi-provider-chat.md`; plan + final-state summary at `docs/plans/step-12-opencode-implementation-plan.md`; research at `docs/plans/step-12-opencode-research.md`; operator UI smoke at `docs/plans/step-12-ui-smoke-checklist.md` - Skills sync (E2E, Step 10): `docs/features/skills-sync.md`; plan + per-stage history at `docs/plans/step-10-skills-sync.md`; research at `docs/plans/step-10-skills-sync-research.md`; operator UI smoke at `docs/plans/step-10-ui-smoke-checklist.md` - Attachments + context system (Step 8): `docs/plans/step-8-attachments.md` (research + locked plan) diff --git a/docs/core/STATUS.md b/docs/core/STATUS.md index fa109dc6..1597c64e 100644 --- a/docs/core/STATUS.md +++ b/docs/core/STATUS.md @@ -171,6 +171,7 @@ The repo structure is clean and domain-split: - Permissions settings page with per-tool body rendering - Wired Debug mode pill with marker cleanup flow - Plain-quit on Beta toggle off (no auto-restart) +- **Run checkpoints (issue #80, opt-in)**: background working-tree snapshot at session start (shadow ref via temp index — zero first-token latency, user's index/worktree/stash untouched), restore button in the pane header (confirm dialog, disabled mid-turn), Settings → Agent toggle, per-thread bookkeeping in `agent_chat_checkpoints`, ref pruning. See `docs/features/agent-chat.md` § Run checkpoints + `docs/plans/agent-run-checkpoint.md` - See `docs/features/agent-chat.md` for the canonical feature breakdown ### Infrastructure diff --git a/docs/features/agent-chat.md b/docs/features/agent-chat.md index 581dce07..ca1c0a30 100644 --- a/docs/features/agent-chat.md +++ b/docs/features/agent-chat.md @@ -508,12 +508,51 @@ string when the target provider is missing from the registry. | `agent_chat_set_model` | Swap a thread's model mid-session. | | `agent_chat_set_permission_mode` | Swap a thread's permission mode mid-session. | | `agent_chat_stop_session` | Gracefully close a session. Idempotent. | +| `agent_chat_get_checkpoint` | Return the run-start rollback checkpoint recorded for a thread (or null). Not flag-gated — renders as "no checkpoint" instead of an error. | +| `agent_chat_restore_checkpoint` | Roll the workspace back to the thread's run-start checkpoint. Mutates the working tree; the UI confirms first. | Provider errors are serialized as `SerializableProviderError` JSON so the UI can inspect the error subtype (e.g. `{"kind":"not_authenticated", ...}`) instead of parsing a free-form string. +## Run checkpoints (issue #80) + +Opt-in rollback point taken when a chat session starts. Off by +default; toggled via Settings → Agent → "Checkpoint before agent +runs" (`UserSettings.agent_chat.checkpoints_enabled`, synced +settings). + +- **Background, never on the first-token path.** `agent_chat_start_session` + spawns `tauri::async_runtime::spawn` + `spawn_blocking` AFTER the + provider session is up; even the settings-cache read happens inside + the spawned task. +- **Non-destructive snapshot.** `git_checkpoint_create` + (`src-tauri/src/git.rs`) stages the worktree (tracked + untracked, + `.gitignore` respected) into a temporary `GIT_INDEX_FILE`, writes a + tree, and `commit-tree`s it onto HEAD — the user's index, worktree, + and stash list are untouched and no hooks run. The commit is + anchored at `refs/codemux/checkpoints/` so gc + can't reap it. Skipped silently for non-repos and unborn-HEAD repos. +- **Recorded per thread.** `agent_chat_checkpoints` table (FK → + `agent_chat_sessions`, ON DELETE CASCADE) stores snapshot commit, + HEAD commit, branch, repo path, ref name. On success the backend + emits `agent_chat_checkpoint` (`{thread_id, checkpoint}`); the + frontend hook `useAgentChatCheckpoint` (event + on-mount fetch) + feeds the pane header's restore button (history icon, hover-reveal, + hidden when no checkpoint, disabled mid-turn). +- **Restore** (`git_checkpoint_restore`): refuses on branch mismatch + or pruned commits; takes a safety snapshot of the current state to + `refs/codemux/pre-restore/` first; then `read-tree --reset -u + ` + `clean -fd` + `reset --mixed `. Result: + run commits undone, run-created files deleted (ignored files + spared), pre-run changes back as unstaged edits / untracked files. + Known loss: the pre-run staged/unstaged split flattens to unstaged. +- **Pruning.** Each create prunes both `refs/codemux/` namespaces to + the 20 newest refs and drops the matching bookkeeping rows. + +Design note: `docs/plans/agent-run-checkpoint.md`. + ## Event bridge Every registered provider's canonical event stream is forwarded to diff --git a/docs/plans/agent-run-checkpoint.md b/docs/plans/agent-run-checkpoint.md new file mode 100644 index 00000000..e20d6d7c --- /dev/null +++ b/docs/plans/agent-run-checkpoint.md @@ -0,0 +1,107 @@ +# Agent Run Checkpoint (issue #80) + +- Purpose: Track the design and rollout of the optional background rollback checkpoint taken at agent-chat run start. +- Audience: Anyone changing agent-chat session start, git snapshot helpers, or the restore UI. +- Authority: Active work plan only, not current truth. +- Update when: Scope, restore semantics, or follow-ups change. +- Read next: `docs/features/agent-chat.md`, `docs/core/STATUS.md` + +## Goal + +When an agent-chat session starts, take an **opt-in, background** snapshot of the +workspace working tree so the user can roll back everything the run changed. The +snapshot must add **zero latency** to the first token and must **not disturb** the +user's index, worktree, or stash list. + +## Design + +### Snapshot (non-destructive, shadow ref) + +`git stash create` ignores untracked files, so the snapshot instead builds a commit +through a **temporary index** (`GIT_INDEX_FILE`), which never touches the user's real +index or worktree: + +1. `git read-tree HEAD` into a temp index file +2. `git add -A` (temp index — captures tracked changes **and** untracked files, respects `.gitignore`) +3. `git write-tree` → snapshot tree +4. `git commit-tree -p HEAD` → snapshot commit (no hooks run, identity forced via `-c user.*`) +5. `git update-ref refs/codemux/checkpoints/ ` → protects the snapshot from gc + +Recorded against the thread in SQLite (`agent_chat_checkpoints`, FK → `agent_chat_sessions` +ON DELETE CASCADE): snapshot commit, HEAD commit, branch name, repo path, ref name. + +Skipped silently (no checkpoint row) when: the setting is off, `cwd` is not a git repo, +or the repo has an unborn HEAD (no commits yet). + +### Background execution + +`agent_chat_start_session` spawns `tauri::async_runtime::spawn` + `spawn_blocking` +**after** the provider session has started and the session row is persisted. Nothing +git-related runs on the start-session (or first-token) path. On success the backend +emits `agent_chat_checkpoint` (`{ thread_id, checkpoint }`) so the pane header can +reveal the restore affordance. + +### Restore semantics + +`agent_chat_restore_checkpoint(thread_id)`: + +1. Refuse if the snapshot/HEAD commits are gone (pruned) or the repo is now on a different branch. +2. Safety snapshot of the **current** state to `refs/codemux/pre-restore/` (parents at the + agent's last commit, so even agent-made commits stay reachable after the branch resets). +3. `git read-tree --reset -u ` — worktree + index now match the snapshot tree + (restores modified/deleted files, including formerly-untracked ones). +4. `git clean -fd` — deletes run-created files (ignored files are spared). +5. `git reset --mixed ` — undoes run-made commits, leaves the restored + content as **unstaged** changes; formerly-untracked files show as untracked again. + +Known, documented loss: the staged/unstaged split of the pre-run state is flattened to +unstaged (the snapshot records one tree, not the index). + +### Pruning + +Each successful checkpoint prunes both `refs/codemux/checkpoints/*` and +`refs/codemux/pre-restore/*` to the 20 newest per namespace (by committer date) and +deletes the matching `agent_chat_checkpoints` rows, so shadow refs cannot grow unboundedly. + +### Opt-in setting + +`UserSettings.agent_chat.checkpoints_enabled` (synced settings, **default `false`**). +Toggle lives in Settings → Agent (visible when the agent-chat beta flag is on). The +background task reads the settings cache (`settings_sync::load_cache()`), same pattern +as session-restore. + +## Active Priorities + +1. ~~Backend: git helpers + DB table + commands + start-session hook~~ (landed) +2. ~~Frontend: setting toggle, store slice, header restore button + confirm dialog~~ (landed) +3. Follow-ups below + +## Open Questions / Follow-ups + +- Checkpoint continuity across silent restarts (permission-mode change restarts the + session under a new thread id, which takes a fresh checkpoint — "before this run" + then means "before the restart"). Possible fix: carry the checkpoint forward when + `resume_cursor` is set. +- Surface checkpoints over the socket API / MCP server so agents can self-rollback. +- Per-turn checkpoint history (explicitly out of scope for v1 per issue #80). +- Restore button for *past* sessions from the SessionSelector dropdown. + +## Likely Touch Points + +- `src-tauri/src/git.rs` — `git_checkpoint_create` / `git_checkpoint_restore` / `git_checkpoint_prune` +- `src-tauri/src/database.rs` — `agent_chat_checkpoints` table + CRUD +- `src-tauri/src/commands/agent_chat.rs` — spawn hook, `agent_chat_get_checkpoint`, `agent_chat_restore_checkpoint` +- `src-tauri/src/settings_sync.rs` — `AgentChatSettings` +- `src/components/chat/AgentChatPaneHeader.tsx` — restore affordance +- `src/stores/agent-chat-store.ts` — per-thread checkpoint slice +- `src/components/settings/settings-view.tsx` — opt-in toggle +- `src/dev/tauri-mock.ts` — mock handlers for browser-pane smoke tests + +## Already Landed + +- (see git history of this branch) + +## Notes + +- The anti-pattern this feature exists to avoid: any synchronous `git add -A`-style + walk between the user pressing send and the provider's first token. diff --git a/src-tauri/src/commands/agent_chat.rs b/src-tauri/src/commands/agent_chat.rs index c77b9840..ef5bf23a 100644 --- a/src-tauri/src/commands/agent_chat.rs +++ b/src-tauri/src/commands/agent_chat.rs @@ -26,7 +26,7 @@ use crate::agent_provider::{ ProviderRuntimeEvent, RequestId, SendTurnInput, SerializableProviderError, StartSessionInput, ThreadId, TurnId, }; -use crate::database::{AgentChatSessionRecord, DatabaseStore}; +use crate::database::{AgentChatCheckpointRecord, AgentChatSessionRecord, DatabaseStore}; use crate::observability::ObservabilityStore; use crate::state::AppStateStore; @@ -481,11 +481,235 @@ pub async fn agent_chat_start_session( "[codemux::agent_chat] failed to persist session record: {error}" ); } + // Issue #80 — optional rollback checkpoint. Spawned AFTER the + // provider session is up and the session row is persisted, so + // not a single git operation (or even the settings-cache read) + // sits on the latency-to-first-token path. The opt-in gate is + // evaluated inside the task. + if let Some(cwd) = cwd_for_persist.clone() { + spawn_run_checkpoint( + &app, + session.thread_id.0.clone(), + workspace_id.clone(), + cwd, + ); + } } crate::state::emit_app_state(&app); Ok(session.thread_id) } +// ── Run checkpoints (issue #80) ────────────────────────────────────── +// +// When the (opt-in) setting is on, every session start snapshots the +// working tree in the BACKGROUND via `git_checkpoint_create` — a +// non-destructive shadow-ref commit that leaves the user's index, +// worktree, and stash list untouched. The snapshot is recorded against +// the thread so the pane header can offer "Restore checkpoint". + +/// Event emitted when a background checkpoint lands, so the pane +/// header can reveal the restore affordance without polling. +pub const AGENT_CHAT_CHECKPOINT_EVENT: &str = "agent_chat_checkpoint"; + +/// Payload emitted on [`AGENT_CHAT_CHECKPOINT_EVENT`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentChatCheckpointEventPayload { + pub thread_id: ThreadId, + pub checkpoint: AgentChatCheckpointRecord, +} + +/// Whether the user opted into run-start checkpoints. Reads the synced +/// settings cache (same pattern as session-restore in +/// `terminal/mod.rs`); default is OFF. +pub fn run_checkpoints_enabled() -> bool { + crate::settings_sync::load_cache() + .map(|s| s.agent_chat.checkpoints_enabled) + .unwrap_or(false) +} + +/// Synchronous core of the background checkpoint: snapshot the repo at +/// `repo_path`, persist the bookkeeping row, and prune old shadow refs +/// (dropping their rows too). Blocking — run it on a blocking thread. +/// +/// Returns `Ok(None)` when there was nothing to checkpoint (not a git +/// repo / unborn HEAD). Public so integration tests can exercise the +/// full create→restore round trip without a Tauri runtime. +pub fn create_run_checkpoint_blocking( + db: &DatabaseStore, + repo_path: &std::path::Path, + thread_id: &str, + workspace_id: &str, +) -> Result, String> { + let ref_name = crate::git::checkpoint_ref_name(thread_id); + let message = format!("codemux checkpoint: before agent run {thread_id}"); + let Some(snapshot) = crate::git::git_checkpoint_create(repo_path, &ref_name, &message)? + else { + return Ok(None); + }; + let record = AgentChatCheckpointRecord { + thread_id: thread_id.to_string(), + workspace_id: workspace_id.to_string(), + repo_path: repo_path.to_string_lossy().to_string(), + ref_name: snapshot.ref_name.clone(), + snapshot_commit: snapshot.snapshot_commit.clone(), + head_commit: snapshot.head_commit.clone(), + branch: snapshot.branch.clone(), + created_at: String::new(), // assigned by SQLite + }; + db.upsert_agent_chat_checkpoint(&record)?; + // Cap shadow-ref growth. Best-effort: a prune failure must not + // fail the checkpoint that already landed. + for namespace in [ + crate::git::CHECKPOINT_REF_PREFIX, + crate::git::PRE_RESTORE_REF_PREFIX, + ] { + match crate::git::git_checkpoint_prune( + repo_path, + namespace, + crate::git::CHECKPOINT_KEEP_PER_NAMESPACE, + ) { + Ok(pruned) => { + if let Err(error) = db.delete_agent_chat_checkpoints_by_refs( + &record.repo_path, + &pruned, + ) { + eprintln!( + "[codemux::agent_chat] failed to drop pruned checkpoint rows: {error}" + ); + } + } + Err(error) => eprintln!( + "[codemux::agent_chat] checkpoint prune failed: {error}" + ), + } + } + // Re-read so the caller (and the emitted event) sees the + // SQLite-assigned created_at. + Ok(db.get_agent_chat_checkpoint(thread_id).or(Some(record))) +} + +/// Synchronous core of the restore path. Blocking — run it on a +/// blocking thread. Public for integration tests. +pub fn restore_run_checkpoint_blocking( + db: &DatabaseStore, + thread_id: &str, +) -> Result<(), String> { + let record = db.get_agent_chat_checkpoint(thread_id).ok_or_else(|| { + "No checkpoint is recorded for this chat.".to_string() + })?; + crate::git::git_checkpoint_restore( + std::path::Path::new(&record.repo_path), + &record.snapshot_commit, + &record.head_commit, + record.branch.as_deref(), + &crate::git::pre_restore_ref_name(thread_id), + ) +} + +/// Fire-and-forget background checkpoint for a freshly started run. +/// +/// The whole body — including the settings-cache read that decides +/// whether the feature is even on — runs off the command path: +/// `tauri::async_runtime::spawn` returns immediately and the git work +/// happens on the blocking pool. Failures are logged, never surfaced: +/// a checkpoint must not break (or slow) the chat it protects. +fn spawn_run_checkpoint( + app: &AppHandle, + thread_id: String, + workspace_id: String, + cwd: String, +) { + spawn_run_checkpoint_with_gate(app, thread_id, workspace_id, cwd, run_checkpoints_enabled); +} + +/// [`spawn_run_checkpoint`] with the opt-in gate injected. Public (and +/// generic over the runtime) so integration tests can drive the REAL +/// background path — spawn, blocking pool, DB write through managed +/// state, event emission — on a `tauri::test::mock_app` without +/// touching the user's settings cache. +pub fn spawn_run_checkpoint_with_gate( + app: &AppHandle, + thread_id: String, + workspace_id: String, + cwd: String, + gate: fn() -> bool, +) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let blocking_app = app.clone(); + let blocking_thread_id = thread_id.clone(); + let result = tokio::task::spawn_blocking(move || { + if !gate() { + return Ok(None); + } + let db: State<'_, DatabaseStore> = blocking_app.state(); + create_run_checkpoint_blocking( + &db, + std::path::Path::new(&cwd), + &blocking_thread_id, + &workspace_id, + ) + }) + .await; + match result { + Ok(Ok(Some(checkpoint))) => { + eprintln!( + "[codemux::agent_chat] checkpoint {} recorded for thread {thread_id}", + checkpoint.snapshot_commit + ); + let payload = AgentChatCheckpointEventPayload { + thread_id: ThreadId(thread_id), + checkpoint, + }; + if let Err(error) = app.emit(AGENT_CHAT_CHECKPOINT_EVENT, &payload) { + eprintln!( + "[codemux::agent_chat] failed to emit {AGENT_CHAT_CHECKPOINT_EVENT}: {error}" + ); + } + } + Ok(Ok(None)) => { /* feature off, or nothing to snapshot */ } + Ok(Err(error)) => eprintln!( + "[codemux::agent_chat] background checkpoint failed for {thread_id}: {error}" + ), + Err(join_error) => eprintln!( + "[codemux::agent_chat] checkpoint task panicked for {thread_id}: {join_error}" + ), + } + }); +} + +/// Return the rollback checkpoint recorded for a thread, if any. +/// +/// Not gated on the feature flag (mirrors `agent_chat_list_sessions`): +/// the header should render "no checkpoint" rather than an error +/// string when the flag is off. +#[tauri::command] +pub async fn agent_chat_get_checkpoint( + db: State<'_, DatabaseStore>, + thread_id: String, +) -> Result, String> { + Ok(db.get_agent_chat_checkpoint(&thread_id)) +} + +/// Roll the workspace back to the checkpoint taken when this run +/// started. Mutates the working tree — the UI confirms first. +#[tauri::command] +pub async fn agent_chat_restore_checkpoint( + app: AppHandle, + thread_id: String, +) -> Result<(), String> { + let observability: State<'_, ObservabilityStore> = app.state(); + feature_flag_on(&observability)?; + // Blocking pool, same rationale as commands/git.rs: a wedged git + // subprocess must not freeze the GTK main thread. + tokio::task::spawn_blocking(move || { + let db: State<'_, DatabaseStore> = app.state(); + restore_run_checkpoint_blocking(&db, &thread_id) + }) + .await + .map_err(|e| format!("agent_chat_restore_checkpoint task join failed: {e}"))? +} + /// Queue a user turn on an existing session. #[tauri::command] pub async fn agent_chat_send_turn( diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 37ef343c..babd2b3f 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Mutex; -const SCHEMA_VERSION: u32 = 6; +const SCHEMA_VERSION: u32 = 7; pub struct DatabaseStore { conn: Mutex, @@ -46,6 +46,23 @@ pub struct AgentChatSessionRecord { pub last_active_at: String, } +/// Persisted bookkeeping for the run-start rollback checkpoint +/// (issue #80): the background working-tree snapshot taken when an +/// agent-chat session starts. The snapshot itself lives in the repo's +/// object database, anchored on `ref_name`; this row records where it +/// is and what state to restore (`head_commit` / `branch`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentChatCheckpointRecord { + pub thread_id: String, + pub workspace_id: String, + pub repo_path: String, + pub ref_name: String, + pub snapshot_commit: String, + pub head_commit: String, + pub branch: Option, + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProjectScripts { #[serde(default)] @@ -166,6 +183,30 @@ fn create_schema(conn: &Connection) -> Result<(), String> { CREATE INDEX IF NOT EXISTS idx_agent_chat_messages_thread ON agent_chat_messages(thread_id, id ASC); + -- Run-start rollback checkpoints (issue #80). One row per + -- thread: the background snapshot taken when the agent-chat + -- session started. `ref_name` is the shadow git ref anchoring + -- the snapshot (refs/codemux/checkpoints/); pruning that + -- ref deletes this row too. Cascade with the session so + -- deleting a chat from the history dropdown cleans up its + -- checkpoint bookkeeping. + CREATE TABLE IF NOT EXISTS agent_chat_checkpoints ( + thread_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + repo_path TEXT NOT NULL, + ref_name TEXT NOT NULL, + snapshot_commit TEXT NOT NULL, + head_commit TEXT NOT NULL, + branch TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (thread_id) + REFERENCES agent_chat_sessions(thread_id) + ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_agent_chat_checkpoints_ref + ON agent_chat_checkpoints(ref_name); + -- Hosts (Step 2 of cloud push — Settings → Hosts pane data model). -- -- Each row is a user-defined SSH target plus a friendly name. The @@ -2610,6 +2651,102 @@ impl DatabaseStore { } } +// ── Agent Chat Checkpoints (issue #80) ── +// +// One row per thread: the background run-start snapshot. Writes come +// from the checkpoint background task; reads from the pane header's +// restore affordance. Rows die with the session (FK CASCADE) or when +// the shadow ref is pruned (`delete_agent_chat_checkpoints_by_refs`). + +impl DatabaseStore { + /// Insert or replace the checkpoint row for a thread. A thread has + /// at most one run-start checkpoint, so conflict = full replace. + pub fn upsert_agent_chat_checkpoint( + &self, + record: &AgentChatCheckpointRecord, + ) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO agent_chat_checkpoints + (thread_id, workspace_id, repo_path, ref_name, + snapshot_commit, head_commit, branch, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now')) + ON CONFLICT(thread_id) DO UPDATE SET + workspace_id = ?2, + repo_path = ?3, + ref_name = ?4, + snapshot_commit = ?5, + head_commit = ?6, + branch = ?7, + created_at = datetime('now')", + params![ + record.thread_id, + record.workspace_id, + record.repo_path, + record.ref_name, + record.snapshot_commit, + record.head_commit, + record.branch, + ], + ) + .map_err(|e| format!("Failed to upsert agent_chat_checkpoint: {e}"))?; + Ok(()) + } + + /// Fetch the checkpoint recorded for a thread, if any. + pub fn get_agent_chat_checkpoint( + &self, + thread_id: &str, + ) -> Option { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT thread_id, workspace_id, repo_path, ref_name, + snapshot_commit, head_commit, branch, created_at + FROM agent_chat_checkpoints WHERE thread_id = ?1", + params![thread_id], + |row| { + Ok(AgentChatCheckpointRecord { + thread_id: row.get(0)?, + workspace_id: row.get(1)?, + repo_path: row.get(2)?, + ref_name: row.get(3)?, + snapshot_commit: row.get(4)?, + head_commit: row.get(5)?, + branch: row.get(6)?, + created_at: row.get(7)?, + }) + }, + ) + .optional() + .ok() + .flatten() + } + + /// Drop the bookkeeping rows whose shadow refs were just pruned + /// from a repo. `ref_name` alone is ambiguous across repos (two + /// repos can both have `refs/codemux/checkpoints/x`), so the + /// delete is scoped to the repo path. + pub fn delete_agent_chat_checkpoints_by_refs( + &self, + repo_path: &str, + ref_names: &[String], + ) -> Result<(), String> { + if ref_names.is_empty() { + return Ok(()); + } + let conn = self.conn.lock().unwrap(); + for ref_name in ref_names { + conn.execute( + "DELETE FROM agent_chat_checkpoints + WHERE repo_path = ?1 AND ref_name = ?2", + params![repo_path, ref_name], + ) + .map_err(|e| format!("Failed to delete pruned checkpoint rows: {e}"))?; + } + Ok(()) + } +} + // ── Auth Tokens ── impl DatabaseStore { @@ -3366,6 +3503,98 @@ mod tests { assert_eq!(rec.sdk_session_id, None); } + // ── agent_chat_checkpoints (issue #80) ── + + fn sample_checkpoint(thread_id: &str) -> AgentChatCheckpointRecord { + AgentChatCheckpointRecord { + thread_id: thread_id.to_string(), + workspace_id: "ws-1".to_string(), + repo_path: "/tmp/repo".to_string(), + ref_name: format!("refs/codemux/checkpoints/{thread_id}"), + snapshot_commit: "a".repeat(40), + head_commit: "b".repeat(40), + branch: Some("main".to_string()), + created_at: String::new(), // assigned by SQLite on insert + } + } + + #[test] + fn agent_chat_checkpoint_upsert_and_fetch() { + let db = init_test_database(); + db.upsert_agent_chat_session("t1", "ws-1", Some("/tmp/repo"), "claude") + .unwrap(); + db.upsert_agent_chat_checkpoint(&sample_checkpoint("t1")) + .unwrap(); + + let rec = db.get_agent_chat_checkpoint("t1").expect("row exists"); + assert_eq!(rec.thread_id, "t1"); + assert_eq!(rec.workspace_id, "ws-1"); + assert_eq!(rec.repo_path, "/tmp/repo"); + assert_eq!(rec.ref_name, "refs/codemux/checkpoints/t1"); + assert_eq!(rec.snapshot_commit, "a".repeat(40)); + assert_eq!(rec.head_commit, "b".repeat(40)); + assert_eq!(rec.branch.as_deref(), Some("main")); + assert!(!rec.created_at.is_empty(), "created_at assigned by SQLite"); + + // Replace-on-conflict: a fresh checkpoint for the same thread + // overwrites the previous one wholesale. + let mut updated = sample_checkpoint("t1"); + updated.snapshot_commit = "c".repeat(40); + updated.branch = None; + db.upsert_agent_chat_checkpoint(&updated).unwrap(); + let rec = db.get_agent_chat_checkpoint("t1").unwrap(); + assert_eq!(rec.snapshot_commit, "c".repeat(40)); + assert_eq!(rec.branch, None); + + assert!(db.get_agent_chat_checkpoint("missing").is_none()); + } + + #[test] + fn agent_chat_checkpoint_cascades_with_session_delete() { + let db = init_test_database(); + db.upsert_agent_chat_session("t1", "ws-1", None, "claude") + .unwrap(); + db.upsert_agent_chat_checkpoint(&sample_checkpoint("t1")) + .unwrap(); + assert!(db.get_agent_chat_checkpoint("t1").is_some()); + + db.delete_agent_chat_session("t1").unwrap(); + assert!( + db.get_agent_chat_checkpoint("t1").is_none(), + "checkpoint row should cascade-delete with the session" + ); + } + + #[test] + fn agent_chat_checkpoint_delete_by_pruned_refs_is_repo_scoped() { + let db = init_test_database(); + for (thread, repo) in [("t1", "/repo/a"), ("t2", "/repo/b")] { + db.upsert_agent_chat_session(thread, "ws-1", Some(repo), "claude") + .unwrap(); + let mut cp = sample_checkpoint(thread); + cp.repo_path = repo.to_string(); + // Same ref name in both repos — the prune delete must only + // hit the matching repo's row. + cp.ref_name = "refs/codemux/checkpoints/shared".to_string(); + db.upsert_agent_chat_checkpoint(&cp).unwrap(); + } + + db.delete_agent_chat_checkpoints_by_refs( + "/repo/a", + &["refs/codemux/checkpoints/shared".to_string()], + ) + .unwrap(); + assert!(db.get_agent_chat_checkpoint("t1").is_none(), "pruned row gone"); + assert!( + db.get_agent_chat_checkpoint("t2").is_some(), + "other repo's row untouched" + ); + + // Empty list is a no-op. + db.delete_agent_chat_checkpoints_by_refs("/repo/b", &[]).unwrap(); + assert!(db.get_agent_chat_checkpoint("t2").is_some()); + } + #[test] fn agent_chat_sessions_upsert_on_conflict_preserves_identity_bumps_activity() { let db = init_test_database(); diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index adf3fcc5..9cc575ed 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -427,6 +427,298 @@ pub fn git_stash_pop(repo_path: &Path) -> Result<(), String> { Ok(()) } +// ── Run checkpoints (issue #80) ───────────────────────────────────── +// +// Non-destructive working-tree snapshots taken in the background when +// an agent-chat run starts, so the user can roll back everything the +// run changed. Three invariants drive the implementation: +// +// 1. Creating a checkpoint must NOT disturb the user's index, +// worktree, or stash list. `git stash create` would qualify but +// ignores untracked files, so we instead build a snapshot commit +// through a temporary index (`GIT_INDEX_FILE`), which git treats +// as a fully independent staging area. +// 2. The snapshot must survive `git gc` — it is anchored on a +// dedicated shadow ref under `refs/codemux/`. +// 3. No hooks may fire (the user's `pre-commit` must not run on a +// background snapshot) — `git commit-tree` is plumbing and runs +// none. + +/// Ref namespace for run-start checkpoints. +pub const CHECKPOINT_REF_PREFIX: &str = "refs/codemux/checkpoints"; +/// Ref namespace for the safety snapshots taken right before a restore. +pub const PRE_RESTORE_REF_PREFIX: &str = "refs/codemux/pre-restore"; +/// How many refs to keep per namespace when pruning. +pub const CHECKPOINT_KEEP_PER_NAMESPACE: usize = 20; + +/// A created working-tree snapshot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitCheckpoint { + /// Fully-qualified ref anchoring the snapshot (gc protection). + pub ref_name: String, + /// Commit object whose tree is the full worktree content + /// (tracked changes + untracked non-ignored files). + pub snapshot_commit: String, + /// `HEAD` at snapshot time. Restore moves the branch back here, + /// undoing any commits the run made. + pub head_commit: String, + /// Checked-out branch at snapshot time (`None` when detached). + pub branch: Option, +} + +/// Sanitize an arbitrary id (e.g. an agent-chat thread id) into a +/// single safe ref component. Conservative: anything outside +/// `[A-Za-z0-9_-]` becomes `-`, which sidesteps every +/// `git check-ref-format` rule (`..`, `@{`, leading `.`, `*.lock`, …). +pub fn sanitize_ref_component(raw: &str) -> String { + let mut out: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + out.truncate(100); + let trimmed = out.trim_matches('-'); + if trimmed.is_empty() { + "checkpoint".to_string() + } else { + trimmed.to_string() + } +} + +/// Full checkpoint ref for a thread id. +pub fn checkpoint_ref_name(thread_id: &str) -> String { + format!("{CHECKPOINT_REF_PREFIX}/{}", sanitize_ref_component(thread_id)) +} + +/// Full pre-restore safety ref for a thread id. +pub fn pre_restore_ref_name(thread_id: &str) -> String { + format!("{PRE_RESTORE_REF_PREFIX}/{}", sanitize_ref_component(thread_id)) +} + +/// Run git with a private index file so staging operations never touch +/// the user's real index. +fn run_git_with_index( + repo_path: &Path, + index_file: &Path, + args: &[&str], +) -> Result { + let output = Command::new("git") + .args(args) + .env("GIT_INDEX_FILE", index_file) + .current_dir(repo_path) + .output() + .map_err(|e| format!("Failed to run git: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "git {} failed: {}", + args.first().unwrap_or(&""), + stderr.trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).trim_end().to_string()) +} + +/// Snapshot the full working-tree state (staged + unstaged + untracked, +/// `.gitignore` respected) into a commit anchored at `full_ref`, +/// without touching the user's index, worktree, or stash list. +/// +/// Returns `Ok(None)` when there is nothing snapshottable — the path +/// is not inside a git repo, or the repo has an unborn HEAD (no +/// commits yet). Callers treat `None` as "checkpoint skipped". +pub fn git_checkpoint_create( + repo_path: &Path, + full_ref: &str, + message: &str, +) -> Result, String> { + // Resolve the worktree root; failure means "not a git repo" and + // also covers bare repos (no toplevel → nothing to snapshot). + let Ok(toplevel) = run_git(repo_path, &["rev-parse", "--show-toplevel"]) else { + return Ok(None); + }; + let repo = Path::new(&toplevel); + // Unborn HEAD (fresh `git init`, zero commits): there is no parent + // to anchor the snapshot to and no baseline to roll back to. + let Ok(head) = run_git(repo, &["rev-parse", "HEAD"]) else { + return Ok(None); + }; + let branch = { + let b = run_git_permissive(repo, &["branch", "--show-current"]); + if b.is_empty() { + None + } else { + Some(b) + } + }; + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let tmp_index = std::env::temp_dir().join(format!( + "codemux-checkpoint-index-{}-{nanos}", + std::process::id() + )); + + let result = (|| -> Result { + // Seed the temp index from the real one when possible: the + // copied stat cache means the following `add -A` only rehashes + // files that actually changed, instead of the whole repo. + // Index writes are atomic (write + rename), so a concurrent + // git process can't hand us a torn file. + let index_rel = run_git(repo, &["rev-parse", "--git-path", "index"])?; + let real_index = { + let p = PathBuf::from(&index_rel); + if p.is_absolute() { + p + } else { + repo.join(p) + } + }; + if real_index.exists() { + std::fs::copy(&real_index, &tmp_index) + .map_err(|e| format!("Failed to copy index for checkpoint: {e}"))?; + } else { + run_git_with_index(repo, &tmp_index, &["read-tree", "HEAD"])?; + } + // Stage everything (tracked modifications, deletions, and + // untracked files) into the PRIVATE index only. + run_git_with_index(repo, &tmp_index, &["add", "-A"])?; + let tree = run_git_with_index(repo, &tmp_index, &["write-tree"])?; + // Plumbing commit: no hooks, forced identity so a machine + // without user.name/email configured still checkpoints. + let snapshot = run_git( + repo, + &[ + "-c", + "user.name=Codemux", + "-c", + "user.email=checkpoint@codemux.invalid", + "commit-tree", + &tree, + "-p", + &head, + "-m", + message, + ], + )?; + run_git(repo, &["update-ref", full_ref, &snapshot])?; + Ok(snapshot) + })(); + // Always clean up the temp index, success or failure. + let _ = std::fs::remove_file(&tmp_index); + let snapshot_commit = result?; + + Ok(Some(GitCheckpoint { + ref_name: full_ref.to_string(), + snapshot_commit, + head_commit: head, + branch, + })) +} + +/// Roll the working tree back to a checkpoint created by +/// [`git_checkpoint_create`]. +/// +/// Effect: worktree content == snapshot, run-created files deleted +/// (ignored files spared), run-made commits undone (branch reset to +/// `head_commit`), and the restored pre-run dirty state shown as +/// unstaged changes / untracked files. The pre-run staged-vs-unstaged +/// split is flattened to unstaged — the snapshot records one tree, +/// not the index. +/// +/// Before mutating anything, the CURRENT state is snapshotted to +/// `safety_ref` (parented on the run's last commit), so a restore is +/// itself recoverable. +pub fn git_checkpoint_restore( + repo_path: &Path, + snapshot_commit: &str, + head_commit: &str, + branch: Option<&str>, + safety_ref: &str, +) -> Result<(), String> { + let toplevel = run_git(repo_path, &["rev-parse", "--show-toplevel"]) + .map_err(|_| "Cannot restore: not a git repository".to_string())?; + let repo = Path::new(&toplevel); + + // Both commits must still exist (a pruned checkpoint after `git gc` + // would otherwise fail halfway through the restore). + for (label, commit) in [("snapshot", snapshot_commit), ("base", head_commit)] { + run_git(repo, &["cat-file", "-e", &format!("{commit}^{{commit}}")]).map_err(|_| { + format!( + "Cannot restore: the checkpoint {label} commit no longer exists (it may have been pruned)" + ) + })?; + } + + // Same-branch guard: restoring a snapshot from branch A while B is + // checked out would silently reset B to A-era content. + let current = run_git_permissive(repo, &["branch", "--show-current"]); + let expected = branch.unwrap_or(""); + if current != expected { + let cur_label = if current.is_empty() { "(detached)" } else { ¤t }; + let exp_label = if expected.is_empty() { "(detached)" } else { expected }; + return Err(format!( + "Cannot restore: the workspace is now on branch '{cur_label}' but the checkpoint was taken on '{exp_label}'. Switch back to that branch first." + )); + } + + // Safety net: snapshot the CURRENT state (including any commits the + // run made — they stay reachable through this ref's parent chain) + // before we start rewriting the worktree. Best-effort. + if let Err(error) = git_checkpoint_create(repo, safety_ref, "codemux pre-restore safety snapshot") + { + eprintln!("[codemux::git] pre-restore safety snapshot failed: {error}"); + } + + // 1. Worktree + index → snapshot tree. `read-tree --reset -u` is + // the plumbing behind `reset --hard`: it overwrites local + // modifications and deletes files tracked in the old index that + // are absent from the snapshot. + run_git(repo, &["read-tree", "--reset", "-u", snapshot_commit])?; + // 2. Delete run-created files. After step 1 the index == snapshot + // tree, so anything untracked now did not exist at checkpoint + // time. No `-x`: ignored files (node_modules, build dirs, …) + // are spared. + run_git(repo, &["clean", "-fd"])?; + // 3. Move the branch back to the pre-run commit and reset the + // index to it, leaving the snapshot content in the worktree as + // unstaged changes. Formerly-untracked files fall out of the + // index here and show as untracked again. + run_git(repo, &["reset", "--mixed", head_commit])?; + Ok(()) +} + +/// Delete all but the newest `keep` refs under `namespace` (ordered by +/// committer date, newest first). Returns the deleted ref names so the +/// caller can drop matching bookkeeping rows. +pub fn git_checkpoint_prune( + repo_path: &Path, + namespace: &str, + keep: usize, +) -> Result, String> { + let out = run_git( + repo_path, + &[ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname)", + namespace, + ], + )?; + let mut pruned = Vec::new(); + for ref_name in out.lines().filter(|l| !l.is_empty()).skip(keep) { + run_git(repo_path, &["update-ref", "-d", ref_name])?; + pruned.push(ref_name.to_string()); + } + Ok(pruned) +} + pub fn git_discard_file(repo_path: &Path, file: &str) -> Result<(), String> { // Try git restore first (works for tracked files) let restore = run_git(repo_path, &["restore", "--", file]); @@ -5224,6 +5516,332 @@ C source.txt -> copy.txt"; assert!(result.is_err(), "pop with empty stash should error"); } + // ── git_checkpoint tests (issue #80) ── + + /// Raw `git status --porcelain` so staged-vs-unstaged is visible. + fn porcelain(repo: &Path) -> String { + run_git_permissive(repo, &["status", "--porcelain"]) + } + + #[test] + fn checkpoint_sanitize_ref_component() { + assert_eq!(sanitize_ref_component("chat-pane-3-17000"), "chat-pane-3-17000"); + assert_eq!(sanitize_ref_component("a b@{c..d}.lock"), "a-b--c--d--lock"); + assert_eq!(sanitize_ref_component("???"), "checkpoint"); + assert_eq!(sanitize_ref_component(""), "checkpoint"); + let long = "x".repeat(300); + assert_eq!(sanitize_ref_component(&long).len(), 100); + } + + #[test] + fn checkpoint_create_skips_non_repo_and_unborn_head() { + let dir = TempDir::new().unwrap(); + // Plain directory — not a repo. + let result = + git_checkpoint_create(dir.path(), "refs/codemux/checkpoints/t", "msg").unwrap(); + assert!(result.is_none(), "non-repo should skip"); + + // Fresh init, zero commits — unborn HEAD. + let unborn = TempDir::new().unwrap(); + run_git(unborn.path(), &["init"]).unwrap(); + let result = + git_checkpoint_create(unborn.path(), "refs/codemux/checkpoints/t", "msg").unwrap(); + assert!(result.is_none(), "unborn HEAD should skip"); + } + + #[test] + fn checkpoint_create_does_not_disturb_index_worktree_or_stash() { + let (_dir, repo) = setup_test_repo(); + git_config(&repo); + + // Build a mixed state: one committed+staged file, one + // committed+unstaged file, one untracked file. + std::fs::write(repo.join("staged.txt"), "v1").unwrap(); + std::fs::write(repo.join("unstaged.txt"), "v1").unwrap(); + run_git(&repo, &["add", "."]).unwrap(); + run_git(&repo, &["commit", "-m", "base"]).unwrap(); + std::fs::write(repo.join("staged.txt"), "v2-staged").unwrap(); + run_git(&repo, &["add", "staged.txt"]).unwrap(); + std::fs::write(repo.join("unstaged.txt"), "v2-unstaged").unwrap(); + std::fs::write(repo.join("untracked.txt"), "new").unwrap(); + + let before = porcelain(&repo); + assert!(before.contains("M staged.txt"), "precondition: staged change"); + assert!(before.contains(" M unstaged.txt"), "precondition: unstaged change"); + assert!(before.contains("?? untracked.txt"), "precondition: untracked file"); + + let ref_name = "refs/codemux/checkpoints/thread-1"; + let cp = git_checkpoint_create(&repo, ref_name, "test checkpoint") + .unwrap() + .expect("checkpoint should be created"); + + // The user's state is byte-identical afterwards. + assert_eq!(porcelain(&repo), before, "status must be undisturbed"); + let stash = run_git_permissive(&repo, &["stash", "list"]); + assert!(stash.is_empty(), "stash list must stay empty, got: {stash}"); + + // The snapshot captured all three flavors of change. + for (file, expect) in [ + ("staged.txt", "v2-staged"), + ("unstaged.txt", "v2-unstaged"), + ("untracked.txt", "new"), + ] { + let content = run_git( + &repo, + &["show", &format!("{}:{file}", cp.snapshot_commit)], + ) + .unwrap(); + assert_eq!(content, expect, "snapshot content for {file}"); + } + + // Anchored on the shadow ref, parented on HEAD. + let resolved = run_git(&repo, &["rev-parse", ref_name]).unwrap(); + assert_eq!(resolved, cp.snapshot_commit); + let parent = run_git(&repo, &["rev-parse", &format!("{}^", cp.snapshot_commit)]).unwrap(); + assert_eq!(parent, cp.head_commit); + // Branch name depends on the machine's init.defaultBranch — + // assert against whatever the repo actually reports. + let current_branch = run_git(&repo, &["branch", "--show-current"]).unwrap(); + assert_eq!(cp.branch.as_deref(), Some(current_branch.as_str()), "branch recorded"); + } + + #[test] + fn checkpoint_restore_round_trip_undoes_a_simulated_run() { + let (_dir, repo) = setup_test_repo(); + git_config(&repo); + // Default-branch name differs across git versions; pin it. + run_git(&repo, &["checkout", "-B", "main"]).unwrap(); + + // Pre-run state: tracked file with an unstaged edit, an + // untracked file, and an ignored file. + std::fs::write(repo.join(".gitignore"), "ignored.txt\n").unwrap(); + std::fs::write(repo.join("code.txt"), "original").unwrap(); + run_git(&repo, &["add", "."]).unwrap(); + run_git(&repo, &["commit", "-m", "base"]).unwrap(); + std::fs::write(repo.join("code.txt"), "user-edit").unwrap(); + std::fs::write(repo.join("notes.txt"), "user-notes").unwrap(); + std::fs::write(repo.join("ignored.txt"), "local-junk").unwrap(); + + let cp = git_checkpoint_create( + &repo, + "refs/codemux/checkpoints/run-1", + "run checkpoint", + ) + .unwrap() + .expect("checkpoint created"); + + // Simulated agent run: edits, deletions, new files, a commit. + std::fs::write(repo.join("code.txt"), "agent-rewrite").unwrap(); + std::fs::remove_file(repo.join("notes.txt")).unwrap(); + std::fs::write(repo.join("agent-new.txt"), "agent artifact").unwrap(); + run_git(&repo, &["add", "-A"]).unwrap(); + run_git(&repo, &["commit", "-m", "agent commit"]).unwrap(); + let agent_head = run_git(&repo, &["rev-parse", "HEAD"]).unwrap(); + std::fs::write(repo.join("agent-new2.txt"), "uncommitted artifact").unwrap(); + + git_checkpoint_restore( + &repo, + &cp.snapshot_commit, + &cp.head_commit, + cp.branch.as_deref(), + "refs/codemux/pre-restore/run-1", + ) + .expect("restore should succeed"); + + // Branch is back on the pre-run commit. + assert_eq!(run_git(&repo, &["rev-parse", "HEAD"]).unwrap(), cp.head_commit); + assert_eq!( + run_git(&repo, &["branch", "--show-current"]).unwrap(), + "main" + ); + // File contents are back to the pre-run state. + assert_eq!( + std::fs::read_to_string(repo.join("code.txt")).unwrap(), + "user-edit" + ); + assert_eq!( + std::fs::read_to_string(repo.join("notes.txt")).unwrap(), + "user-notes" + ); + // Run artifacts are gone; ignored local files survive. + assert!(!repo.join("agent-new.txt").exists(), "committed artifact removed"); + assert!(!repo.join("agent-new2.txt").exists(), "uncommitted artifact removed"); + assert_eq!( + std::fs::read_to_string(repo.join("ignored.txt")).unwrap(), + "local-junk", + "ignored file spared by clean" + ); + // Restored dirty state shows as unstaged + untracked. + let status = porcelain(&repo); + assert!(status.contains(" M code.txt"), "edit unstaged, got: {status}"); + assert!(status.contains("?? notes.txt"), "untracked again, got: {status}"); + // The safety ref exists and keeps the agent's commit reachable. + let safety = run_git(&repo, &["rev-parse", "refs/codemux/pre-restore/run-1"]).unwrap(); + let safety_parent = run_git(&repo, &["rev-parse", &format!("{safety}^")]).unwrap(); + assert_eq!(safety_parent, agent_head, "safety snapshot parents the run's last commit"); + } + + #[test] + fn checkpoint_restore_refuses_branch_mismatch() { + let (_dir, repo) = setup_test_repo(); + git_config(&repo); + run_git(&repo, &["checkout", "-B", "main"]).unwrap(); + std::fs::write(repo.join("f.txt"), "x").unwrap(); + run_git(&repo, &["add", "."]).unwrap(); + run_git(&repo, &["commit", "-m", "base"]).unwrap(); + + let cp = git_checkpoint_create(&repo, "refs/codemux/checkpoints/t", "cp") + .unwrap() + .unwrap(); + + run_git(&repo, &["checkout", "-b", "other"]).unwrap(); + let err = git_checkpoint_restore( + &repo, + &cp.snapshot_commit, + &cp.head_commit, + cp.branch.as_deref(), + "refs/codemux/pre-restore/t", + ) + .expect_err("restore on the wrong branch must refuse"); + assert!(err.contains("on branch 'other'"), "got: {err}"); + assert!(err.contains("'main'"), "got: {err}"); + } + + #[test] + fn checkpoint_restore_refuses_missing_snapshot() { + let (_dir, repo) = setup_test_repo(); + let head = run_git(&repo, &["rev-parse", "HEAD"]).unwrap(); + let bogus = "0123456789abcdef0123456789abcdef01234567"; + let err = git_checkpoint_restore( + &repo, + bogus, + &head, + None, + "refs/codemux/pre-restore/t", + ) + .expect_err("missing snapshot must refuse"); + assert!(err.contains("no longer exists"), "got: {err}"); + } + + #[test] + fn checkpoint_round_trip_works_in_a_linked_worktree() { + // Codemux workspaces are usually linked worktrees, not the + // main checkout: refs are SHARED with the main repo while the + // index and HEAD are per-worktree. The snapshot must read the + // worktree-specific index (`rev-parse --git-path index`) and + // the restore must only rewrite the worktree's own state. + let (_dir, repo) = setup_test_repo(); + git_config(&repo); + std::fs::write(repo.join("base.txt"), "base").unwrap(); + run_git(&repo, &["add", "."]).unwrap(); + run_git(&repo, &["commit", "-m", "base"]).unwrap(); + + let wt_path = git_create_worktree(&repo, "cp-branch", true, None, None) + .expect("create worktree"); + let wt = PathBuf::from(&wt_path); + + // Dirty the WORKTREE only. + std::fs::write(wt.join("base.txt"), "wt-edit").unwrap(); + std::fs::write(wt.join("wt-untracked.txt"), "wt-new").unwrap(); + + let cp = git_checkpoint_create(&wt, "refs/codemux/checkpoints/wt-1", "wt cp") + .unwrap() + .expect("worktree is snapshottable"); + assert_eq!(cp.branch.as_deref(), Some("cp-branch")); + // Ref is visible from the main repo too (shared ref store). + assert_eq!( + run_git(&repo, &["rev-parse", "refs/codemux/checkpoints/wt-1"]).unwrap(), + cp.snapshot_commit + ); + + // Simulated run inside the worktree. + std::fs::write(wt.join("base.txt"), "agent").unwrap(); + std::fs::write(wt.join("agent.txt"), "artifact").unwrap(); + + git_checkpoint_restore( + &wt, + &cp.snapshot_commit, + &cp.head_commit, + cp.branch.as_deref(), + "refs/codemux/pre-restore/wt-1", + ) + .expect("restore in worktree"); + + assert_eq!( + std::fs::read_to_string(wt.join("base.txt")).unwrap(), + "wt-edit" + ); + assert_eq!( + std::fs::read_to_string(wt.join("wt-untracked.txt")).unwrap(), + "wt-new" + ); + assert!(!wt.join("agent.txt").exists()); + // The MAIN checkout was never touched. + assert_eq!( + std::fs::read_to_string(repo.join("base.txt")).unwrap(), + "base" + ); + let main_status = run_git_permissive(&repo, &["status", "--porcelain"]); + assert!(main_status.is_empty(), "main checkout stays clean: {main_status}"); + + // Cleanup the worktree so TempDir drop works on all platforms. + let _ = git_remove_worktree(&wt, Some("cp-branch"), true); + } + + #[test] + fn checkpoint_prune_keeps_newest() { + let (_dir, repo) = setup_test_repo(); + git_config(&repo); + // Three checkpoints with strictly increasing committer dates so + // the `-committerdate` sort is deterministic. + for (i, date) in [(1, "2024-01-01T00:00:00"), (2, "2024-01-02T00:00:00"), (3, "2024-01-03T00:00:00")] { + std::fs::write(repo.join(format!("f{i}.txt")), format!("v{i}")).unwrap(); + let tree_msg = format!("cp {i}"); + // Force the committer date through the env-free `-c` route: + // commit-tree honors GIT_COMMITTER_DATE, so wrap create + // manually here via update-ref on a dated commit. + let head = run_git(&repo, &["rev-parse", "HEAD"]).unwrap(); + let tmp_index = std::env::temp_dir().join(format!( + "codemux-prune-test-index-{}-{i}", + std::process::id() + )); + run_git_with_index(&repo, &tmp_index, &["read-tree", "HEAD"]).unwrap(); + run_git_with_index(&repo, &tmp_index, &["add", "-A"]).unwrap(); + let tree = run_git_with_index(&repo, &tmp_index, &["write-tree"]).unwrap(); + let _ = std::fs::remove_file(&tmp_index); + let output = Command::new("git") + .args([ + "-c", "user.name=Test", "-c", "user.email=t@t.t", + "commit-tree", &tree, "-p", &head, "-m", &tree_msg, + ]) + .env("GIT_COMMITTER_DATE", date) + .current_dir(&repo) + .output() + .unwrap(); + assert!(output.status.success()); + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + run_git( + &repo, + &["update-ref", &format!("refs/codemux/checkpoints/t{i}"), &commit], + ) + .unwrap(); + } + + let pruned = git_checkpoint_prune(&repo, CHECKPOINT_REF_PREFIX, 2).unwrap(); + assert_eq!(pruned, vec!["refs/codemux/checkpoints/t1".to_string()]); + let remaining = run_git( + &repo, + &["for-each-ref", "--format=%(refname)", CHECKPOINT_REF_PREFIX], + ) + .unwrap(); + assert!(remaining.contains("t2") && remaining.contains("t3")); + assert!(!remaining.contains("t1")); + + // Pruning when under the cap is a no-op. + let pruned = git_checkpoint_prune(&repo, CHECKPOINT_REF_PREFIX, 20).unwrap(); + assert!(pruned.is_empty()); + } + // ── get_commit_files tests ── #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38701732..864070b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1493,6 +1493,8 @@ pub fn run() { commands::agent_chat_rename_session, commands::agent_chat_delete_session, commands::agent_chat_list_messages, + commands::agent_chat_get_checkpoint, + commands::agent_chat_restore_checkpoint, commands::attach_agent_chat_output, commands::detach_agent_chat_output, commands::opencode_check_availability, diff --git a/src-tauri/src/settings_sync.rs b/src-tauri/src/settings_sync.rs index cdb99ef7..b8a109e2 100644 --- a/src-tauri/src/settings_sync.rs +++ b/src-tauri/src/settings_sync.rs @@ -24,6 +24,8 @@ pub struct UserSettings { pub file_tree: FileTreeSettings, #[serde(default)] pub session_restore: SessionRestoreSettings, + #[serde(default)] + pub agent_chat: AgentChatSettings, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] @@ -116,6 +118,18 @@ pub struct FileTreeSettings { pub show_hidden_files: bool, } +/// Agent-chat behavior knobs. `checkpoints_enabled` is the opt-in for +/// the run-start rollback checkpoint (issue #80): when on, starting an +/// agent-chat session snapshots the working tree in the background so +/// the run's changes can be rolled back. Defaults to OFF — the +/// snapshot writes objects into the user's repo, which must be a +/// deliberate choice. +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] +pub struct AgentChatSettings { + #[serde(default)] + pub checkpoints_enabled: bool, +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct SessionRestoreSettings { #[serde(default = "default_true")] @@ -504,6 +518,9 @@ mod tests { scrollback_lines: 5000, max_total_mb: 50, }, + agent_chat: AgentChatSettings { + checkpoints_enabled: true, + }, }; let json = serde_json::to_string(&s).unwrap(); @@ -524,6 +541,17 @@ mod tests { assert!(!back.session_restore.enabled); assert_eq!(back.session_restore.scrollback_lines, 5000); assert_eq!(back.session_restore.max_total_mb, 50); + assert!(back.agent_chat.checkpoints_enabled); + } + + /// A settings blob saved before the agent_chat section existed + /// still deserializes — and the checkpoint opt-in stays OFF. + #[test] + #[serial] + fn missing_agent_chat_section_defaults_to_checkpoints_off() { + let legacy = r#"{"appearance":{"theme":"dark"}}"#; + let parsed: UserSettings = serde_json::from_str(legacy).unwrap(); + assert!(!parsed.agent_chat.checkpoints_enabled); } /// Patching one section preserves all other sections when round-tripped through cache. diff --git a/src-tauri/tests/agent_chat_commands.rs b/src-tauri/tests/agent_chat_commands.rs index e99bec50..414628d4 100644 --- a/src-tauri/tests/agent_chat_commands.rs +++ b/src-tauri/tests/agent_chat_commands.rs @@ -597,3 +597,268 @@ async fn full_bridge_pipeline_streams_provider_events_per_thread() { let b = captured_b.lock().unwrap(); assert!(b.iter().all(|p| p.thread_id.0 == "pipe-b")); } + +// ── Run checkpoints (issue #80) ── +// +// End-to-end backend round trip through the same blocking helpers the +// Tauri commands and the start-session background task call: a REAL +// temp git repo + an in-memory DatabaseStore. Create a checkpoint, +// simulate an agent trashing the workspace (edits, deletions, new +// files, a commit), restore, and verify the pre-run state is back. + +mod run_checkpoints { + use std::path::{Path, PathBuf}; + use std::process::Command; + + use codemux_lib::commands::agent_chat::{ + create_run_checkpoint_blocking, restore_run_checkpoint_blocking, + }; + use codemux_lib::database::DatabaseStore; + + fn git(repo: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("git runs"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim_end().to_string() + } + + fn setup_repo() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::TempDir::new().expect("temp dir"); + let path = dir.path().to_path_buf(); + git(&path, &["init", "-b", "main"]); + git(&path, &["config", "user.name", "Test"]); + git(&path, &["config", "user.email", "t@t.t"]); + std::fs::write(path.join("code.txt"), "original").unwrap(); + git(&path, &["add", "."]); + git(&path, &["commit", "-m", "base"]); + (dir, path) + } + + fn session_db(thread_id: &str, repo: &Path) -> DatabaseStore { + let db = DatabaseStore::new_in_memory(); + // The checkpoint row FKs onto the session row, mirroring the + // production ordering (session persisted before the + // checkpoint task runs). + db.upsert_agent_chat_session( + thread_id, + "ws-1", + Some(&repo.to_string_lossy()), + "claude", + ) + .expect("session row"); + db + } + + #[test] + fn checkpoint_round_trip_restores_pre_run_state() { + let (_dir, repo) = setup_repo(); + let db = session_db("thread-cp", &repo); + + // Pre-run dirty state: unstaged edit + untracked file. + std::fs::write(repo.join("code.txt"), "user-edit").unwrap(); + std::fs::write(repo.join("scratch.txt"), "user-notes").unwrap(); + + let record = create_run_checkpoint_blocking(&db, &repo, "thread-cp", "ws-1") + .expect("create ok") + .expect("repo is snapshottable"); + assert_eq!(record.thread_id, "thread-cp"); + assert_eq!(record.workspace_id, "ws-1"); + assert!(!record.created_at.is_empty(), "created_at re-read from DB"); + assert_eq!(record.branch.as_deref(), Some("main")); + // Recorded in the DB and anchored in the repo. + let stored = db + .get_agent_chat_checkpoint("thread-cp") + .expect("row persisted"); + assert_eq!(stored.snapshot_commit, record.snapshot_commit); + assert_eq!( + git(&repo, &["rev-parse", &stored.ref_name]), + stored.snapshot_commit + ); + + // The checkpoint did not disturb the user's state. + let status = git(&repo, &["status", "--porcelain"]); + assert!(status.contains(" M code.txt"), "got: {status}"); + assert!(status.contains("?? scratch.txt"), "got: {status}"); + + // Simulated agent run. + std::fs::write(repo.join("code.txt"), "agent-rewrite").unwrap(); + std::fs::remove_file(repo.join("scratch.txt")).unwrap(); + std::fs::write(repo.join("agent.txt"), "artifact").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "agent went wild"]); + + restore_run_checkpoint_blocking(&db, "thread-cp").expect("restore ok"); + + assert_eq!( + std::fs::read_to_string(repo.join("code.txt")).unwrap(), + "user-edit" + ); + assert_eq!( + std::fs::read_to_string(repo.join("scratch.txt")).unwrap(), + "user-notes" + ); + assert!(!repo.join("agent.txt").exists(), "agent artifact removed"); + assert_eq!( + git(&repo, &["rev-parse", "HEAD"]), + record.head_commit, + "agent commit undone" + ); + } + + #[test] + fn checkpoint_skips_non_repo_dir() { + let dir = tempfile::TempDir::new().unwrap(); + let db = DatabaseStore::new_in_memory(); + let result = create_run_checkpoint_blocking(&db, dir.path(), "t", "ws") + .expect("non-repo must not error"); + assert!(result.is_none(), "non-repo is a silent skip"); + assert!(db.get_agent_chat_checkpoint("t").is_none()); + } + + #[test] + fn restore_without_checkpoint_errors_cleanly() { + let db = DatabaseStore::new_in_memory(); + let err = restore_run_checkpoint_blocking(&db, "unknown-thread") + .expect_err("no checkpoint recorded"); + assert!(err.contains("No checkpoint"), "got: {err}"); + } + + #[test] + fn second_run_checkpoint_replaces_the_first() { + let (_dir, repo) = setup_repo(); + let db = session_db("thread-a", &repo); + + let first = create_run_checkpoint_blocking(&db, &repo, "thread-a", "ws-1") + .unwrap() + .unwrap(); + std::fs::write(repo.join("code.txt"), "later").unwrap(); + let second = create_run_checkpoint_blocking(&db, &repo, "thread-a", "ws-1") + .unwrap() + .unwrap(); + assert_ne!(first.snapshot_commit, second.snapshot_commit); + let stored = db.get_agent_chat_checkpoint("thread-a").unwrap(); + assert_eq!(stored.snapshot_commit, second.snapshot_commit); + } +} + +/// Full production glue for the background run checkpoint (issue #80): +/// `spawn_run_checkpoint_with_gate` on a mock app — async spawn → +/// blocking pool → REAL git snapshot → DB row through managed state → +/// `agent_chat_checkpoint` event emission. Only the settings-cache +/// read is injected (gate fn), so the test never touches the user's +/// real settings cache. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn background_checkpoint_spawn_persists_and_emits_event() { + use codemux_lib::commands::agent_chat::{ + spawn_run_checkpoint_with_gate, AgentChatCheckpointEventPayload, + AGENT_CHAT_CHECKPOINT_EVENT, + }; + + // Real repo with a dirty worktree. + let dir = tempfile::TempDir::new().expect("temp dir"); + let repo = dir.path().to_path_buf(); + let git = |args: &[&str]| { + let out = std::process::Command::new("git") + .args(args) + .current_dir(&repo) + .output() + .expect("git runs"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + }; + git(&["init", "-b", "main"]); + git(&["config", "user.name", "Test"]); + git(&["config", "user.email", "t@t.t"]); + std::fs::write(repo.join("f.txt"), "v1").unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "base"]); + std::fs::write(repo.join("f.txt"), "dirty").unwrap(); + + let app = tauri::test::mock_app(); + let db = DatabaseStore::new_in_memory(); + db.upsert_agent_chat_session( + "thread-bg", + "ws-1", + Some(&repo.to_string_lossy()), + "claude", + ) + .expect("session row"); + app.manage(db); + let handle = app.handle().clone(); + + let (tx, rx) = tokio::sync::oneshot::channel::(); + let tx = Arc::new(std::sync::Mutex::new(Some(tx))); + handle.listen(AGENT_CHAT_CHECKPOINT_EVENT, move |event| { + let payload: AgentChatCheckpointEventPayload = + serde_json::from_str(event.payload()).expect("valid payload JSON"); + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(payload); + } + }); + + spawn_run_checkpoint_with_gate( + &handle, + "thread-bg".to_string(), + "ws-1".to_string(), + repo.to_string_lossy().to_string(), + || true, + ); + + let payload = timeout(Duration::from_secs(10), rx) + .await + .expect("checkpoint event should fire within 10s") + .expect("sender not dropped"); + assert_eq!(payload.thread_id.0, "thread-bg"); + assert_eq!(payload.checkpoint.workspace_id, "ws-1"); + assert!(!payload.checkpoint.snapshot_commit.is_empty()); + + // The row is queryable through the same managed state the restore + // command uses. + let db = handle.state::(); + let stored = db + .get_agent_chat_checkpoint("thread-bg") + .expect("row persisted by the background task"); + assert_eq!(stored.snapshot_commit, payload.checkpoint.snapshot_commit); + // And the snapshot is a real commit anchored in the repo. + let out = std::process::Command::new("git") + .args(["cat-file", "-t", &stored.snapshot_commit]) + .current_dir(&repo) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "commit"); +} + +/// The gate is evaluated INSIDE the background task: when it reports +/// "feature off", nothing is written and no event fires. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn background_checkpoint_spawn_is_a_noop_when_gate_is_off() { + use codemux_lib::commands::agent_chat::spawn_run_checkpoint_with_gate; + + let dir = tempfile::TempDir::new().expect("temp dir"); + let app = tauri::test::mock_app(); + app.manage(DatabaseStore::new_in_memory()); + let handle = app.handle().clone(); + + spawn_run_checkpoint_with_gate( + &handle, + "thread-off".to_string(), + "ws-1".to_string(), + dir.path().to_string_lossy().to_string(), + || false, + ); + + // Give the spawned task time to run, then assert nothing landed. + tokio::time::sleep(Duration::from_millis(300)).await; + let db = handle.state::(); + assert!(db.get_agent_chat_checkpoint("thread-off").is_none()); +} diff --git a/src/components/chat/AgentChatPaneHeader.test.tsx b/src/components/chat/AgentChatPaneHeader.test.tsx index 1576d22e..f517b61c 100644 --- a/src/components/chat/AgentChatPaneHeader.test.tsx +++ b/src/components/chat/AgentChatPaneHeader.test.tsx @@ -1,6 +1,6 @@ /// import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import type { AgentChatProviderKind, @@ -14,13 +14,22 @@ import type { // the tests via `vi.mocked(...)`. vi.mock("@/tauri/commands", () => ({ + agentChatGetCheckpoint: vi.fn().mockResolvedValue(null), agentChatListMessages: vi.fn().mockResolvedValue([]), + agentChatRestoreCheckpoint: vi.fn().mockResolvedValue(undefined), agentChatStartSession: vi.fn().mockResolvedValue("thread-new"), agentChatStopSession: vi.fn().mockResolvedValue(undefined), closePane: vi.fn().mockResolvedValue(undefined), splitPane: vi.fn().mockResolvedValue(undefined), })); +// The checkpoint hook subscribes to the `agent_chat_checkpoint` Tauri +// event; under jsdom there is no Tauri runtime, so stub the listener +// registration with a resolved no-op unlisten. +vi.mock("@/tauri/events", () => ({ + onAgentChatCheckpoint: vi.fn().mockResolvedValue(() => {}), +})); + vi.mock("@/lib/toast", () => ({ toast: { error: vi.fn(), @@ -76,9 +85,12 @@ vi.mock("@/stores/app-store", async () => { import { AgentChatPaneHeader } from "./AgentChatPaneHeader"; import { + agentChatGetCheckpoint, agentChatListMessages, + agentChatRestoreCheckpoint, agentChatStartSession, agentChatStopSession, + type AgentChatCheckpointRecord, type AgentChatSessionRecord, } from "@/tauri/commands"; import { toast } from "@/lib/toast"; @@ -267,3 +279,103 @@ describe("AgentChatPaneHeader — resume hydration", () => { expect(vi.mocked(agentChatStartSession)).toHaveBeenCalledTimes(1); }); }); + +// ── Run checkpoint restore (issue #80) ── + +function makeCheckpoint( + overrides: Partial = {}, +): AgentChatCheckpointRecord { + return { + thread_id: "thread-old", + workspace_id: "ws-1", + repo_path: "/projects/foo", + ref_name: "refs/codemux/checkpoints/thread-old", + snapshot_commit: "a".repeat(40), + head_commit: "b".repeat(40), + branch: "main", + created_at: "2026-06-09 10:00:00", + ...overrides, + }; +} + +describe("AgentChatPaneHeader — checkpoint restore", () => { + beforeEach(() => { + useAgentChatStore.setState({ threads: {} }); + vi.mocked(agentChatGetCheckpoint).mockReset(); + vi.mocked(agentChatRestoreCheckpoint).mockReset(); + vi.mocked(toast.error).mockReset(); + vi.mocked(toast.success).mockReset(); + vi.mocked(agentChatGetCheckpoint).mockResolvedValue(null); + vi.mocked(agentChatRestoreCheckpoint).mockResolvedValue(undefined); + }); + + it("hides the restore button when no checkpoint is recorded", async () => { + const { queryByTestId } = renderHeader(); + // Let the on-mount fetch (null) settle. + await act(async () => {}); + expect(queryByTestId("restore-checkpoint-button")).toBeNull(); + }); + + it("shows the restore button once the checkpoint fetch resolves", async () => { + vi.mocked(agentChatGetCheckpoint).mockResolvedValue(makeCheckpoint()); + const { findByTestId } = renderHeader(); + const button = await findByTestId("restore-checkpoint-button"); + expect(button).toBeEnabled(); + expect(vi.mocked(agentChatGetCheckpoint)).toHaveBeenCalledWith( + "thread-old", + ); + }); + + it("invokes agent_chat_restore_checkpoint after explicit confirmation", async () => { + vi.mocked(agentChatGetCheckpoint).mockResolvedValue(makeCheckpoint()); + const { findByTestId } = renderHeader(); + const button = await findByTestId("restore-checkpoint-button"); + + fireEvent.click(button); + // Nothing restored yet — the confirm dialog gates the mutation. + expect(vi.mocked(agentChatRestoreCheckpoint)).not.toHaveBeenCalled(); + + const confirm = await findByTestId("restore-checkpoint-confirm"); + fireEvent.click(confirm); + await waitFor(() => + expect(vi.mocked(agentChatRestoreCheckpoint)).toHaveBeenCalledWith( + "thread-old", + ), + ); + await waitFor(() => expect(vi.mocked(toast.success)).toHaveBeenCalled()); + }); + + it("surfaces an error toast when the restore fails", async () => { + vi.mocked(agentChatGetCheckpoint).mockResolvedValue(makeCheckpoint()); + vi.mocked(agentChatRestoreCheckpoint).mockRejectedValue( + "Cannot restore: the checkpoint snapshot no longer exists", + ); + const { findByTestId } = renderHeader(); + fireEvent.click(await findByTestId("restore-checkpoint-button")); + fireEvent.click(await findByTestId("restore-checkpoint-confirm")); + await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalled()); + expect(vi.mocked(toast.success)).not.toHaveBeenCalled(); + }); + + it("disables the restore button while a turn is running", async () => { + vi.mocked(agentChatGetCheckpoint).mockResolvedValue(makeCheckpoint()); + const { findByTestId } = renderHeader(); + const button = await findByTestId("restore-checkpoint-button"); + expect(button).toBeEnabled(); + + // Mark the thread as mid-turn; restoring under a running agent + // would yank files out from under its tools. + act(() => { + useAgentChatStore.setState((s) => ({ + threads: { + ...s.threads, + "thread-old": { + ...s.threads["thread-old"], + activeTurnId: "turn-1", + }, + }, + })); + }); + await waitFor(() => expect(button).toBeDisabled()); + }); +}); diff --git a/src/components/chat/AgentChatPaneHeader.tsx b/src/components/chat/AgentChatPaneHeader.tsx index c7b4f1c9..dcbbda4f 100644 --- a/src/components/chat/AgentChatPaneHeader.tsx +++ b/src/components/chat/AgentChatPaneHeader.tsx @@ -1,13 +1,26 @@ -import { SplitSquareHorizontal, SplitSquareVertical, X } from "lucide-react"; +import { useState } from "react"; +import { History, SplitSquareHorizontal, SplitSquareVertical, X } from "lucide-react"; import { SessionSelector } from "@/components/chat/SessionSelector"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { useAgentChatCheckpoint } from "@/hooks/use-agent-chat-checkpoint"; import { sessionDisplayTitle } from "@/lib/agent-chat/session-history"; import { toast } from "@/lib/toast"; -import { useAgentChatStore } from "@/stores/agent-chat-store"; +import { selectThread, useAgentChatStore } from "@/stores/agent-chat-store"; import { findWorkspaceIdForPane, useAppStore } from "@/stores/app-store"; import { agentChatListMessages, + agentChatRestoreCheckpoint, agentChatStartSession, agentChatStopSession, closePane, @@ -54,6 +67,33 @@ export function AgentChatPaneHeader({ pane, isActive, onPointerDown }: Props) { }); const cwd = pane.cwd ?? fallbackCwd; + // Run-start rollback checkpoint (issue #80). `checkpoint` stays null + // when the opt-in setting is off / the snapshot hasn't landed, which + // hides the restore affordance entirely. + const checkpoint = useAgentChatCheckpoint(pane.thread_id ?? null); + const turnActive = useAgentChatStore((s) => { + const slice = pane.thread_id ? selectThread(pane.thread_id)(s) : null; + return slice ? slice.activeTurnId != null || slice.streaming : false; + }); + const [confirmRestoreOpen, setConfirmRestoreOpen] = useState(false); + const [restoring, setRestoring] = useState(false); + + const handleRestoreConfirmed = async () => { + if (!pane.thread_id) return; + setRestoring(true); + try { + await agentChatRestoreCheckpoint(pane.thread_id); + toast.success( + "Workspace restored to the snapshot taken when this run started.", + ); + } catch (error) { + toast.error(`Restore failed: ${error}`); + } finally { + setRestoring(false); + setConfirmRestoreOpen(false); + } + }; + const handleSelect = async (record: AgentChatSessionRecord) => { if (!cwd) { toast.error("Cannot resume: no working directory."); @@ -189,6 +229,26 @@ export function AgentChatPaneHeader({ pane, isActive, onPointerDown }: Props) { target, 14px glyph, drop close to destructive-foreground when its red hover bg kicks in. */}
+ {checkpoint && ( + + )}
+ + e.stopPropagation()} + > + + Restore workspace to before this run? + + Files go back to the snapshot taken when this chat session + started{checkpoint?.branch ? ` (branch ${checkpoint.branch})` : ""}. + Commits made during the run are undone, files the run created are + deleted, and your pre-run changes come back as unstaged edits. A + safety snapshot of the current state is kept under{" "} + refs/codemux/pre-restore. + + + + Cancel + { + // Keep the dialog open while the restore runs; we + // close it ourselves in the finally block. + e.preventDefault(); + void handleRestoreConfirmed(); + }} + data-testid="restore-checkpoint-confirm" + > + {restoring ? "Restoring…" : "Restore"} + + + + ); } diff --git a/src/components/settings/settings-view.tsx b/src/components/settings/settings-view.tsx index f824adfb..17be64ff 100644 --- a/src/components/settings/settings-view.tsx +++ b/src/components/settings/settings-view.tsx @@ -1347,6 +1347,26 @@ export function SettingsView() { }} /> + {/* Run-start rollback checkpoint (issue #80). Only shown + when the Agent Chat beta is on — the snapshot fires on + chat-session start, so without the beta the toggle + would do nothing. */} + {enableAgentChat && ( + <> + + + { + updateSyncedSetting("agent_chat", "checkpoints_enabled", checked).catch(console.error); + }} + /> + + + )} ); diff --git a/src/dev/tauri-mock.ts b/src/dev/tauri-mock.ts index a3815cd9..17c5f19d 100644 --- a/src/dev/tauri-mock.ts +++ b/src/dev/tauri-mock.ts @@ -237,6 +237,9 @@ const SYNCED_SETTINGS: UserSettings = { scrollback_lines: 10_000, max_total_mb: 100, }, + // Checkpoints ON in the mock so the seeded chat pane exercises the + // restore affordance (issue #80) without a real backend. + agent_chat: { checkpoints_enabled: true }, }; const EMPTY_CAPABILITIES: ProviderChatCapabilities = { @@ -593,6 +596,26 @@ const handlers: Record = { agent_chat_stop_session: () => undefined, agent_chat_rename_session: () => undefined, agent_chat_delete_session: () => undefined, + // Run-start rollback checkpoint (issue #80). The seeded thread has a + // checkpoint so the pane header shows the restore affordance; + // restore just logs (the mock has no real working tree to rewrite). + agent_chat_get_checkpoint: (a) => + a.threadId === MOCK_CHAT_THREAD_ID + ? { + thread_id: MOCK_CHAT_THREAD_ID, + workspace_id: "ws-codemux-chat", + repo_path: `${MOCK_HOME_DIR}/projects/codemux`, + ref_name: `refs/codemux/checkpoints/${MOCK_CHAT_THREAD_ID}`, + snapshot_commit: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + head_commit: "1234567890abcdef1234567890abcdef12345678", + branch: "main", + created_at: "2026-06-09 10:00:00", + } + : null, + agent_chat_restore_checkpoint: (a) => { + console.info("[tauri-mock] agent_chat_restore_checkpoint", a.threadId); + return undefined; + }, grep_count_pattern: () => 0, // ── Agent chat per-thread event channel (issue #75) ── diff --git a/src/hooks/use-agent-chat-checkpoint.ts b/src/hooks/use-agent-chat-checkpoint.ts new file mode 100644 index 00000000..9a90a032 --- /dev/null +++ b/src/hooks/use-agent-chat-checkpoint.ts @@ -0,0 +1,67 @@ +import { useEffect } from "react"; + +import { useTauriEvent } from "@/hooks/use-tauri-event"; +import { selectThread, useAgentChatStore } from "@/stores/agent-chat-store"; +import { + agentChatGetCheckpoint, + type AgentChatCheckpointRecord, +} from "@/tauri/commands"; +import { + onAgentChatCheckpoint, + type AgentChatCheckpointPayload, +} from "@/tauri/events"; + +/** + * Keep a thread's run-start rollback checkpoint (issue #80) in the + * agent-chat store and return it. + * + * Two feeds, because the snapshot is created in the BACKGROUND after + * `agent_chat_start_session` returns: + * + * 1. The `agent_chat_checkpoint` Tauri event — fires when the + * background task lands, covering the live path with no polling. + * 2. A one-shot fetch on mount / thread change — covers pane remounts + * mid-session, where the event already fired before this component + * existed. + * + * Passing `null` disables both feeds (pane without a session yet). + */ +export function useAgentChatCheckpoint( + threadId: string | null, +): AgentChatCheckpointRecord | null { + const checkpoint = useAgentChatStore( + (s) => (threadId ? selectThread(threadId)(s)?.checkpoint ?? null : null), + ); + const setCheckpoint = useAgentChatStore((s) => s.setCheckpoint); + + useTauriEvent( + onAgentChatCheckpoint, + (payload) => { + if (threadId == null) return; + if (payload.thread_id !== threadId) return; + setCheckpoint(threadId, payload.checkpoint); + }, + [threadId, setCheckpoint], + ); + + useEffect(() => { + if (threadId == null) return; + let cancelled = false; + agentChatGetCheckpoint(threadId) + .then((record) => { + // Don't clobber a checkpoint the event feed already delivered + // with a slower null response. + if (cancelled || record == null) return; + setCheckpoint(threadId, record); + }) + .catch((err) => { + // Non-fatal: the restore affordance just stays hidden. + console.warn("[agent-chat] checkpoint fetch failed:", err); + }); + return () => { + cancelled = true; + }; + }, [threadId, setCheckpoint]); + + return checkpoint; +} diff --git a/src/stores/agent-chat-store.ts b/src/stores/agent-chat-store.ts index 55abae87..b5db0856 100644 --- a/src/stores/agent-chat-store.ts +++ b/src/stores/agent-chat-store.ts @@ -9,6 +9,7 @@ import { markRequestResponding, } from "@/lib/agent-chat/reducer"; import type { ChatThreadState, ChatViewItem } from "@/lib/agent-chat/types"; +import type { AgentChatCheckpointRecord } from "@/tauri/commands"; import type { ApprovalDecision, ProviderRuntimeEvent, @@ -123,6 +124,10 @@ export interface ChatThreadSlice extends ChatThreadState { * this); Stage 1 only exposes the slice + actions. The slice itself * imposes no cap — UI layer enforces 20 hard / 10 soft warn. */ stagedAttachments: Attachment[]; + /** Run-start rollback checkpoint (issue #80). `null` until the + * background snapshot lands (event) or the on-mount fetch resolves. + * Drives the pane header's "Restore checkpoint" affordance. */ + checkpoint: AgentChatCheckpointRecord | null; } function emptySlice(): ChatThreadSlice { @@ -141,6 +146,7 @@ function emptySlice(): ChatThreadSlice { hasDebugActivity: false, debugActivityResolved: false, stagedAttachments: [], + checkpoint: null, }; } @@ -227,6 +233,13 @@ interface AgentChatStore { /** Clear all staged attachments. Called by Stage 2's send-handler * alongside the existing `inputDraft = ""` reset. */ clearStagedAttachments: (threadId: string) => void; + /** Record (or clear) the thread's run-start rollback checkpoint. + * Fed by the `agent_chat_checkpoint` event and the header's + * on-mount fetch. */ + setCheckpoint: ( + threadId: string, + checkpoint: AgentChatCheckpointRecord | null, + ) => void; } function updateSlice( @@ -358,6 +371,12 @@ export const useAgentChatStore = create((set) => ({ streaming: false, activeTurnId: null, pendingRequestIds: [], + // The old thread's checkpoint row dies with its session row + // (FK cascade on the restart-dedup path); the new session + // start takes a fresh checkpoint and its event re-populates + // this. Carrying the stale record would render a restore + // button pointing at a deleted row. + checkpoint: null, }; const nextThreads: Record = {}; for (const [key, value] of Object.entries(state.threads)) { @@ -491,6 +510,13 @@ export const useAgentChatStore = create((set) => ({ : { ...slice, stagedAttachments: [] }, ), ), + + setCheckpoint: (threadId, checkpoint) => + set((state) => + updateSlice(state, threadId, (slice) => + slice.checkpoint === checkpoint ? slice : { ...slice, checkpoint }, + ), + ), })); export const selectThread = diff --git a/src/stores/settings-sync-integration.test.ts b/src/stores/settings-sync-integration.test.ts index 887571a8..1c84dff0 100644 --- a/src/stores/settings-sync-integration.test.ts +++ b/src/stores/settings-sync-integration.test.ts @@ -40,6 +40,7 @@ const FULL_CUSTOM: UserSettings = { notifications: { sound_enabled: false, desktop_enabled: false }, file_tree: { show_hidden_files: true }, session_restore: { enabled: false, scrollback_lines: 5000, max_total_mb: 50 }, + agent_chat: { checkpoints_enabled: true }, }; // ── Setup ── diff --git a/src/stores/synced-settings-store.test.ts b/src/stores/synced-settings-store.test.ts index 57b65010..ed514aee 100644 --- a/src/stores/synced-settings-store.test.ts +++ b/src/stores/synced-settings-store.test.ts @@ -35,6 +35,7 @@ const DARK_SETTINGS: UserSettings = { notifications: { sound_enabled: false, desktop_enabled: true }, file_tree: { show_hidden_files: true }, session_restore: { enabled: true, scrollback_lines: 10000, max_total_mb: 100 }, + agent_chat: { checkpoints_enabled: true }, }; describe("synced-settings-store", () => { diff --git a/src/stores/synced-settings-store.ts b/src/stores/synced-settings-store.ts index 8af99558..ce8371f1 100644 --- a/src/stores/synced-settings-store.ts +++ b/src/stores/synced-settings-store.ts @@ -21,6 +21,7 @@ const DEFAULT_SETTINGS: UserSettings = { notifications: { sound_enabled: true, desktop_enabled: true }, file_tree: { show_hidden_files: false }, session_restore: { enabled: true, scrollback_lines: 10_000, max_total_mb: 100 }, + agent_chat: { checkpoints_enabled: false }, }; export interface SyncedSettingsState { @@ -172,3 +173,6 @@ export const selectShowHiddenFiles = (s: SyncedSettingsState): boolean => export const selectShowResourceMonitor = (s: SyncedSettingsState): boolean => s.settings.appearance.show_resource_monitor; + +export const selectAgentCheckpointsEnabled = (s: SyncedSettingsState): boolean => + s.settings.agent_chat?.checkpoints_enabled ?? false; diff --git a/src/tauri/commands.ts b/src/tauri/commands.ts index f121af25..2c64a0aa 100644 --- a/src/tauri/commands.ts +++ b/src/tauri/commands.ts @@ -1298,6 +1298,31 @@ export const agentChatDeleteSession = (threadId: string) => export const agentChatListMessages = (threadId: string) => invoke("agent_chat_list_messages", { threadId }); +// ── Run checkpoints (issue #80) ── + +/** Mirrors src-tauri/src/database.rs:AgentChatCheckpointRecord — the + * background working-tree snapshot taken when a run started. */ +export interface AgentChatCheckpointRecord { + thread_id: string; + workspace_id: string; + repo_path: string; + ref_name: string; + snapshot_commit: string; + head_commit: string; + branch: string | null; + created_at: string; +} + +export const agentChatGetCheckpoint = (threadId: string) => + invoke("agent_chat_get_checkpoint", { + threadId, + }); + +/** Roll the workspace back to the checkpoint taken when this run + * started. Mutates the working tree — confirm with the user first. */ +export const agentChatRestoreCheckpoint = (threadId: string) => + invoke("agent_chat_restore_checkpoint", { threadId }); + /** * Register a per-thread `Channel` that receives every live * `AgentChatEventPayload` for `threadId` — including the diff --git a/src/tauri/events.ts b/src/tauri/events.ts index 4f44dd31..ffaef782 100644 --- a/src/tauri/events.ts +++ b/src/tauri/events.ts @@ -240,6 +240,26 @@ export interface AgentChatEventPayload { event: ProviderRuntimeEvent; } +/** Emitted when the background run-start checkpoint (issue #80) lands, + * so the pane header can reveal the restore affordance without + * polling. Stays on the GLOBAL event bus (not the per-thread + * Channel): the checkpoint task outlives the start_session command + * and there is exactly one event per run, so a broadcast with a + * thread-id filter on the subscriber side is the right transport. + * Mirrors AgentChatCheckpointEventPayload in + * src-tauri/src/commands/agent_chat.rs. */ +export interface AgentChatCheckpointPayload { + thread_id: string; + checkpoint: import("./commands").AgentChatCheckpointRecord; +} + +export const onAgentChatCheckpoint = ( + cb: EventCallback, +): Promise => + listen("agent_chat_checkpoint", (e) => + cb(e.payload), + ); + // ── Tunnel health ── // // Mirror of src-tauri/src/ssh/tunnel_supervisor.rs:TunnelStatus diff --git a/src/tauri/types.ts b/src/tauri/types.ts index f42baa4a..caa3b438 100644 --- a/src/tauri/types.ts +++ b/src/tauri/types.ts @@ -49,6 +49,13 @@ export interface SessionRestoreSettings { max_total_mb: number; } +/** Mirrors src-tauri/src/settings_sync.rs:AgentChatSettings. + * `checkpoints_enabled` is the opt-in for the run-start rollback + * checkpoint (issue #80) — default OFF. */ +export interface AgentChatSyncSettings { + checkpoints_enabled: boolean; +} + export interface UserSettings { appearance: AppearanceSettings; editor: EditorSettings; @@ -58,6 +65,7 @@ export interface UserSettings { notifications: NotificationSyncSettings; file_tree: FileTreeSyncSettings; session_restore: SessionRestoreSettings; + agent_chat: AgentChatSyncSettings; } // ── Resource Monitor ──