Skip to main content

harn_hostlib/
fs_snapshot.rs

1//! Per-tool-call filesystem snapshots — Gemini-style `/restore` primitives.
2//!
3//! Captures the pre-image of paths touched by a mutating tool call so a
4//! client can roll the change back surgically without losing untracked
5//! work. Snapshot identity is the ACP `toolCallId`, so consumers index
6//! into the same id space the rest of the transcript already records.
7//!
8//! Two capture modes:
9//!
10//! 1. **Explicit** — the caller passes a `paths` list to
11//!    `hostlib_fs_snapshot`; bytes are copied immediately.
12//! 2. **Auto-on-write** — calling `hostlib_fs_snapshot` without `paths`
13//!    registers an open snapshot. The
14//!    [`auto_capture_for_write`] hook fires from inside
15//!    `tools/write_file` and `tools/delete_file` and lazy-copies each
16//!    pre-image into the active snapshot keyed by the current
17//!    [`harn_vm::agent_sessions::current_tool_call_id`].
18//!
19//! Storage layout (per session):
20//!
21//! ```text
22//! .harn/state/snapshots/<session_id>/
23//!   <snapshot_id>/
24//!     manifest.json    # path -> { kind, body_hash?, mode? }
25//!     bodies/<sha256>  # content-addressed; deduped across snapshots
26//! ```
27//!
28//! Snapshots are session-scoped and ephemeral. They are not persisted
29//! across machine reboots; consumers that need durable rollback bundle
30//! them into a session via `session/load`.
31
32use std::collections::{BTreeMap, BTreeSet};
33use std::fs as stdfs;
34use std::path::{Component, Path, PathBuf};
35use std::sync::Arc;
36use std::sync::{Mutex, OnceLock};
37
38use harn_vm::VmValue;
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41
42use crate::error::HostlibError;
43use crate::registry::{BuiltinRegistry, HostlibCapability};
44use crate::tools::args::{
45    build_dict, dict_arg, optional_string, optional_string_list, require_string, str_value,
46    to_agent_path,
47};
48
49const SNAPSHOT_BUILTIN: &str = "hostlib_fs_snapshot";
50const RESTORE_BUILTIN: &str = "hostlib_fs_restore";
51const LIST_BUILTIN: &str = "hostlib_fs_list_snapshots";
52const DROP_BUILTIN: &str = "hostlib_fs_drop_snapshot";
53
54const MANIFEST_VERSION: u32 = 1;
55const STATE_REL: &[&str] = &[".harn", "state", "snapshots"];
56
57/// Default cap on the on-disk footprint of one session's snapshot bundle
58/// before the oldest snapshots are evicted. Matches the proposal in
59/// [#1720](https://github.com/burin-labs/harn/issues/1720): 1 GiB.
60pub const DEFAULT_SESSION_BYTE_CAP: u64 = 1024 * 1024 * 1024;
61
62/// Hostlib filesystem snapshot capability handle.
63#[derive(Default)]
64pub struct FsSnapshotCapability;
65
66impl HostlibCapability for FsSnapshotCapability {
67    fn module_name(&self) -> &'static str {
68        // Snapshots live under the existing `fs/` schema directory so the
69        // contract surface stays consolidated alongside the staging
70        // primitives.
71        "fs"
72    }
73
74    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
75        registry.register_fn("fs", SNAPSHOT_BUILTIN, "snapshot", snapshot_builtin);
76        registry.register_fn("fs", RESTORE_BUILTIN, "restore", restore_builtin);
77        registry.register_fn("fs", LIST_BUILTIN, "list_snapshots", list_snapshots_builtin);
78        registry.register_fn("fs", DROP_BUILTIN, "drop_snapshot", drop_snapshot_builtin);
79    }
80}
81
82#[derive(Clone, Debug, Serialize, Deserialize)]
83#[serde(tag = "kind", rename_all = "snake_case")]
84enum SnapshotEntry {
85    File {
86        body_hash: String,
87        len: u64,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        mode: Option<u32>,
90    },
91    Absent,
92}
93
94#[derive(Clone, Debug, Serialize, Deserialize)]
95struct Manifest {
96    version: u32,
97    snapshot_id: String,
98    scope_id: String,
99    session_id: String,
100    root: String,
101    taken_at_ms: i64,
102    entries: BTreeMap<String, SnapshotEntry>,
103}
104
105#[derive(Clone, Debug)]
106struct SnapshotState {
107    snapshot_id: String,
108    scope_id: String,
109    session_id: String,
110    root: PathBuf,
111    taken_at_ms: i64,
112    /// Logical absolute paths (workspace-relative when storage permits).
113    entries: BTreeMap<PathBuf, SnapshotEntry>,
114}
115
116/// Per-snapshot summary returned by `list_snapshots`.
117#[derive(Clone, Debug)]
118pub struct SnapshotSummary {
119    /// Stable identifier (canonically the ACP toolCallId).
120    pub snapshot_id: String,
121    /// Caller-chosen scope id passed when the snapshot was created.
122    pub scope_id: String,
123    /// Wall-clock capture time, milliseconds since the UNIX epoch.
124    pub taken_at_ms: i64,
125    /// Logical paths captured at snapshot time.
126    pub captured_paths: Vec<String>,
127    /// Total bytes captured for `captured_paths`.
128    pub byte_count: u64,
129}
130
131/// Result returned after capturing a new snapshot.
132#[derive(Clone, Debug)]
133pub struct SnapshotResult {
134    /// Stable identifier (equal to the requested `scope_id`).
135    pub snapshot_id: String,
136    /// Paths captured into this snapshot.
137    pub captured_paths: Vec<String>,
138    /// Total bytes captured for `captured_paths`.
139    pub byte_count: u64,
140}
141
142/// Result returned after restoring a snapshot.
143#[derive(Clone, Debug)]
144pub struct RestoreResult {
145    /// Echoed snapshot id.
146    pub snapshot_id: String,
147    /// Paths successfully restored.
148    pub restored_paths: Vec<String>,
149    /// Paths skipped, with human-readable reasons.
150    pub skipped_paths_with_reasons: Vec<(String, String)>,
151}
152
153/// Result returned after dropping a snapshot.
154#[derive(Clone, Debug)]
155pub struct DropResult {
156    /// Echoed snapshot id.
157    pub snapshot_id: String,
158    /// True when an existing snapshot was removed.
159    pub dropped: bool,
160}
161
162#[derive(Debug)]
163struct SessionSnapshots {
164    /// Snapshots, in insertion order.
165    snapshots: Vec<SnapshotState>,
166    /// Bytes currently held in this session's snapshot bundle. We track
167    /// this rather than recomputing from `bodies/` so eviction stays
168    /// O(snapshots) instead of walking the filesystem on every write.
169    byte_count: u64,
170    /// Per-session byte cap. Defaults to [`DEFAULT_SESSION_BYTE_CAP`] and
171    /// can be overridden with [`configure_session_byte_cap`].
172    byte_cap: u64,
173}
174
175impl Default for SessionSnapshots {
176    fn default() -> Self {
177        Self {
178            snapshots: Vec::new(),
179            byte_count: 0,
180            byte_cap: DEFAULT_SESSION_BYTE_CAP,
181        }
182    }
183}
184
185static SESSIONS: OnceLock<Mutex<BTreeMap<String, SessionSnapshots>>> = OnceLock::new();
186
187fn sessions() -> &'static Mutex<BTreeMap<String, SessionSnapshots>> {
188    SESSIONS.get_or_init(|| Mutex::new(BTreeMap::new()))
189}
190
191/// Override the byte cap for a specific session and immediately enforce
192/// it. Returns the previous cap.
193///
194/// Primarily intended for tests that want to force eviction without
195/// writing a gigabyte. Production embedders generally leave the default
196/// in place; touching one session never affects another.
197pub fn configure_session_byte_cap(session_id: &str, bytes: u64) -> u64 {
198    let mut guard = sessions()
199        .lock()
200        .expect("fs_snapshot session mutex poisoned");
201    let bundle = guard.entry(session_id.to_string()).or_default();
202    let previous = bundle.byte_cap;
203    bundle.byte_cap = bytes.max(1);
204    enforce_byte_cap(bundle, session_id, None);
205    previous
206}
207
208/// Drop every snapshot registered for `session_id`, both in memory and
209/// on disk. Returns the number of snapshots removed.
210///
211/// ACP hosts should call this on session close so the snapshot bundle
212/// doesn't outlive the conversation. Tests can also call it on
213/// teardown when reusing a session id across cases.
214pub fn drop_session_snapshots(session_id: &str) -> usize {
215    let mut guard = sessions()
216        .lock()
217        .expect("fs_snapshot session mutex poisoned");
218    let Some(bundle) = guard.remove(session_id) else {
219        return 0;
220    };
221    let count = bundle.snapshots.len();
222    for snapshot in &bundle.snapshots {
223        remove_snapshot_dir(snapshot);
224    }
225    count
226}
227
228/// Drop every registered session's snapshots, in memory and on disk.
229/// Returns the number of sessions removed.
230///
231/// [`drop_session_snapshots`] handles a single conversation on ACP
232/// session close. This drains the entire process-global map and is
233/// intended for host reset paths (e.g. the test runner between cases)
234/// where the worker is reused and snapshot bundles would otherwise
235/// accumulate one session at a time.
236pub fn reset_all_sessions() -> usize {
237    let mut guard = sessions()
238        .lock()
239        .expect("fs_snapshot session mutex poisoned");
240    let session_count = guard.len();
241    for bundle in guard.values() {
242        for snapshot in &bundle.snapshots {
243            remove_snapshot_dir(snapshot);
244        }
245    }
246    guard.clear();
247    session_count
248}
249
250/// Number of sessions with registered snapshots. Test-only.
251#[cfg(test)]
252pub fn session_count() -> usize {
253    sessions()
254        .lock()
255        .expect("fs_snapshot session mutex poisoned")
256        .len()
257}
258
259/// Take a snapshot. When `paths` is empty the snapshot is "open" — bytes
260/// are captured lazily as `auto_capture_for_write` fires from inside
261/// the mutating tool builtins.
262pub fn snapshot(
263    session_id: &str,
264    scope_id: &str,
265    paths: &[String],
266    root: Option<&Path>,
267) -> Result<SnapshotResult, HostlibError> {
268    validate_session_id(SNAPSHOT_BUILTIN, session_id)?;
269    validate_scope_id(SNAPSHOT_BUILTIN, scope_id)?;
270    let root = resolve_root(root);
271    let mut guard = sessions()
272        .lock()
273        .expect("fs_snapshot session mutex poisoned");
274    let bundle = guard.entry(session_id.to_string()).or_default();
275    upsert_snapshot(bundle, session_id, scope_id, &root)?;
276    let mut captured_paths = Vec::new();
277    let mut byte_count = 0u64;
278    for raw in paths {
279        let path = normalize_logical(Path::new(raw));
280        let added =
281            capture_path(bundle, session_id, scope_id, &path, &root).map_err(|message| {
282                HostlibError::Backend {
283                    builtin: SNAPSHOT_BUILTIN,
284                    message,
285                }
286            })?;
287        if let Some(bytes) = added {
288            byte_count = byte_count.saturating_add(bytes);
289            captured_paths.push(to_agent_path(&path));
290        }
291    }
292    enforce_byte_cap(bundle, session_id, Some(scope_id));
293    let state = bundle
294        .snapshots
295        .iter()
296        .find(|snap| snap.snapshot_id == scope_id)
297        .expect("snapshot just upserted is protected from byte-cap eviction");
298    persist_manifest(state).map_err(|err| HostlibError::Backend {
299        builtin: SNAPSHOT_BUILTIN,
300        message: err,
301    })?;
302    Ok(SnapshotResult {
303        snapshot_id: state.snapshot_id.clone(),
304        captured_paths,
305        byte_count,
306    })
307}
308
309/// Restore a previously-captured snapshot.
310pub fn restore(
311    session_id: &str,
312    snapshot_id: &str,
313    paths: &[String],
314) -> Result<RestoreResult, HostlibError> {
315    validate_session_id(RESTORE_BUILTIN, session_id)?;
316    validate_scope_id(RESTORE_BUILTIN, snapshot_id)?;
317    let mut guard = sessions()
318        .lock()
319        .expect("fs_snapshot session mutex poisoned");
320    let bundle = guard
321        .get_mut(session_id)
322        .ok_or_else(|| HostlibError::Backend {
323            builtin: RESTORE_BUILTIN,
324            message: format!("no snapshots registered for session `{session_id}`"),
325        })?;
326    let state = bundle
327        .snapshots
328        .iter()
329        .find(|snap| snap.snapshot_id == snapshot_id)
330        .cloned()
331        .ok_or_else(|| HostlibError::Backend {
332            builtin: RESTORE_BUILTIN,
333            message: format!("unknown snapshot `{snapshot_id}` for session `{session_id}`"),
334        })?;
335    let selected = select_paths(&state, paths);
336    let mut restored_paths = Vec::new();
337    let mut skipped_paths_with_reasons = Vec::new();
338    for path in selected {
339        let Some(entry) = state.entries.get(&path) else {
340            continue;
341        };
342        let label = to_agent_path(&path);
343        match restore_entry(&state, &path, entry) {
344            Ok(()) => restored_paths.push(label),
345            Err(reason) => skipped_paths_with_reasons.push((label, reason)),
346        }
347    }
348    Ok(RestoreResult {
349        snapshot_id: snapshot_id.to_string(),
350        restored_paths,
351        skipped_paths_with_reasons,
352    })
353}
354
355/// List snapshots registered for a session, sorted by capture time.
356pub fn list_snapshots(session_id: &str) -> Result<Vec<SnapshotSummary>, HostlibError> {
357    validate_session_id(LIST_BUILTIN, session_id)?;
358    let guard = sessions()
359        .lock()
360        .expect("fs_snapshot session mutex poisoned");
361    let Some(bundle) = guard.get(session_id) else {
362        return Ok(Vec::new());
363    };
364    let mut summaries: Vec<SnapshotSummary> = bundle
365        .snapshots
366        .iter()
367        .map(|state| SnapshotSummary {
368            snapshot_id: state.snapshot_id.clone(),
369            scope_id: state.scope_id.clone(),
370            taken_at_ms: state.taken_at_ms,
371            captured_paths: state.entries.keys().map(to_agent_path).collect(),
372            byte_count: entry_byte_count(state),
373        })
374        .collect();
375    summaries.sort_by_key(|summary| summary.taken_at_ms);
376    Ok(summaries)
377}
378
379/// Drop a snapshot's in-memory and on-disk state.
380pub fn drop_snapshot(session_id: &str, snapshot_id: &str) -> Result<DropResult, HostlibError> {
381    validate_session_id(DROP_BUILTIN, session_id)?;
382    validate_scope_id(DROP_BUILTIN, snapshot_id)?;
383    let mut guard = sessions()
384        .lock()
385        .expect("fs_snapshot session mutex poisoned");
386    let Some(bundle) = guard.get_mut(session_id) else {
387        return Ok(DropResult {
388            snapshot_id: snapshot_id.to_string(),
389            dropped: false,
390        });
391    };
392    let position = bundle
393        .snapshots
394        .iter()
395        .position(|snap| snap.snapshot_id == snapshot_id);
396    let dropped = match position {
397        Some(idx) => {
398            let removed = bundle.snapshots.remove(idx);
399            bundle.byte_count = bundle.byte_count.saturating_sub(entry_byte_count(&removed));
400            remove_snapshot_dir(&removed);
401            true
402        }
403        None => false,
404    };
405    Ok(DropResult {
406        snapshot_id: snapshot_id.to_string(),
407        dropped,
408    })
409}
410
411/// Auto-on-write hook called from the mutating tool builtins.
412///
413/// Captures `path`'s pre-image into the snapshot whose id matches the
414/// current [`harn_vm::agent_sessions::current_tool_call_id`]. The first
415/// write in a tool call auto-opens that snapshot. The hook silently no-ops
416/// when no session is active or no tool-call id is set, which keeps read-only
417/// tools and writes outside active tool scopes cheap.
418pub(crate) fn auto_capture_for_write(builtin: &'static str, path: &Path) {
419    // `harness.fs.read_text` keeps a small thread-local cache. Hostlib writes
420    // bypass the VM builtin that normally updates it, so invalidate at this
421    // shared mutation seam before any early return. Size + mtime is not a safe
422    // substitute: a same-length rewrite can retain a coarse timestamp and make
423    // the next read return pre-mutation bytes.
424    harn_vm::invalidate_cached_file_text(path);
425
426    let Some(session_id) = active_session_id() else {
427        return;
428    };
429    // Record the mutated path against the session BEFORE the snapshot/tool-call
430    // gate below: this is the single chokepoint every hostlib write reaches, so
431    // it is the authoritative source for a session's `files_written` (consumed by
432    // the sub-agent receipt). Recorded unconditionally — even when no restore
433    // snapshot is open (no active tool call) — because the write still happened.
434    harn_vm::agent_sessions::record_session_changed_path(
435        &session_id,
436        normalize_logical(path).to_string_lossy().as_ref(),
437    );
438    let Some(snapshot_id) = harn_vm::agent_sessions::current_tool_call_id() else {
439        return;
440    };
441    let mut guard = sessions()
442        .lock()
443        .expect("fs_snapshot session mutex poisoned");
444    let bundle = guard.entry(session_id.clone()).or_default();
445    if !bundle
446        .snapshots
447        .iter()
448        .any(|snap| snap.snapshot_id == snapshot_id)
449    {
450        let root =
451            crate::fs::configured_session_root(&session_id).unwrap_or_else(|| resolve_root(None));
452        if let Err(error) = upsert_snapshot(bundle, &session_id, &snapshot_id, &root) {
453            tracing::warn!(
454                "fs_snapshot: failed to auto-open snapshot {snapshot_id} in session {session_id} (builtin={builtin}): {error}"
455            );
456            return;
457        }
458    }
459    let Some(snapshot) = bundle
460        .snapshots
461        .iter()
462        .find(|snap| snap.snapshot_id == snapshot_id)
463    else {
464        return;
465    };
466    let scope_id = snapshot.scope_id.clone();
467    let root = snapshot.root.clone();
468    let key = normalize_logical(path);
469    match capture_path(bundle, &session_id, &snapshot_id, &key, &root) {
470        Ok(_added) => {
471            if let Some(state) = bundle
472                .snapshots
473                .iter()
474                .find(|snap| snap.snapshot_id == snapshot_id)
475            {
476                if let Err(err) = persist_manifest(state) {
477                    tracing::warn!(
478                        "fs_snapshot: failed to persist manifest for snapshot {snapshot_id} in session {session_id} (scope_id={scope_id}, builtin={builtin}): {err}"
479                    );
480                }
481            }
482        }
483        Err(err) => {
484            tracing::warn!(
485                "fs_snapshot: failed to auto-capture `{}` for snapshot {snapshot_id} in session {session_id} (scope_id={scope_id}, builtin={builtin}): {err}",
486                key.display()
487            );
488        }
489    }
490    enforce_byte_cap(bundle, &session_id, Some(&snapshot_id));
491}
492
493fn snapshot_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
494    let raw = dict_arg(SNAPSHOT_BUILTIN, args)?;
495    let dict = raw.as_ref();
496    let session_id = require_string(SNAPSHOT_BUILTIN, dict, "session_id")?;
497    let scope_id = require_string(SNAPSHOT_BUILTIN, dict, "scope_id")?;
498    let paths = optional_string_list(SNAPSHOT_BUILTIN, dict, "paths")?;
499    let root = optional_string(SNAPSHOT_BUILTIN, dict, "root")?.map(PathBuf::from);
500    let result = snapshot(&session_id, &scope_id, &paths, root.as_deref())?;
501    Ok(build_dict([
502        ("snapshot_id", str_value(&result.snapshot_id)),
503        (
504            "captured_paths",
505            VmValue::List(Arc::new(
506                result
507                    .captured_paths
508                    .into_iter()
509                    .map(|path| VmValue::String(arcstr::ArcStr::from(path)))
510                    .collect(),
511            )),
512        ),
513        ("byte_count", VmValue::Int(result.byte_count as i64)),
514    ]))
515}
516
517fn restore_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
518    let raw = dict_arg(RESTORE_BUILTIN, args)?;
519    let dict = raw.as_ref();
520    let session_id = require_string(RESTORE_BUILTIN, dict, "session_id")?;
521    let snapshot_id = require_string(RESTORE_BUILTIN, dict, "snapshot_id")?;
522    let paths = optional_string_list(RESTORE_BUILTIN, dict, "paths")?;
523    let result = restore(&session_id, &snapshot_id, &paths)?;
524    Ok(build_dict([
525        ("snapshot_id", str_value(&result.snapshot_id)),
526        (
527            "restored_paths",
528            VmValue::List(Arc::new(
529                result
530                    .restored_paths
531                    .into_iter()
532                    .map(|path| VmValue::String(arcstr::ArcStr::from(path)))
533                    .collect(),
534            )),
535        ),
536        (
537            "skipped_paths_with_reasons",
538            VmValue::List(Arc::new(
539                result
540                    .skipped_paths_with_reasons
541                    .into_iter()
542                    .map(|(path, reason)| {
543                        build_dict([("path", str_value(&path)), ("reason", str_value(&reason))])
544                    })
545                    .collect(),
546            )),
547        ),
548    ]))
549}
550
551fn list_snapshots_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
552    let raw = dict_arg(LIST_BUILTIN, args)?;
553    let dict = raw.as_ref();
554    let session_id = require_string(LIST_BUILTIN, dict, "session_id")?;
555    let summaries = list_snapshots(&session_id)?;
556    Ok(build_dict([(
557        "snapshots",
558        VmValue::List(Arc::new(
559            summaries.into_iter().map(snapshot_summary_value).collect(),
560        )),
561    )]))
562}
563
564fn drop_snapshot_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
565    let raw = dict_arg(DROP_BUILTIN, args)?;
566    let dict = raw.as_ref();
567    let session_id = require_string(DROP_BUILTIN, dict, "session_id")?;
568    let snapshot_id = require_string(DROP_BUILTIN, dict, "snapshot_id")?;
569    let result = drop_snapshot(&session_id, &snapshot_id)?;
570    Ok(build_dict([
571        ("snapshot_id", str_value(&result.snapshot_id)),
572        ("dropped", VmValue::Bool(result.dropped)),
573    ]))
574}
575
576fn snapshot_summary_value(summary: SnapshotSummary) -> VmValue {
577    build_dict([
578        ("snapshot_id", str_value(&summary.snapshot_id)),
579        ("scope_id", str_value(&summary.scope_id)),
580        ("taken_at_ms", VmValue::Int(summary.taken_at_ms)),
581        (
582            "captured_paths",
583            VmValue::List(Arc::new(
584                summary
585                    .captured_paths
586                    .into_iter()
587                    .map(|path| VmValue::String(arcstr::ArcStr::from(path)))
588                    .collect(),
589            )),
590        ),
591        ("byte_count", VmValue::Int(summary.byte_count as i64)),
592    ])
593}
594
595fn upsert_snapshot(
596    bundle: &mut SessionSnapshots,
597    session_id: &str,
598    scope_id: &str,
599    root: &Path,
600) -> Result<(), HostlibError> {
601    if bundle
602        .snapshots
603        .iter()
604        .any(|snap| snap.snapshot_id == scope_id)
605    {
606        return Ok(());
607    }
608    let state = SnapshotState {
609        snapshot_id: scope_id.to_string(),
610        scope_id: scope_id.to_string(),
611        session_id: session_id.to_string(),
612        root: root.to_path_buf(),
613        taken_at_ms: now_ms(),
614        entries: BTreeMap::new(),
615    };
616    let dir = snapshot_dir(&state.root, &state.session_id, &state.snapshot_id);
617    stdfs::create_dir_all(dir.join("bodies")).map_err(|err| HostlibError::Backend {
618        builtin: SNAPSHOT_BUILTIN,
619        message: format!("mkdir {}: {err}", dir.display()),
620    })?;
621    bundle.snapshots.push(state);
622    Ok(())
623}
624
625fn capture_path(
626    bundle: &mut SessionSnapshots,
627    session_id: &str,
628    snapshot_id: &str,
629    path: &Path,
630    root: &Path,
631) -> Result<Option<u64>, String> {
632    let snap_index = bundle
633        .snapshots
634        .iter()
635        .position(|snap| snap.snapshot_id == snapshot_id)
636        .ok_or_else(|| format!("snapshot `{snapshot_id}` is not registered"))?;
637    if bundle.snapshots[snap_index].entries.contains_key(path) {
638        return Ok(None);
639    }
640    let metadata = stdfs::symlink_metadata(path);
641    let (entry, byte_count) = match metadata {
642        Err(err) if err.kind() == std::io::ErrorKind::NotFound => (SnapshotEntry::Absent, 0u64),
643        Err(err) => {
644            return Err(format!("stat `{}`: {err}", path.display()));
645        }
646        Ok(metadata) if metadata.is_dir() => {
647            return Err(format!(
648                "snapshot of directory `{}` is not supported yet",
649                path.display()
650            ));
651        }
652        Ok(metadata) if metadata.file_type().is_symlink() => {
653            return Err(format!(
654                "snapshot of symlink `{}` is not supported yet",
655                path.display()
656            ));
657        }
658        Ok(metadata) => {
659            let bytes = stdfs::read(path)
660                .map_err(|err| format!("read `{}` for snapshot: {err}", path.display()))?;
661            let body_hash = hex::encode(Sha256::digest(&bytes));
662            let len = bytes.len() as u64;
663            store_body(root, session_id, snapshot_id, &body_hash, &bytes)?;
664            #[cfg(unix)]
665            let mode = {
666                use std::os::unix::fs::MetadataExt;
667                Some(metadata.mode())
668            };
669            #[cfg(not(unix))]
670            let mode = {
671                let _ = &metadata;
672                None
673            };
674            (
675                SnapshotEntry::File {
676                    body_hash,
677                    len,
678                    mode,
679                },
680                len,
681            )
682        }
683    };
684    let snap = &mut bundle.snapshots[snap_index];
685    snap.entries.insert(path.to_path_buf(), entry);
686    bundle.byte_count = bundle.byte_count.saturating_add(byte_count);
687    Ok(Some(byte_count))
688}
689
690fn store_body(
691    root: &Path,
692    session_id: &str,
693    snapshot_id: &str,
694    body_hash: &str,
695    bytes: &[u8],
696) -> Result<(), String> {
697    let bodies = snapshot_dir(root, session_id, snapshot_id).join("bodies");
698    stdfs::create_dir_all(&bodies).map_err(|err| format!("mkdir {}: {err}", bodies.display()))?;
699    let body_path = bodies.join(body_hash);
700    if !body_path.exists() {
701        atomic_write(&body_path, bytes)?;
702    }
703    Ok(())
704}
705
706fn restore_entry(state: &SnapshotState, path: &Path, entry: &SnapshotEntry) -> Result<(), String> {
707    match entry {
708        SnapshotEntry::Absent => match stdfs::symlink_metadata(path) {
709            Ok(metadata) if metadata.is_dir() => stdfs::remove_dir_all(path)
710                .map_err(|err| format!("remove_dir_all {}: {err}", path.display())),
711            Ok(_) => stdfs::remove_file(path)
712                .map_err(|err| format!("remove_file {}: {err}", path.display())),
713            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
714            Err(err) => Err(format!("stat {}: {err}", path.display())),
715        },
716        SnapshotEntry::File {
717            body_hash, mode, ..
718        } => {
719            let body_path = snapshot_dir(&state.root, &state.session_id, &state.snapshot_id)
720                .join("bodies")
721                .join(body_hash);
722            let bytes = stdfs::read(&body_path)
723                .map_err(|err| format!("read snapshot body `{}`: {err}", body_path.display()))?;
724            atomic_write(path, &bytes)?;
725            #[cfg(unix)]
726            if let Some(bits) = mode {
727                use std::os::unix::fs::PermissionsExt;
728                let permissions = stdfs::Permissions::from_mode(*bits);
729                stdfs::set_permissions(path, permissions)
730                    .map_err(|err| format!("set_permissions `{}`: {err}", path.display()))?;
731            }
732            #[cfg(not(unix))]
733            let _ = mode;
734            Ok(())
735        }
736    }
737}
738
739fn persist_manifest(state: &SnapshotState) -> Result<(), String> {
740    let dir = snapshot_dir(&state.root, &state.session_id, &state.snapshot_id);
741    stdfs::create_dir_all(&dir).map_err(|err| format!("mkdir {}: {err}", dir.display()))?;
742    let manifest = Manifest {
743        version: MANIFEST_VERSION,
744        snapshot_id: state.snapshot_id.clone(),
745        scope_id: state.scope_id.clone(),
746        session_id: state.session_id.clone(),
747        root: state.root.to_string_lossy().into_owned(),
748        taken_at_ms: state.taken_at_ms,
749        entries: state
750            .entries
751            .iter()
752            .map(|(path, entry)| (path.to_string_lossy().into_owned(), entry.clone()))
753            .collect(),
754    };
755    let bytes = serde_json::to_vec_pretty(&manifest)
756        .map_err(|err| format!("serialize snapshot manifest: {err}"))?;
757    atomic_write(&dir.join("manifest.json"), &bytes)
758}
759
760fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
761    harn_vm::atomic_io::atomic_write(path, bytes)
762        .map_err(|error| format!("write {}: {error}", path.display()))
763}
764
765/// Evict snapshots oldest-first until the session is back under its byte
766/// cap. `protected` names the snapshot currently being written (if any);
767/// it is never evicted, even when it alone exceeds the cap — otherwise the
768/// caller would lose the very snapshot it just captured (and `snapshot`
769/// would panic re-fetching it). A snapshot larger than the whole cap is
770/// therefore retained: rollback for an in-flight write takes precedence
771/// over the soft budget.
772fn enforce_byte_cap(bundle: &mut SessionSnapshots, session_id: &str, protected: Option<&str>) {
773    while bundle.byte_count > bundle.byte_cap {
774        let Some(idx) = bundle
775            .snapshots
776            .iter()
777            .position(|snap| Some(snap.snapshot_id.as_str()) != protected)
778        else {
779            break;
780        };
781        let evicted = bundle.snapshots.remove(idx);
782        bundle.byte_count = bundle.byte_count.saturating_sub(entry_byte_count(&evicted));
783        tracing::info!(
784            "fs_snapshot: evicting snapshot `{}` from session `{session_id}` (over byte cap {})",
785            evicted.snapshot_id,
786            bundle.byte_cap,
787        );
788        remove_snapshot_dir(&evicted);
789    }
790}
791
792fn remove_snapshot_dir(state: &SnapshotState) {
793    let dir = snapshot_dir(&state.root, &state.session_id, &state.snapshot_id);
794    let _ = stdfs::remove_dir_all(&dir);
795}
796
797fn entry_byte_count(state: &SnapshotState) -> u64 {
798    state
799        .entries
800        .values()
801        .map(|entry| match entry {
802            SnapshotEntry::File { len, .. } => *len,
803            SnapshotEntry::Absent => 0,
804        })
805        .sum()
806}
807
808fn select_paths(state: &SnapshotState, paths: &[String]) -> Vec<PathBuf> {
809    if paths.is_empty() {
810        return state.entries.keys().cloned().collect();
811    }
812    let requested: BTreeSet<PathBuf> = paths
813        .iter()
814        .map(|path| normalize_logical(Path::new(path)))
815        .collect();
816    state
817        .entries
818        .keys()
819        .filter(|path| requested.contains(*path))
820        .cloned()
821        .collect()
822}
823
824fn validate_session_id(builtin: &'static str, session_id: &str) -> Result<(), HostlibError> {
825    if session_id.trim().is_empty() {
826        return Err(HostlibError::InvalidParameter {
827            builtin,
828            param: "session_id",
829            message: "must not be empty".to_string(),
830        });
831    }
832    Ok(())
833}
834
835fn validate_scope_id(builtin: &'static str, scope_id: &str) -> Result<(), HostlibError> {
836    if scope_id.trim().is_empty() {
837        let param = match builtin {
838            SNAPSHOT_BUILTIN => "scope_id",
839            _ => "snapshot_id",
840        };
841        return Err(HostlibError::InvalidParameter {
842            builtin,
843            param,
844            message: "must not be empty".to_string(),
845        });
846    }
847    Ok(())
848}
849
850fn active_session_id() -> Option<String> {
851    harn_vm::agent_sessions::current_session_id().filter(|id| !id.trim().is_empty())
852}
853
854fn resolve_root(root: Option<&Path>) -> PathBuf {
855    match root {
856        Some(path) => normalize_logical(path),
857        None => normalize_logical(&std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))),
858    }
859}
860
861fn snapshot_dir(root: &Path, session_id: &str, snapshot_id: &str) -> PathBuf {
862    let mut dir = root.to_path_buf();
863    for component in STATE_REL {
864        dir.push(component);
865    }
866    dir.push(sanitize_component(session_id));
867    dir.push(sanitize_component(snapshot_id));
868    dir
869}
870
871#[expect(
872    clippy::string_slice,
873    reason = "the sha256 hex digest is ASCII, so byte offset 12 is a char boundary"
874)]
875fn sanitize_component(input: &str) -> String {
876    let sanitized: String = input
877        .chars()
878        .map(|ch| match ch {
879            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch,
880            _ => '_',
881        })
882        .collect();
883    if sanitized == input {
884        sanitized
885    } else {
886        let hash = hex::encode(Sha256::digest(input.as_bytes()));
887        format!("{sanitized}-{}", &hash[..12])
888    }
889}
890
891fn normalize_logical(path: &Path) -> PathBuf {
892    let absolute = if path.is_absolute() {
893        path.to_path_buf()
894    } else {
895        std::env::current_dir()
896            .unwrap_or_else(|_| PathBuf::from("."))
897            .join(path)
898    };
899    let mut out = PathBuf::new();
900    for component in absolute.components() {
901        match component {
902            Component::ParentDir => {
903                out.pop();
904            }
905            Component::CurDir => {}
906            other => out.push(other),
907        }
908    }
909    out
910}
911
912fn now_ms() -> i64 {
913    std::time::SystemTime::now()
914        .duration_since(std::time::UNIX_EPOCH)
915        .map(|duration| duration.as_millis() as i64)
916        .unwrap_or(0)
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use std::sync::atomic::{AtomicU64, Ordering};
923    use tempfile::TempDir;
924
925    /// Hand each test its own session id so the process-wide `SESSIONS`
926    /// map isolates them by key — no serialization or process-wide
927    /// reset required.
928    fn unique_session(prefix: &str) -> String {
929        static COUNTER: AtomicU64 = AtomicU64::new(0);
930        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
931        format!("{prefix}-{n}-{}", std::process::id())
932    }
933
934    fn unique_scope() -> String {
935        static COUNTER: AtomicU64 = AtomicU64::new(0);
936        format!("tc-{}", COUNTER.fetch_add(1, Ordering::Relaxed))
937    }
938
939    fn enter_session(id: &str) -> harn_vm::agent_sessions::CurrentSessionGuard {
940        harn_vm::agent_sessions::open_or_create(Some(id.to_string()));
941        harn_vm::agent_sessions::enter_current_session(id.to_string())
942    }
943
944    #[test]
945    fn explicit_snapshot_then_restore_round_trips_file_bytes() {
946        let dir = TempDir::new().unwrap();
947        let file = dir.path().join("note.txt");
948        stdfs::write(&file, b"v1").unwrap();
949        let session = unique_session("snap-roundtrip");
950        let scope = unique_scope();
951        let _session_guard = enter_session(&session);
952
953        let result = snapshot(
954            &session,
955            &scope,
956            &[file.to_string_lossy().into_owned()],
957            Some(dir.path()),
958        )
959        .unwrap();
960        assert_eq!(result.snapshot_id, scope);
961        assert_eq!(result.captured_paths.len(), 1);
962        assert_eq!(result.byte_count, 2);
963
964        stdfs::write(&file, b"clobbered").unwrap();
965        let restored = restore(&session, &scope, &[]).unwrap();
966        assert_eq!(restored.restored_paths.len(), 1);
967        assert!(restored.skipped_paths_with_reasons.is_empty());
968        assert_eq!(stdfs::read(&file).unwrap(), b"v1");
969    }
970
971    #[test]
972    fn snapshot_with_long_scope_id_and_deep_root_writes_bodies() {
973        // Reproduces the ACP session-rollback redo capture that failed only on
974        // Windows: a deep workspace root plus a long, colon-bearing redo scope
975        // id (`{checkpoint_id}:redo:{snapshot_id}`) drives the derived storage
976        // path toward the legacy 260-char MAX_PATH. The atomic temp sibling used
977        // to be ~40 chars longer than its target, so the body write overflowed
978        // MAX_PATH and failed with os error 3. It must succeed on every OS.
979        let dir = TempDir::new().unwrap();
980        let mut root = dir.path().to_path_buf();
981        for segment in ["AppData", "Local", "Temp", "workspace-checkout", "packages"] {
982            root.push(segment);
983        }
984        stdfs::create_dir_all(&root).unwrap();
985        let file = root.join("note.txt");
986        stdfs::write(&file, b"before").unwrap();
987        let session = unique_session("redo-longpath");
988        let scope = format!("turn_{}:redo:turn-file", "0".repeat(32));
989        let _session_guard = enter_session(&session);
990
991        let result = snapshot(
992            &session,
993            &scope,
994            &[file.to_string_lossy().into_owned()],
995            Some(&root),
996        )
997        .expect("long-path redo snapshot must write its bodies");
998        assert_eq!(result.captured_paths.len(), 1);
999        assert_eq!(result.byte_count, 6);
1000    }
1001
1002    #[test]
1003    fn restore_reinstates_deleted_file() {
1004        let dir = TempDir::new().unwrap();
1005        let file = dir.path().join("doomed.txt");
1006        stdfs::write(&file, b"alive").unwrap();
1007        let session = unique_session("snap-reinstate");
1008        let scope = unique_scope();
1009        let _session_guard = enter_session(&session);
1010
1011        snapshot(
1012            &session,
1013            &scope,
1014            &[file.to_string_lossy().into_owned()],
1015            Some(dir.path()),
1016        )
1017        .unwrap();
1018        stdfs::remove_file(&file).unwrap();
1019        assert!(!file.exists());
1020        let restored = restore(&session, &scope, &[]).unwrap();
1021        assert_eq!(restored.restored_paths.len(), 1);
1022        assert_eq!(stdfs::read(&file).unwrap(), b"alive");
1023    }
1024
1025    #[test]
1026    fn absent_snapshot_means_restore_deletes_paths_created_during_the_call() {
1027        let dir = TempDir::new().unwrap();
1028        let file = dir.path().join("new.txt");
1029        assert!(!file.exists());
1030        let session = unique_session("snap-absent");
1031        let scope = unique_scope();
1032        let _session_guard = enter_session(&session);
1033
1034        snapshot(
1035            &session,
1036            &scope,
1037            &[file.to_string_lossy().into_owned()],
1038            Some(dir.path()),
1039        )
1040        .unwrap();
1041        stdfs::write(&file, b"created during call").unwrap();
1042        let restored = restore(&session, &scope, &[]).unwrap();
1043        assert_eq!(restored.restored_paths.len(), 1);
1044        assert!(
1045            !file.exists(),
1046            "restore must delete files that the snapshot saw as absent"
1047        );
1048    }
1049
1050    #[test]
1051    fn list_and_drop_round_trip_through_metadata() {
1052        let dir = TempDir::new().unwrap();
1053        let file = dir.path().join("listed.txt");
1054        stdfs::write(&file, b"abc").unwrap();
1055        let session = unique_session("snap-list");
1056        let scope = unique_scope();
1057        let _session_guard = enter_session(&session);
1058
1059        snapshot(
1060            &session,
1061            &scope,
1062            &[file.to_string_lossy().into_owned()],
1063            Some(dir.path()),
1064        )
1065        .unwrap();
1066        let summaries = list_snapshots(&session).unwrap();
1067        assert_eq!(summaries.len(), 1);
1068        assert_eq!(summaries[0].snapshot_id, scope);
1069        assert_eq!(summaries[0].byte_count, 3);
1070
1071        let dropped = drop_snapshot(&session, &scope).unwrap();
1072        assert!(dropped.dropped);
1073        assert!(list_snapshots(&session).unwrap().is_empty());
1074
1075        let again = drop_snapshot(&session, &scope).unwrap();
1076        assert!(!again.dropped, "second drop must be idempotent");
1077    }
1078
1079    #[test]
1080    fn auto_capture_records_pre_image_keyed_by_current_tool_call_id() {
1081        let dir = TempDir::new().unwrap();
1082        let file = dir.path().join("auto.txt");
1083        stdfs::write(&file, b"pre").unwrap();
1084        let session = unique_session("snap-auto");
1085        let scope = unique_scope();
1086        let _session_guard = enter_session(&session);
1087        let _tool_guard = harn_vm::agent_sessions::enter_current_tool_call(scope.clone());
1088
1089        snapshot(&session, &scope, &[], Some(dir.path())).unwrap();
1090        auto_capture_for_write("hostlib_tools_write_file", &file);
1091        stdfs::write(&file, b"post").unwrap();
1092
1093        let restored = restore(&session, &scope, &[]).unwrap();
1094        assert_eq!(restored.restored_paths.len(), 1);
1095        assert_eq!(stdfs::read(&file).unwrap(), b"pre");
1096    }
1097
1098    #[test]
1099    fn auto_capture_records_session_changed_path_for_files_written_receipt() {
1100        let dir = TempDir::new().unwrap();
1101        let one = dir.path().join("a.txt");
1102        let two = dir.path().join("b.txt");
1103        let session = unique_session("snap-changed");
1104        harn_vm::agent_sessions::clear_session_changed_paths(&session);
1105        let _session_guard = enter_session(&session);
1106
1107        // No active tool call / open snapshot: the write still happened, so the
1108        // path must be recorded for the receipt regardless.
1109        auto_capture_for_write("hostlib_tools_write_file", &one);
1110        auto_capture_for_write("hostlib_tools_write_file", &two);
1111        // A duplicate write of the same path must dedupe.
1112        auto_capture_for_write("hostlib_tools_write_file", &one);
1113
1114        let changed = harn_vm::agent_sessions::session_changed_paths(&session);
1115        assert_eq!(changed.len(), 2, "two distinct paths recorded (deduped)");
1116        let expect_one = normalize_logical(&one).to_string_lossy().into_owned();
1117        let expect_two = normalize_logical(&two).to_string_lossy().into_owned();
1118        assert!(
1119            changed.contains(&expect_one),
1120            "path a recorded: {changed:?}"
1121        );
1122        assert!(
1123            changed.contains(&expect_two),
1124            "path b recorded: {changed:?}"
1125        );
1126
1127        // `take` drains so the receipt captures the set exactly once.
1128        let drained = harn_vm::agent_sessions::take_session_changed_paths(&session);
1129        assert_eq!(drained.len(), 2);
1130        assert!(
1131            harn_vm::agent_sessions::session_changed_paths(&session).is_empty(),
1132            "take drains the session's recorded paths"
1133        );
1134    }
1135
1136    #[test]
1137    fn byte_cap_evicts_oldest_snapshot_when_exceeded() {
1138        let dir = TempDir::new().unwrap();
1139        let session = unique_session("snap-evict");
1140        let _session_guard = enter_session(&session);
1141
1142        // Per-session cap: only affects this test's session, so other
1143        // tests can run in parallel without seeing the squeeze.
1144        configure_session_byte_cap(&session, 8);
1145
1146        let mk = |name: &str| {
1147            let path = dir.path().join(name);
1148            stdfs::write(&path, b"12345").unwrap();
1149            path
1150        };
1151
1152        let scope_a = unique_scope();
1153        let scope_b = unique_scope();
1154        let a = mk("a.txt");
1155        snapshot(
1156            &session,
1157            &scope_a,
1158            &[a.to_string_lossy().into_owned()],
1159            Some(dir.path()),
1160        )
1161        .unwrap();
1162        let b = mk("b.txt");
1163        snapshot(
1164            &session,
1165            &scope_b,
1166            &[b.to_string_lossy().into_owned()],
1167            Some(dir.path()),
1168        )
1169        .unwrap();
1170
1171        let ids: Vec<String> = list_snapshots(&session)
1172            .unwrap()
1173            .into_iter()
1174            .map(|summary| summary.snapshot_id)
1175            .collect();
1176        assert_eq!(
1177            ids,
1178            vec![scope_b],
1179            "older snapshot must be evicted when the per-session byte cap is exceeded"
1180        );
1181    }
1182
1183    #[test]
1184    fn snapshot_larger_than_cap_is_retained_not_evicted() {
1185        // A single snapshot whose captured bytes exceed the whole cap must
1186        // survive — evicting the snapshot we just took would lose rollback
1187        // for the in-flight write (and previously panicked re-fetching it).
1188        let dir = TempDir::new().unwrap();
1189        let session = unique_session("snap-oversized");
1190        let _session_guard = enter_session(&session);
1191        configure_session_byte_cap(&session, 4);
1192
1193        let scope = unique_scope();
1194        let file = dir.path().join("big.txt");
1195        stdfs::write(&file, b"0123456789").unwrap();
1196        let result = snapshot(
1197            &session,
1198            &scope,
1199            &[file.to_string_lossy().into_owned()],
1200            Some(dir.path()),
1201        )
1202        .unwrap();
1203        assert_eq!(result.byte_count, 10);
1204
1205        let ids: Vec<String> = list_snapshots(&session)
1206            .unwrap()
1207            .into_iter()
1208            .map(|summary| summary.snapshot_id)
1209            .collect();
1210        assert_eq!(
1211            ids,
1212            vec![scope],
1213            "an oversized snapshot must be retained rather than evicting itself"
1214        );
1215    }
1216
1217    #[test]
1218    fn drop_session_snapshots_removes_every_snapshot_for_a_session() {
1219        let dir = TempDir::new().unwrap();
1220        let file = dir.path().join("retained.txt");
1221        stdfs::write(&file, b"x").unwrap();
1222        let session = unique_session("snap-drop-session");
1223        let scope_a = unique_scope();
1224        let scope_b = unique_scope();
1225        let _session_guard = enter_session(&session);
1226
1227        snapshot(
1228            &session,
1229            &scope_a,
1230            &[file.to_string_lossy().into_owned()],
1231            Some(dir.path()),
1232        )
1233        .unwrap();
1234        snapshot(
1235            &session,
1236            &scope_b,
1237            &[file.to_string_lossy().into_owned()],
1238            Some(dir.path()),
1239        )
1240        .unwrap();
1241        assert_eq!(list_snapshots(&session).unwrap().len(), 2);
1242
1243        assert_eq!(drop_session_snapshots(&session), 2);
1244        assert!(list_snapshots(&session).unwrap().is_empty());
1245        assert_eq!(drop_session_snapshots(&session), 0, "idempotent");
1246    }
1247}