Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,28 @@ min_severity = "info"
silence_secs = 300
# Webhook URL (required when delivery_channel = "webhook")
# webhook_url = "https://hooks.example.com/your-token"

# ── Workspace snapshots (time-travel debugging) ──────────────────────────────
# Snapshots are opt-in. Enable to allow 'sparks snapshot create|list|diff|restore'.
[snapshot]
# Set to true to enable snapshot commands (default: false).
enabled = false

# Directory to store snapshots (default: ~/.sparks/snapshots).
# snapshot_dir = "~/.sparks/snapshots"

# Maximum number of snapshots to retain. Oldest are pruned automatically.
# Set to 0 for unlimited. (default: 20)
max_snapshots = 20

# Skip snapshot if the workspace exceeds this size in MB. Set to 0 to disable.
# (default: 50)
max_workspace_mb = 50

# Paths to include in the snapshot, relative to the workspace root.
# (default: ["."] — the whole workspace)
include = ["."]

# Paths/patterns to exclude from the snapshot.
# (default: target/, .git/, .worktrees/, *.db, *.log)
exclude = ["target/", ".git/", ".worktrees/", "*.db", "*.log"]
46 changes: 46 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ pub struct Config {
pub alerts: AlertsConfig,
#[serde(default)]
pub sonarqube: SonarqubeConfig,
#[serde(default)]
pub snapshot: SnapshotConfig,
#[serde(skip)]
inline_secret_labels: Vec<String>,
}
Expand Down Expand Up @@ -950,6 +952,49 @@ impl Default for LangfuseConfig {
}
}

// ── Snapshot config ───────────────────────────────────────────────────

#[derive(Debug, Deserialize, serde::Serialize, Clone)]
pub struct SnapshotConfig {
/// Enable automatic workspace snapshots (default: false - opt-in)
#[serde(default)]
pub enabled: bool,
/// Directory to store snapshots (default: ~/.sparks/snapshots)
pub snapshot_dir: Option<String>,
/// Maximum number of snapshots to retain (default: 20, 0 = unlimited)
#[serde(default = "default_snapshot_max")]
pub max_snapshots: usize,
/// Maximum workspace size in MB to snapshot (default: 50, 0 = no limit)
#[serde(default = "default_snapshot_max_mb")]
pub max_workspace_mb: u64,
/// Glob patterns to include (default: ["."])
#[serde(default = "default_snapshot_include")]
pub include: Vec<String>,
/// Glob patterns to exclude (default: ["target/", ".git/", "*.db"])
#[serde(default = "default_snapshot_exclude")]
pub exclude: Vec<String>,
}

fn default_snapshot_max() -> usize { 20 }
fn default_snapshot_max_mb() -> u64 { 50 }
fn default_snapshot_include() -> Vec<String> { vec![".".into()] }
fn default_snapshot_exclude() -> Vec<String> {
vec!["target/".into(), ".git/".into(), ".worktrees/".into(), "*.db".into(), "*.log".into()]
}

impl Default for SnapshotConfig {
fn default() -> Self {
Self {
enabled: false,
snapshot_dir: None,
max_snapshots: default_snapshot_max(),
max_workspace_mb: default_snapshot_max_mb(),
include: default_snapshot_include(),
exclude: default_snapshot_exclude(),
}
}
}

fn default_metrics_interval() -> u64 {
30
}
Expand Down Expand Up @@ -1492,6 +1537,7 @@ impl Default for Config {
langfuse: LangfuseConfig::default(),
alerts: AlertsConfig::default(),
sonarqube: SonarqubeConfig::default(),
snapshot: SnapshotConfig::default(),
inline_secret_labels: Vec::new(),
}
}
Expand Down
61 changes: 61 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ mod randomness;
mod reason_codes;
mod scheduler;
mod secrets;
mod snapshot;
mod self_heal;
mod session_review;
mod sonarqube;
Expand Down Expand Up @@ -251,6 +252,34 @@ enum Commands {
#[command(subcommand)]
action: SelfBuildAction,
},
/// Manage workspace snapshots for time-travel debugging
Snapshot {
#[command(subcommand)]
action: SnapshotAction,
},
}

#[derive(Subcommand)]
enum SnapshotAction {
/// Create a new snapshot
Create {
#[arg(long)]
label: Option<String>,
},
/// List all snapshots
List,
/// Show diff between two snapshots
Diff {
id_a: String,
id_b: String,
},
/// Restore a snapshot
Restore {
id: String,
/// Actually restore (default is dry-run)
#[arg(long)]
apply: bool,
},
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -952,6 +981,38 @@ async fn main() -> anyhow::Result<()> {
Some(Commands::Eval { .. }) => unreachable!(), // handled above
Some(Commands::Feature { action }) => handle_feature(action, config, memory).await?,
Some(Commands::SelfBuild { action }) => handle_self_build(action, config, memory).await?,
Some(Commands::Snapshot { action }) => {
let workspace_root = std::env::current_dir()?;
let store = snapshot::SnapshotStore::new(config.snapshot.clone(), workspace_root);
match action {
SnapshotAction::Create { label } => {
let meta = store.create("cli", label.as_deref())?;
println!("Snapshot created: {} ({})", &meta.id[..12], meta.size_human());
}
SnapshotAction::List => {
let snaps = store.list()?;
if snaps.is_empty() {
println!("No snapshots found.");
} else {
for s in &snaps {
let label = s.label.as_deref().unwrap_or("");
// Show 12 hex chars so users can distinguish snapshots with the same
// 8-char prefix when passing an ID to diff/restore/get.
println!(" {} | {} | {} | {} {}",
&s.id[..12], s.created_at, s.size_human(), s.session_key, label);
}
}
}
SnapshotAction::Diff { id_a, id_b } => {
let diff = store.diff(&id_a, &id_b)?;
println!("{}", diff);
}
SnapshotAction::Restore { id, apply } => {
let result = store.restore(&id, !apply)?;
println!("{}", result);
}
}
}
Some(Commands::Chat) | None => run_chat(config, memory, auto_approve).await?,
}

Expand Down
Loading
Loading