diff --git a/README.md b/README.md index 9f409554..6751eb33 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ DeepSeek TUI is a coding agent that runs entirely in your terminal. It gives Dee - **Three interaction modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved). Decomposition-first system prompts teach the model to `todo_write`, `update_plan`, and spawn sub-agents before acting - **Reasoning-effort tiers** — cycle through `off → high → max` with Shift+Tab - **Session save/resume** — checkpoint and resume long sessions +- **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git` - **HTTP/SSE runtime API** — `deepseek serve --http` for headless agent workflows - **MCP protocol** — connect to Model Context Protocol servers for extended tooling - **Live cost tracking** — per-turn and session-level token usage and cost estimates diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9e7b1884..3aa387c7 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -128,10 +128,39 @@ pub struct ConfigToml { /// to a permissive default that mirrors pre-v0.7.0 behavior. #[serde(default)] pub network: Option, + /// Workspace side-git snapshots (#137). The live TUI defaults this to + /// enabled with 7-day retention when absent. + #[serde(default)] + pub snapshots: Option, #[serde(flatten)] pub extras: BTreeMap, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SnapshotsToml { + #[serde(default = "default_snapshots_enabled")] + pub enabled: bool, + #[serde(default = "default_snapshot_max_age_days")] + pub max_age_days: u64, +} + +fn default_snapshots_enabled() -> bool { + true +} + +fn default_snapshot_max_age_days() -> u64 { + 7 +} + +impl Default for SnapshotsToml { + fn default() -> Self { + Self { + enabled: default_snapshots_enabled(), + max_age_days: default_snapshot_max_age_days(), + } + } +} + /// On-disk schema for the `[network]` table (#135). See `config.example.toml` /// for documentation. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 7b0a7275..89dd445d 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -192,6 +192,41 @@ pub struct NotificationsConfig { pub include_summary: bool, } +fn default_snapshots_enabled() -> bool { + true +} + +fn default_snapshot_max_age_days() -> u64 { + crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60) +} + +/// Workspace side-git snapshot configuration (#137). +#[derive(Debug, Clone, Deserialize)] +pub struct SnapshotsConfig { + /// Snapshot the workspace before and after each interactive agent turn. + #[serde(default = "default_snapshots_enabled")] + pub enabled: bool, + /// Prune side-git snapshots older than this many days at session boot. + #[serde(default = "default_snapshot_max_age_days")] + pub max_age_days: u64, +} + +impl Default for SnapshotsConfig { + fn default() -> Self { + Self { + enabled: default_snapshots_enabled(), + max_age_days: default_snapshot_max_age_days(), + } + } +} + +impl SnapshotsConfig { + #[must_use] + pub fn max_age(&self) -> std::time::Duration { + std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60)) + } +} + /// One configurable footer item. /// /// Order in the user's `Vec` is preserved: items in the left @@ -429,6 +464,11 @@ pub struct Config { /// to a permissive default that mirrors pre-v0.7.0 behavior. #[serde(default)] pub network: Option, + + /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day + /// retention when the table is absent. + #[serde(default)] + pub snapshots: Option, } /// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live @@ -875,6 +915,12 @@ impl Config { self.notifications.clone().unwrap_or_default() } + /// Resolve workspace side-git snapshot settings with defaults applied. + #[must_use] + pub fn snapshots_config(&self) -> SnapshotsConfig { + self.snapshots.clone().unwrap_or_default() + } + /// Resolve enabled features from defaults and config entries. #[must_use] pub fn features(&self) -> Features { @@ -1384,6 +1430,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { features: merge_features(base.features, override_cfg.features), notifications: override_cfg.notifications.or(base.notifications), network: override_cfg.network.or(base.network), + snapshots: override_cfg.snapshots.or(base.snapshots), } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 02bcf7e7..d010c138 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -110,6 +110,8 @@ pub struct EngineConfig { /// session-scoped approvals (`/network allow `) persist for the /// remainder of the run. pub network_policy: Option, + /// Whether to take side-git workspace snapshots before/after each turn. + pub snapshots_enabled: bool, } impl Default for EngineConfig { @@ -131,6 +133,7 @@ impl Default for EngineConfig { plan_state: new_shared_plan_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy: None, + snapshots_enabled: true, } } } @@ -1436,14 +1439,15 @@ impl Engine { self.turn_counter = self.turn_counter.saturating_add(1); self.capacity_controller.mark_turn_start(self.turn_counter); - // Snapshot the workspace BEFORE we touch a single tool. Spawn on - // the blocking pool so the agent loop never waits on the side-git - // commit; failure is non-fatal (the helper logs at WARN). - let pre_workspace = self.session.workspace.clone(); - let pre_seq = self.turn_counter; - tokio::task::spawn_blocking(move || { - let _ = pre_turn_snapshot(&pre_workspace, pre_seq); - }); + // Snapshot the workspace BEFORE we touch a single tool. Run the git + // work on the blocking pool so the async runtime stays responsive; + // failure is non-fatal (the helper logs at WARN). + if self.config.snapshots_enabled { + let pre_workspace = self.session.workspace.clone(); + let pre_seq = self.turn_counter; + let _ = tokio::task::spawn_blocking(move || pre_turn_snapshot(&pre_workspace, pre_seq)) + .await; + } // Emit turn started event let _ = self @@ -1667,11 +1671,13 @@ impl Engine { // Post-turn snapshot. Same non-blocking, non-fatal contract as // the pre-turn hook above. - let post_workspace = self.session.workspace.clone(); - let post_seq = self.turn_counter; - tokio::task::spawn_blocking(move || { - let _ = post_turn_snapshot(&post_workspace, post_seq); - }); + if self.config.snapshots_enabled { + let post_workspace = self.session.workspace.clone(); + let post_seq = self.turn_counter; + let _ = + tokio::task::spawn_blocking(move || post_turn_snapshot(&post_workspace, post_seq)) + .await; + } // Checkpoint-restart cycle boundary (issue #124). The turn just // settled cleanly — no in-flight tools, no streaming, no pending diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 1dbe14fc..e947489b 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -2791,7 +2791,10 @@ async fn run_interactive( // Prune stale workspace snapshots from prior sessions (7-day default). // Non-fatal: a flaky disk, missing `git`, or read-only home should // never block the TUI from starting. - session_manager::prune_workspace_snapshots_at_boot(&workspace); + let snapshots = config.snapshots_config(); + if snapshots.enabled { + session_manager::prune_workspace_snapshots(&workspace, snapshots.max_age()); + } tui::run_tui( config, @@ -2956,6 +2959,7 @@ async fn run_exec_agent( plan_state: new_shared_plan_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy, + snapshots_enabled: config.snapshots_config().enabled, }; let engine_handle = spawn_engine(engine_config, config); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index ed942108..d0bcd310 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1390,6 +1390,7 @@ impl RuntimeThreadManager { plan_state: new_shared_plan_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy, + snapshots_enabled: self.config.snapshots_config().enabled, }; let engine = spawn_engine(engine_cfg, &self.config); diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 3a98cfc1..f853c052 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -370,16 +370,6 @@ pub fn default_sessions_dir() -> std::io::Result { Ok(home.join(".deepseek").join("sessions")) } -/// Boot-time hook: prune workspace snapshots older than the configured -/// retention window for `workspace`. Failure is logged but never fatal — -/// the session boot must not block on a flaky disk or missing `git`. -/// -/// Called once per `run_interactive` invocation. The default retention -/// (7 days) is governed by `crate::snapshot::DEFAULT_MAX_AGE`. -pub fn prune_workspace_snapshots_at_boot(workspace: &Path) { - prune_workspace_snapshots(workspace, crate::snapshot::DEFAULT_MAX_AGE); -} - /// Prune snapshots older than `max_age` for `workspace`. /// /// Always non-fatal. Returns silently — callers don't need the count diff --git a/crates/tui/src/snapshot/paths.rs b/crates/tui/src/snapshot/paths.rs index 9fe5e353..90d70091 100644 --- a/crates/tui/src/snapshot/paths.rs +++ b/crates/tui/src/snapshot/paths.rs @@ -5,8 +5,6 @@ //! project independently — `git worktree list` users won't get cross-talk //! between feature branches. -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; use std::io; use std::path::{Path, PathBuf}; @@ -72,13 +70,15 @@ fn strip_worktree_suffix(path: &Path) -> PathBuf { path.to_path_buf() } -/// Hex-encoded `DefaultHasher` digest. Sufficient for directory naming -/// (collision risk is negligible for the small set of paths we care -/// about, and we'd rather not pull in `sha2` for a 16-byte tag). +/// Hex-encoded deterministic FNV-1a digest. This is only a directory tag, not +/// a security boundary, but it must remain stable across process launches. fn stable_hex(path: &Path) -> String { - let mut hasher = DefaultHasher::new(); - path.hash(&mut hasher); - format!("{:016x}", hasher.finish()) + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in path.to_string_lossy().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") } #[cfg(test)] diff --git a/crates/tui/src/snapshot/repo.rs b/crates/tui/src/snapshot/repo.rs index 8f34de6f..be3707c7 100644 --- a/crates/tui/src/snapshot/repo.rs +++ b/crates/tui/src/snapshot/repo.rs @@ -12,8 +12,9 @@ //! repo, the command fails fast instead of falling back to "current //! directory". +use std::collections::HashSet; use std::io; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::process::{Command, Output}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -110,10 +111,10 @@ impl SnapshotRepo { /// Take a snapshot of the current working tree. /// - /// Internally: `git add -A` then `git commit --allow-empty -m