1use 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
57pub const DEFAULT_SESSION_BYTE_CAP: u64 = 1024 * 1024 * 1024;
61
62#[derive(Default)]
64pub struct FsSnapshotCapability;
65
66impl HostlibCapability for FsSnapshotCapability {
67 fn module_name(&self) -> &'static str {
68 "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 entries: BTreeMap<PathBuf, SnapshotEntry>,
114}
115
116#[derive(Clone, Debug)]
118pub struct SnapshotSummary {
119 pub snapshot_id: String,
121 pub scope_id: String,
123 pub taken_at_ms: i64,
125 pub captured_paths: Vec<String>,
127 pub byte_count: u64,
129}
130
131#[derive(Clone, Debug)]
133pub struct SnapshotResult {
134 pub snapshot_id: String,
136 pub captured_paths: Vec<String>,
138 pub byte_count: u64,
140}
141
142#[derive(Clone, Debug)]
144pub struct RestoreResult {
145 pub snapshot_id: String,
147 pub restored_paths: Vec<String>,
149 pub skipped_paths_with_reasons: Vec<(String, String)>,
151}
152
153#[derive(Clone, Debug)]
155pub struct DropResult {
156 pub snapshot_id: String,
158 pub dropped: bool,
160}
161
162#[derive(Debug)]
163struct SessionSnapshots {
164 snapshots: Vec<SnapshotState>,
166 byte_count: u64,
170 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
191pub 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
208pub 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
228pub 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#[cfg(test)]
252pub fn session_count() -> usize {
253 sessions()
254 .lock()
255 .expect("fs_snapshot session mutex poisoned")
256 .len()
257}
258
259pub 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
309pub 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
355pub 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
379pub 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
411pub(crate) fn auto_capture_for_write(builtin: &'static str, path: &Path) {
419 harn_vm::invalidate_cached_file_text(path);
425
426 let Some(session_id) = active_session_id() else {
427 return;
428 };
429 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
765fn 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 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 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 auto_capture_for_write("hostlib_tools_write_file", &one);
1110 auto_capture_for_write("hostlib_tools_write_file", &two);
1111 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 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 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 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}