1use std::collections::{BTreeMap, BTreeSet};
10use std::fs::{self as stdfs};
11use std::io::Write;
12use std::path::{Component, Path, PathBuf};
13use std::sync::{Mutex, OnceLock};
14
15use harn_vm::agent_events::AgentEvent;
16use harn_vm::process_sandbox::{check_fs_path_scope, FsAccess};
17use harn_vm::VmValue;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use crate::error::HostlibError;
22use crate::registry::{BuiltinRegistry, HostlibCapability};
23use crate::tools::args::{
24 build_dict, dict_arg, optional_bool, optional_int, optional_string, optional_string_list,
25 require_string, resolve_host_path, str_value, to_agent_path,
26};
27use crate::tools::permissions::enforce_path_scope;
28
29const SET_MODE_BUILTIN: &str = "hostlib_fs_set_mode";
30const STATUS_BUILTIN: &str = "hostlib_fs_staged_status";
31const COMMIT_BUILTIN: &str = "hostlib_fs_commit_staged";
32const DISCARD_BUILTIN: &str = "hostlib_fs_discard_staged";
33const SAFE_TEXT_PATCH_BUILTIN: &str = "hostlib_fs_safe_text_patch";
34const READ_TEXT_BUILTIN: &str = "hostlib_fs_read_text";
35const EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN: &str = "hostlib_fs_emit_safe_text_patch_result";
36
37const MANIFEST_VERSION: u32 = 1;
38const STATE_REL: &[&str] = &[".harn", "state", "staged"];
39
40mod paths;
41#[cfg(test)]
42mod tests;
43mod wire;
44
45pub use harn_vm::conditional_replace::{
46 scope_conditional_replace_lock_root, ScopedConditionalReplaceLockRoot,
47};
48use paths::{
49 active_session_id, default_root, manifest_path, normalize_logical, not_found, session_dir,
50 validate_session_id,
51};
52use wire::{commit_result_to_value, discard_result_to_value, status_to_value};
53
54#[derive(Default)]
56pub struct FsCapability;
57
58impl HostlibCapability for FsCapability {
59 fn module_name(&self) -> &'static str {
60 "fs"
61 }
62
63 fn register_builtins(&self, registry: &mut BuiltinRegistry) {
64 registry.register_fn("fs", SET_MODE_BUILTIN, "set_mode", set_mode_builtin);
65 registry.register_fn("fs", STATUS_BUILTIN, "staged_status", staged_status_builtin);
66 registry.register_fn("fs", COMMIT_BUILTIN, "commit_staged", commit_staged_builtin);
67 registry.register_fn(
68 "fs",
69 DISCARD_BUILTIN,
70 "discard_staged",
71 discard_staged_builtin,
72 );
73 registry.register_fn(
76 "fs",
77 SAFE_TEXT_PATCH_BUILTIN,
78 "safe_text_patch",
79 safe_text_patch_builtin,
80 );
81 registry.register_fn(
82 "fs",
83 READ_TEXT_BUILTIN,
84 "staged_read_text",
85 read_text_builtin,
86 );
87 registry.register_fn(
88 "fs",
89 EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN,
90 "emit_safe_text_patch_result",
91 emit_safe_text_patch_result_builtin,
92 );
93 }
94}
95
96#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum FsMode {
100 Immediate,
102 Staged,
104}
105
106impl FsMode {
107 fn parse(builtin: &'static str, raw: &str) -> Result<Self, HostlibError> {
108 match raw {
109 "immediate" => Ok(Self::Immediate),
110 "staged" => Ok(Self::Staged),
111 other => Err(HostlibError::InvalidParameter {
112 builtin,
113 param: "mode",
114 message: format!("expected \"immediate\" or \"staged\", got `{other}`"),
115 }),
116 }
117 }
118
119 pub fn as_str(self) -> &'static str {
121 match self {
122 Self::Immediate => "immediate",
123 Self::Staged => "staged",
124 }
125 }
126}
127
128#[derive(Clone, Debug, Serialize, Deserialize)]
129struct Manifest {
130 version: u32,
131 session_id: String,
132 mode: FsMode,
133 root: String,
134 entries: BTreeMap<String, StagedEntry>,
135}
136
137#[derive(Clone, Debug, Serialize, Deserialize)]
138#[serde(tag = "kind", rename_all = "snake_case")]
139enum StagedEntry {
140 Write {
141 body_hash: String,
142 len: u64,
143 created_at_ms: i64,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 snapshot_id: Option<String>,
146 },
147 Delete {
148 recursive: bool,
149 created_at_ms: i64,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 snapshot_id: Option<String>,
152 },
153}
154
155impl StagedEntry {
156 fn created_at_ms(&self) -> i64 {
157 match self {
158 Self::Write { created_at_ms, .. } | Self::Delete { created_at_ms, .. } => {
159 *created_at_ms
160 }
161 }
162 }
163
164 fn body_len(&self) -> u64 {
165 match self {
166 Self::Write { len, .. } => *len,
167 Self::Delete { .. } => 0,
168 }
169 }
170
171 fn snapshot_id(&self) -> Option<&str> {
172 match self {
173 Self::Write { snapshot_id, .. } | Self::Delete { snapshot_id, .. } => {
174 snapshot_id.as_deref()
175 }
176 }
177 }
178}
179
180#[derive(Clone, Debug)]
181struct SessionState {
182 session_id: String,
183 mode: FsMode,
184 root: PathBuf,
185 entries: BTreeMap<PathBuf, StagedEntry>,
186}
187
188#[derive(Clone, Debug)]
189pub(crate) struct WriteOutcome {
190 pub(crate) created: bool,
191 pub(crate) bytes_written: usize,
192}
193
194#[derive(Clone, Debug)]
195pub(crate) struct OverlayDirEntry {
196 pub(crate) name: String,
197 pub(crate) is_dir: bool,
198 pub(crate) is_symlink: bool,
199 pub(crate) size: u64,
200}
201
202#[derive(Clone, Debug)]
204pub struct StagedStatus {
205 pub pending_writes: Vec<PendingWrite>,
207 pub total_bytes_pending: u64,
209 pub oldest_pending_age_ms: i64,
211}
212
213#[derive(Clone, Debug)]
214pub struct PendingWrite {
216 pub path: String,
218 pub kind: &'static str,
220 pub bytes_added: u64,
222 pub bytes_removed: u64,
224 pub snapshot_id: Option<String>,
226 change_kind: &'static str,
227}
228
229impl PendingWrite {
230 pub fn event_summary(&self) -> harn_vm::agent_events::StagedWriteSummary {
232 let added = i64::try_from(self.bytes_added).unwrap_or(i64::MAX);
233 let removed = i64::try_from(self.bytes_removed).unwrap_or(i64::MAX);
234 harn_vm::agent_events::StagedWriteSummary {
235 path: self.path.clone(),
236 kind: self.change_kind.to_string(),
237 byte_delta: added.saturating_sub(removed),
238 snapshot_id: self.snapshot_id.clone(),
239 }
240 }
241}
242
243#[derive(Clone, Debug)]
245pub struct SetModeResult {
246 pub previous_mode: FsMode,
248}
249
250#[derive(Clone, Debug)]
252pub struct CommitResult {
253 pub committed_paths: Vec<String>,
255 pub failed_paths_with_reasons: Vec<(String, String)>,
257}
258
259#[derive(Clone, Debug)]
261pub struct DiscardResult {
262 pub discarded_paths: Vec<String>,
264}
265
266static SESSIONS: OnceLock<Mutex<BTreeMap<String, SessionState>>> = OnceLock::new();
267
268fn sessions() -> &'static Mutex<BTreeMap<String, SessionState>> {
269 SESSIONS.get_or_init(|| Mutex::new(BTreeMap::new()))
270}
271
272fn lock_sessions() -> std::sync::MutexGuard<'static, BTreeMap<String, SessionState>> {
276 sessions()
277 .lock()
278 .expect("hostlib fs session mutex poisoned")
279}
280
281pub fn configure_session_root(session_id: &str, root: &Path) {
286 if session_id.trim().is_empty() {
287 return;
288 }
289 let root = normalize_logical(root);
290 let mut guard = lock_sessions();
291 match guard.get_mut(session_id) {
292 Some(state) if state.entries.is_empty() => {
293 state.root = root;
294 }
295 Some(_) => {}
296 None => {
297 let state = load_state(session_id, Some(root.clone())).unwrap_or(SessionState {
298 session_id: session_id.to_string(),
299 mode: FsMode::Immediate,
300 root,
301 entries: BTreeMap::new(),
302 });
303 guard.insert(session_id.to_string(), state);
304 }
305 }
306}
307
308pub fn configured_session_root(session_id: &str) -> Option<PathBuf> {
310 if session_id.trim().is_empty() {
311 return None;
312 }
313 let guard = lock_sessions();
314 guard.get(session_id).map(|state| state.root.clone())
315}
316
317pub fn set_mode(
319 session_id: &str,
320 mode: FsMode,
321 root: Option<&Path>,
322) -> Result<SetModeResult, HostlibError> {
323 validate_session_id(SET_MODE_BUILTIN, session_id)?;
324 let mut guard = lock_sessions();
325 let mut state = state_for_locked(&mut guard, session_id, root.map(normalize_logical))?;
326 let previous_mode = state.mode;
327 state.mode = mode;
328 persist_state(&state, "set_mode", None).map_err(|err| HostlibError::Backend {
329 builtin: SET_MODE_BUILTIN,
330 message: err,
331 })?;
332 guard.insert(session_id.to_string(), state);
333 Ok(SetModeResult { previous_mode })
334}
335
336pub fn staged_status(session_id: &str) -> Result<StagedStatus, HostlibError> {
338 validate_session_id(STATUS_BUILTIN, session_id)?;
339 let mut guard = lock_sessions();
340 let state = state_for_locked(&mut guard, session_id, None)?;
341 let status = status_from_state(&state);
342 guard.insert(session_id.to_string(), state);
343 Ok(status)
344}
345
346pub(crate) fn staged_pending_paths(session_id: &str) -> Result<BTreeSet<PathBuf>, HostlibError> {
353 validate_session_id(STATUS_BUILTIN, session_id)?;
354 let mut guard = lock_sessions();
355 let state = state_for_locked(&mut guard, session_id, None)?;
356 let paths = state.entries.keys().cloned().collect();
357 guard.insert(session_id.to_string(), state);
358 Ok(paths)
359}
360
361pub fn commit_staged(session_id: &str, paths: &[String]) -> Result<CommitResult, HostlibError> {
363 validate_session_id(COMMIT_BUILTIN, session_id)?;
364 let mut guard = lock_sessions();
365 let mut state = state_for_locked(&mut guard, session_id, None)?;
366 let selected = selected_paths(&state, paths);
367 let mut committed_paths = Vec::new();
368 let mut failed_paths_with_reasons = Vec::new();
369
370 for path in selected {
371 let Some(entry) = state.entries.get(&path).cloned() else {
372 continue;
373 };
374 let path_label = to_agent_path(&path);
375 let access = match entry {
381 StagedEntry::Write { .. } => FsAccess::Write,
382 StagedEntry::Delete { .. } => FsAccess::Delete,
383 };
384 if let Err(violation) = check_fs_path_scope(&path, access) {
385 failed_paths_with_reasons.push((path_label, violation.message(COMMIT_BUILTIN)));
386 continue;
387 }
388 match commit_entry(&state, &path, &entry) {
389 Ok(()) => {
390 state.entries.remove(&path);
391 committed_paths.push(path_label);
392 }
393 Err(reason) => failed_paths_with_reasons.push((path_label, reason)),
394 }
395 }
396
397 persist_state(&state, "commit_staged", None).map_err(|err| HostlibError::Backend {
398 builtin: COMMIT_BUILTIN,
399 message: err,
400 })?;
401 emit_staged_update(&state);
402 guard.insert(session_id.to_string(), state);
403 Ok(CommitResult {
404 committed_paths,
405 failed_paths_with_reasons,
406 })
407}
408
409pub fn discard_staged(session_id: &str, paths: &[String]) -> Result<DiscardResult, HostlibError> {
411 validate_session_id(DISCARD_BUILTIN, session_id)?;
412 let mut guard = lock_sessions();
413 let mut state = state_for_locked(&mut guard, session_id, None)?;
414 let selected = selected_paths(&state, paths);
415 let mut discarded_paths = Vec::new();
416 for path in selected {
417 if state.entries.remove(&path).is_some() {
418 discarded_paths.push(to_agent_path(&path));
419 }
420 }
421 persist_state(&state, "discard_staged", None).map_err(|err| HostlibError::Backend {
422 builtin: DISCARD_BUILTIN,
423 message: err,
424 })?;
425 emit_staged_update(&state);
426 guard.insert(session_id.to_string(), state);
427 Ok(DiscardResult { discarded_paths })
428}
429
430pub fn remove_session_state(session_id: &str, root: Option<&Path>) -> Result<(), HostlibError> {
437 validate_session_id(DISCARD_BUILTIN, session_id)?;
438 let mut guard = lock_sessions();
439 let state = match guard.remove(session_id) {
440 Some(state) => state,
441 None => load_state(session_id, root.map(normalize_logical)).map_err(|err| {
442 HostlibError::Backend {
443 builtin: DISCARD_BUILTIN,
444 message: err,
445 }
446 })?,
447 };
448 let dir = session_dir(&state.root, &state.session_id);
449 match stdfs::remove_dir_all(&dir) {
450 Ok(()) => Ok(()),
451 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
452 Err(err) => Err(HostlibError::Backend {
453 builtin: DISCARD_BUILTIN,
454 message: format!("remove staged session {}: {err}", dir.display()),
455 }),
456 }
457}
458
459pub(crate) fn read(
460 path: &Path,
461 explicit_session_id: Option<&str>,
462) -> Option<std::io::Result<Vec<u8>>> {
463 let session_id = active_session_id(explicit_session_id)?;
464 let mut guard = lock_sessions();
465 let state = state_for_locked(&mut guard, &session_id, None).ok()?;
466 let result = if state.mode == FsMode::Staged {
467 overlay_read(&state, path)
468 } else {
469 None
470 };
471 guard.insert(session_id, state);
472 result
473}
474
475pub(crate) fn read_to_string(
476 path: &Path,
477 explicit_session_id: Option<&str>,
478) -> Option<std::io::Result<String>> {
479 read(path, explicit_session_id).map(|result| {
480 result.and_then(|bytes| {
481 String::from_utf8(bytes).map_err(|err| {
482 std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string())
483 })
484 })
485 })
486}
487
488pub(crate) fn read_dir(
489 path: &Path,
490 explicit_session_id: Option<&str>,
491) -> Option<std::io::Result<Vec<OverlayDirEntry>>> {
492 let session_id = active_session_id(explicit_session_id)?;
493 let mut guard = lock_sessions();
494 let state = state_for_locked(&mut guard, &session_id, None).ok()?;
495 let result = if state.mode == FsMode::Staged {
496 Some(overlay_read_dir(&state, path))
497 } else {
498 None
499 };
500 guard.insert(session_id, state);
501 result
502}
503
504pub(crate) fn stage_write_or_none(
505 builtin: &'static str,
506 path: &Path,
507 bytes: &[u8],
508 create_parents: bool,
509 overwrite: bool,
510 explicit_session_id: Option<&str>,
511) -> Result<Option<WriteOutcome>, HostlibError> {
512 let Some(session_id) = active_session_id(explicit_session_id) else {
513 return Ok(None);
514 };
515 let mut guard = lock_sessions();
516 let mut state = state_for_locked(&mut guard, &session_id, None)?;
517 if state.mode != FsMode::Staged {
518 guard.insert(session_id, state);
519 return Ok(None);
520 }
521
522 let key = normalize_logical(path);
523 let existed = overlay_exists(&state, &key);
524 if existed && !overwrite {
525 guard.insert(session_id, state);
526 return Err(HostlibError::Backend {
527 builtin,
528 message: format!("`{}` exists and overwrite=false", key.display()),
529 });
530 }
531 if !create_parents && !parent_exists(&state, &key) {
532 guard.insert(session_id, state);
533 return Err(HostlibError::Backend {
534 builtin,
535 message: format!("parent directory for `{}` does not exist", key.display()),
536 });
537 }
538
539 let hash = write_body(&state, bytes).map_err(|err| HostlibError::Backend {
540 builtin,
541 message: err,
542 })?;
543 state.entries.insert(
544 key.clone(),
545 StagedEntry::Write {
546 body_hash: hash,
547 len: bytes.len() as u64,
548 created_at_ms: now_ms(),
549 snapshot_id: harn_vm::agent_sessions::current_tool_call_id(),
550 },
551 );
552 persist_state(&state, "write", Some(&key)).map_err(|err| HostlibError::Backend {
553 builtin,
554 message: err,
555 })?;
556 emit_staged_update(&state);
557 guard.insert(session_id, state);
558 Ok(Some(WriteOutcome {
559 created: !existed,
560 bytes_written: bytes.len(),
561 }))
562}
563
564pub(crate) fn stage_delete_or_none(
565 builtin: &'static str,
566 path: &Path,
567 recursive: bool,
568 explicit_session_id: Option<&str>,
569) -> Result<Option<bool>, HostlibError> {
570 let Some(session_id) = active_session_id(explicit_session_id) else {
571 return Ok(None);
572 };
573 let mut guard = lock_sessions();
574 let mut state = state_for_locked(&mut guard, &session_id, None)?;
575 if state.mode != FsMode::Staged {
576 guard.insert(session_id, state);
577 return Ok(None);
578 }
579
580 let key = normalize_logical(path);
581 let staged_targets = staged_paths_under(&state, &key);
582 let disk_exists = key.exists();
583 if !disk_exists && staged_targets.is_empty() {
584 guard.insert(session_id, state);
585 return Ok(Some(false));
586 }
587
588 if !disk_exists {
589 for staged in staged_targets {
590 state.entries.remove(&staged);
591 }
592 } else {
593 validate_delete_shape(builtin, &key, recursive)?;
594 for staged in staged_targets {
595 state.entries.remove(&staged);
596 }
597 state.entries.insert(
598 key.clone(),
599 StagedEntry::Delete {
600 recursive,
601 created_at_ms: now_ms(),
602 snapshot_id: harn_vm::agent_sessions::current_tool_call_id(),
603 },
604 );
605 }
606 persist_state(&state, "delete", Some(&key)).map_err(|err| HostlibError::Backend {
607 builtin,
608 message: err,
609 })?;
610 emit_staged_update(&state);
611 guard.insert(session_id, state);
612 Ok(Some(true))
613}
614
615#[derive(Clone, Debug)]
619pub struct SafeTextPatchOutcome {
620 pub result: SafeTextPatchResult,
622 pub current_hash: String,
624 pub after_hash: String,
626 pub created: bool,
628 pub bytes_written: usize,
630}
631
632#[derive(Clone, Copy, Debug, Eq, PartialEq)]
634pub enum SafeTextPatchResult {
635 Applied,
638 StaleBase,
641 NoOp,
644}
645
646impl SafeTextPatchResult {
647 fn as_str(self) -> &'static str {
648 match self {
649 Self::Applied => "applied",
650 Self::StaleBase => "stale_base",
651 Self::NoOp => "no_op",
652 }
653 }
654}
655
656fn hash_label(bytes: &[u8]) -> String {
660 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
661}
662
663pub fn safe_text_patch(
684 path: &Path,
685 content: &str,
686 expected_hash: Option<&str>,
687 session_id: Option<&str>,
688 create_parents: bool,
689 overwrite: bool,
690) -> Result<SafeTextPatchOutcome, HostlibError> {
691 let new_bytes = content.as_bytes();
692 let after_hash = hash_label(new_bytes);
693
694 if let Some(outcome) = safe_text_patch_staged(
695 path,
696 new_bytes,
697 expected_hash,
698 session_id,
699 create_parents,
700 overwrite,
701 &after_hash,
702 )? {
703 return Ok(outcome);
704 }
705
706 safe_text_patch_disk(path, new_bytes, expected_hash, create_parents, overwrite)
707}
708
709#[allow(clippy::too_many_arguments)]
715fn safe_text_patch_staged(
716 path: &Path,
717 new_bytes: &[u8],
718 expected_hash: Option<&str>,
719 session_id: Option<&str>,
720 create_parents: bool,
721 overwrite: bool,
722 after_hash: &str,
723) -> Result<Option<SafeTextPatchOutcome>, HostlibError> {
724 let Some(session) = active_session_id(session_id) else {
725 return Ok(None);
726 };
727 let mut guard = lock_sessions();
728 let mut state = state_for_locked(&mut guard, &session, None)?;
729 if state.mode != FsMode::Staged {
730 guard.insert(session, state);
731 return Ok(None);
732 }
733
734 let key = normalize_logical(path);
735 let (existing_bytes, existed) = match overlay_read(&state, path) {
736 Some(Ok(bytes)) => (bytes, true),
737 Some(Err(err)) if err.kind() == std::io::ErrorKind::NotFound => (Vec::new(), false),
738 Some(Err(err)) => {
739 guard.insert(session, state);
740 return Err(HostlibError::Backend {
741 builtin: SAFE_TEXT_PATCH_BUILTIN,
742 message: format!("read `{}`: {err}", path.display()),
743 });
744 }
745 None => match stdfs::read(path) {
746 Ok(bytes) => (bytes, true),
747 Err(err) if err.kind() == std::io::ErrorKind::NotFound => (Vec::new(), false),
748 Err(err) => {
749 guard.insert(session, state);
750 return Err(HostlibError::Backend {
751 builtin: SAFE_TEXT_PATCH_BUILTIN,
752 message: format!("read `{}`: {err}", path.display()),
753 });
754 }
755 },
756 };
757 let current_hash = hash_label(&existing_bytes);
758
759 if let Some(expected) = expected_hash {
760 if expected != current_hash {
761 guard.insert(session, state);
762 return Ok(Some(SafeTextPatchOutcome {
763 result: SafeTextPatchResult::StaleBase,
764 current_hash,
765 after_hash: after_hash.to_string(),
766 created: false,
767 bytes_written: 0,
768 }));
769 }
770 }
771
772 if existed && existing_bytes == new_bytes {
773 guard.insert(session, state);
774 return Ok(Some(SafeTextPatchOutcome {
775 result: SafeTextPatchResult::NoOp,
776 current_hash,
777 after_hash: after_hash.to_string(),
778 created: false,
779 bytes_written: 0,
780 }));
781 }
782
783 let overlay_existed = overlay_exists(&state, &key);
784 if overlay_existed && !overwrite {
785 guard.insert(session, state);
786 return Err(HostlibError::Backend {
787 builtin: SAFE_TEXT_PATCH_BUILTIN,
788 message: format!("`{}` exists and overwrite=false", key.display()),
789 });
790 }
791 if !create_parents && !parent_exists(&state, &key) {
792 guard.insert(session, state);
793 return Err(HostlibError::Backend {
794 builtin: SAFE_TEXT_PATCH_BUILTIN,
795 message: format!("parent directory for `{}` does not exist", key.display()),
796 });
797 }
798
799 let body_hash = write_body(&state, new_bytes).map_err(|err| HostlibError::Backend {
800 builtin: SAFE_TEXT_PATCH_BUILTIN,
801 message: err,
802 })?;
803 state.entries.insert(
804 key.clone(),
805 StagedEntry::Write {
806 body_hash,
807 len: new_bytes.len() as u64,
808 created_at_ms: now_ms(),
809 snapshot_id: harn_vm::agent_sessions::current_tool_call_id(),
810 },
811 );
812 persist_state(&state, "safe_text_patch", Some(&key)).map_err(|err| HostlibError::Backend {
813 builtin: SAFE_TEXT_PATCH_BUILTIN,
814 message: err,
815 })?;
816 emit_staged_update(&state);
817 guard.insert(session, state);
818
819 Ok(Some(SafeTextPatchOutcome {
820 result: SafeTextPatchResult::Applied,
821 current_hash,
822 after_hash: after_hash.to_string(),
823 created: !existed,
824 bytes_written: new_bytes.len(),
825 }))
826}
827
828fn safe_text_patch_disk(
832 path: &Path,
833 new_bytes: &[u8],
834 expected_hash: Option<&str>,
835 create_parents: bool,
836 overwrite: bool,
837) -> Result<SafeTextPatchOutcome, HostlibError> {
838 let options = harn_vm::conditional_replace::ConditionalReplaceOptions {
839 expected_sha256: expected_hash.map(str::to_string),
840 create: true,
841 overwrite,
842 create_parents,
843 durability: harn_vm::atomic_io::AtomicWriteDurability::Flush,
844 };
845 let receipt = harn_vm::conditional_replace::conditional_replace_with_hook(
846 path,
847 new_bytes,
848 &options,
849 || crate::fs_snapshot::auto_capture_for_write(SAFE_TEXT_PATCH_BUILTIN, path),
850 )
851 .map_err(|err| HostlibError::Backend {
852 builtin: SAFE_TEXT_PATCH_BUILTIN,
853 message: format!("replace `{}`: {err}", path.display()),
854 })?;
855 Ok(SafeTextPatchOutcome {
856 result: match receipt.status {
857 harn_vm::conditional_replace::ConditionalReplaceStatus::Created
858 | harn_vm::conditional_replace::ConditionalReplaceStatus::Replaced => {
859 SafeTextPatchResult::Applied
860 }
861 harn_vm::conditional_replace::ConditionalReplaceStatus::NoOp => {
862 SafeTextPatchResult::NoOp
863 }
864 harn_vm::conditional_replace::ConditionalReplaceStatus::Stale => {
865 SafeTextPatchResult::StaleBase
866 }
867 },
868 current_hash: receipt.before_sha256,
869 after_hash: receipt.after_sha256,
870 created: receipt.status == harn_vm::conditional_replace::ConditionalReplaceStatus::Created,
871 bytes_written: receipt.bytes_written,
872 })
873}
874
875fn read_existing(
880 builtin: &'static str,
881 path: &Path,
882 session_id: Option<&str>,
883) -> Result<(Vec<u8>, bool), HostlibError> {
884 if let Some(result) = read(path, session_id) {
885 return match result {
886 Ok(bytes) => Ok((bytes, true)),
887 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((Vec::new(), false)),
888 Err(err) => Err(HostlibError::Backend {
889 builtin,
890 message: format!("read `{}`: {err}", path.display()),
891 }),
892 };
893 }
894 match stdfs::read(path) {
895 Ok(bytes) => Ok((bytes, true)),
896 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((Vec::new(), false)),
897 Err(err) => Err(HostlibError::Backend {
898 builtin,
899 message: format!("read `{}`: {err}", path.display()),
900 }),
901 }
902}
903
904fn read_text_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
905 let raw = dict_arg(READ_TEXT_BUILTIN, args)?;
906 let dict = raw.as_ref();
907 let path_str = require_string(READ_TEXT_BUILTIN, dict, "path")?;
908 let session_id = optional_string(READ_TEXT_BUILTIN, dict, "session_id")?;
909 let path = resolve_host_path(&path_str);
910 enforce_path_scope(READ_TEXT_BUILTIN, &path, FsAccess::Read)?;
911
912 let (bytes, existed) = read_existing(READ_TEXT_BUILTIN, &path, session_id.as_deref())?;
913 let hash = hash_label(&bytes);
914 let content = match std::str::from_utf8(&bytes) {
915 Ok(s) => s.to_string(),
916 Err(err) => {
917 return Err(HostlibError::Backend {
918 builtin: READ_TEXT_BUILTIN,
919 message: format!("`{path_str}` is not valid UTF-8: {err}"),
920 });
921 }
922 };
923 let bytes_len = bytes.len() as i64;
924 Ok(build_dict([
925 ("path", str_value(&path_str)),
926 ("content", str_value(&content)),
927 ("sha256", str_value(&hash)),
928 ("size", VmValue::Int(bytes_len)),
929 ("exists", VmValue::Bool(existed)),
930 ]))
931}
932
933fn safe_text_patch_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
934 let raw = dict_arg(SAFE_TEXT_PATCH_BUILTIN, args)?;
935 let dict = raw.as_ref();
936
937 let path_str = require_string(SAFE_TEXT_PATCH_BUILTIN, dict, "path")?;
938 let content = require_string(SAFE_TEXT_PATCH_BUILTIN, dict, "content")?;
939 let expected_hash = optional_string(SAFE_TEXT_PATCH_BUILTIN, dict, "expected_hash")?;
940 let session_id = optional_string(SAFE_TEXT_PATCH_BUILTIN, dict, "session_id")?;
941 let create_parents = optional_bool(SAFE_TEXT_PATCH_BUILTIN, dict, "create_parents", true)?;
942 let overwrite = optional_bool(SAFE_TEXT_PATCH_BUILTIN, dict, "overwrite", true)?;
943
944 let path = resolve_host_path(&path_str);
945 enforce_path_scope(SAFE_TEXT_PATCH_BUILTIN, &path, FsAccess::Write)?;
946 let outcome = safe_text_patch(
947 &path,
948 &content,
949 expected_hash.as_deref(),
950 session_id.as_deref(),
951 create_parents,
952 overwrite,
953 )?;
954
955 let entries: Vec<(&'static str, VmValue)> = vec![
956 ("path", str_value(&path_str)),
957 ("result", str_value(outcome.result.as_str())),
958 (
959 "applied",
960 VmValue::Bool(outcome.result == SafeTextPatchResult::Applied),
961 ),
962 (
963 "stale_base",
964 VmValue::Bool(outcome.result == SafeTextPatchResult::StaleBase),
965 ),
966 ("current_hash", str_value(&outcome.current_hash)),
967 ("before_sha256", str_value(&outcome.current_hash)),
968 ("after_sha256", str_value(&outcome.after_hash)),
969 ("created", VmValue::Bool(outcome.created)),
970 ("bytes_written", VmValue::Int(outcome.bytes_written as i64)),
971 (
972 "expected_hash",
973 match expected_hash.as_deref() {
974 Some(hash) => str_value(hash),
975 None => VmValue::Nil,
976 },
977 ),
978 ];
979 Ok(build_dict(entries))
980}
981
982fn emit_safe_text_patch_result_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
983 let raw = dict_arg(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, args)?;
984 let dict = raw.as_ref();
985
986 let path = require_string(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "path")?;
987 let result = require_string(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "result")?;
988 let hunks_count = optional_int(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "hunks_count", 0)?;
989 let bytes_written = optional_int(
990 EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN,
991 dict,
992 "bytes_written",
993 0,
994 )?;
995 let failed_hunk_index = match dict.get("failed_hunk_index") {
996 None | Some(VmValue::Nil) => None,
997 Some(VmValue::Int(n)) if *n >= 0 => Some(*n as usize),
998 Some(other) => {
999 return Err(HostlibError::InvalidParameter {
1000 builtin: EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN,
1001 param: "failed_hunk_index",
1002 message: format!("expected non-negative integer, got {}", other.type_name()),
1003 });
1004 }
1005 };
1006 let session_id = optional_string(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "session_id")?
1007 .or_else(harn_vm::agent_sessions::current_session_id);
1008
1009 if let Some(session_id) = session_id.filter(|s| !s.trim().is_empty()) {
1010 harn_vm::agent_events::emit_event(&AgentEvent::SafeTextPatchResult {
1011 session_id,
1012 path,
1013 result,
1014 hunks_count: hunks_count.max(0) as usize,
1015 bytes_written: bytes_written.max(0) as u64,
1016 failed_hunk_index,
1017 });
1018 Ok(VmValue::Bool(true))
1019 } else {
1020 Ok(VmValue::Bool(false))
1024 }
1025}
1026
1027fn set_mode_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1028 let raw = dict_arg(SET_MODE_BUILTIN, args)?;
1029 let dict = raw.as_ref();
1030 let session_id = require_string(SET_MODE_BUILTIN, dict, "session_id")?;
1031 let mode = FsMode::parse(
1032 SET_MODE_BUILTIN,
1033 &require_string(SET_MODE_BUILTIN, dict, "mode")?,
1034 )?;
1035 let root =
1036 optional_string(SET_MODE_BUILTIN, dict, "root")?.map(|path| resolve_host_path(&path));
1037 let result = set_mode(&session_id, mode, root.as_deref())?;
1038 Ok(build_dict([(
1039 "previous_mode",
1040 str_value(result.previous_mode.as_str()),
1041 )]))
1042}
1043
1044fn staged_status_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1045 let raw = dict_arg(STATUS_BUILTIN, args)?;
1046 let session_id = require_string(STATUS_BUILTIN, raw.as_ref(), "session_id")?;
1047 Ok(status_to_value(staged_status(&session_id)?))
1048}
1049
1050fn commit_staged_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1051 let raw = dict_arg(COMMIT_BUILTIN, args)?;
1052 let dict = raw.as_ref();
1053 let session_id = require_string(COMMIT_BUILTIN, dict, "session_id")?;
1054 let paths = optional_string_list(COMMIT_BUILTIN, dict, "paths")?;
1055 Ok(commit_result_to_value(commit_staged(&session_id, &paths)?))
1056}
1057
1058fn discard_staged_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1059 let raw = dict_arg(DISCARD_BUILTIN, args)?;
1060 let dict = raw.as_ref();
1061 let session_id = require_string(DISCARD_BUILTIN, dict, "session_id")?;
1062 let paths = optional_string_list(DISCARD_BUILTIN, dict, "paths")?;
1063 Ok(discard_result_to_value(discard_staged(
1064 &session_id,
1065 &paths,
1066 )?))
1067}
1068
1069fn state_for_locked(
1070 guard: &mut BTreeMap<String, SessionState>,
1071 session_id: &str,
1072 root: Option<PathBuf>,
1073) -> Result<SessionState, HostlibError> {
1074 if let Some(existing) = guard.get(session_id) {
1075 let mut state = existing.clone();
1076 if let Some(root) = root {
1077 if state.entries.is_empty() {
1078 state.root = root;
1079 }
1080 }
1081 return Ok(state);
1082 }
1083 let state = load_state(session_id, root).map_err(|err| HostlibError::Backend {
1084 builtin: SET_MODE_BUILTIN,
1085 message: err,
1086 })?;
1087 Ok(state)
1088}
1089
1090fn load_state(session_id: &str, root: Option<PathBuf>) -> Result<SessionState, String> {
1091 let root = root.unwrap_or_else(default_root);
1092 let manifest_path = manifest_path(&root, session_id);
1093 if manifest_path.exists() {
1094 let text = stdfs::read_to_string(&manifest_path)
1095 .map_err(|err| format!("read {}: {err}", manifest_path.display()))?;
1096 let manifest: Manifest = serde_json::from_str(&text)
1097 .map_err(|err| format!("parse {}: {err}", manifest_path.display()))?;
1098 if manifest.version != MANIFEST_VERSION {
1099 return Err(format!(
1100 "unsupported staged fs manifest version {} in {}",
1101 manifest.version,
1102 manifest_path.display()
1103 ));
1104 }
1105 if manifest.session_id != session_id {
1106 return Err(format!(
1107 "staged fs manifest session id mismatch in {}",
1108 manifest_path.display()
1109 ));
1110 }
1111 return Ok(SessionState {
1112 session_id: manifest.session_id,
1113 mode: manifest.mode,
1114 root: normalize_logical(Path::new(&manifest.root)),
1115 entries: manifest
1116 .entries
1117 .into_iter()
1118 .map(|(path, entry)| (normalize_logical(Path::new(&path)), entry))
1119 .collect(),
1120 });
1121 }
1122 Ok(SessionState {
1123 session_id: session_id.to_string(),
1124 mode: FsMode::Immediate,
1125 root,
1126 entries: BTreeMap::new(),
1127 })
1128}
1129
1130fn persist_state(state: &SessionState, op: &str, path: Option<&Path>) -> Result<(), String> {
1131 let dir = session_dir(&state.root, &state.session_id);
1132 stdfs::create_dir_all(dir.join("bodies"))
1133 .map_err(|err| format!("mkdir {}: {err}", dir.display()))?;
1134 let manifest = Manifest {
1135 version: MANIFEST_VERSION,
1136 session_id: state.session_id.clone(),
1137 mode: state.mode,
1138 root: state.root.to_string_lossy().into_owned(),
1139 entries: state
1140 .entries
1141 .iter()
1142 .map(|(path, entry)| (path.to_string_lossy().into_owned(), entry.clone()))
1143 .collect(),
1144 };
1145 let bytes = serde_json::to_vec_pretty(&manifest)
1146 .map_err(|err| format!("serialize staged manifest: {err}"))?;
1147 atomic_write(&manifest_path(&state.root, &state.session_id), &bytes)?;
1148 append_journal(state, op, path)?;
1149 prune_unreferenced_bodies(state);
1150 Ok(())
1151}
1152
1153fn append_journal(state: &SessionState, op: &str, path: Option<&Path>) -> Result<(), String> {
1154 let dir = session_dir(&state.root, &state.session_id);
1155 stdfs::create_dir_all(&dir).map_err(|err| format!("mkdir {}: {err}", dir.display()))?;
1156 let line = serde_json::to_string(&serde_json::json!({
1157 "ts_ms": now_ms(),
1158 "op": op,
1159 "path": path.map(|path| path.to_string_lossy().into_owned()), "pending_count": state.entries.len(),
1161 }))
1162 .map_err(|err| format!("serialize staged journal: {err}"))?;
1163 let mut file = stdfs::OpenOptions::new()
1164 .create(true)
1165 .append(true)
1166 .open(dir.join("journal.jsonl"))
1167 .map_err(|err| format!("open staged journal: {err}"))?;
1168 writeln!(file, "{line}").map_err(|err| format!("write staged journal: {err}"))
1169}
1170
1171fn write_body(state: &SessionState, bytes: &[u8]) -> Result<String, String> {
1172 let hash = hex::encode(Sha256::digest(bytes));
1173 let path = session_dir(&state.root, &state.session_id)
1174 .join("bodies")
1175 .join(&hash);
1176 if !path.exists() {
1177 atomic_write(&path, bytes)?;
1178 }
1179 Ok(hash)
1180}
1181
1182fn read_body(state: &SessionState, hash: &str) -> std::io::Result<Vec<u8>> {
1183 stdfs::read(
1184 session_dir(&state.root, &state.session_id)
1185 .join("bodies")
1186 .join(hash),
1187 )
1188}
1189
1190fn prune_unreferenced_bodies(state: &SessionState) {
1191 let live: BTreeSet<String> = state
1192 .entries
1193 .values()
1194 .filter_map(|entry| match entry {
1195 StagedEntry::Write { body_hash, .. } => Some(body_hash.clone()),
1196 StagedEntry::Delete { .. } => None,
1197 })
1198 .collect();
1199 let body_dir = session_dir(&state.root, &state.session_id).join("bodies");
1200 let Ok(entries) = stdfs::read_dir(&body_dir) else {
1201 return;
1202 };
1203 for entry in entries.flatten() {
1204 let name = entry.file_name().to_string_lossy().into_owned();
1205 if !live.contains(&name) {
1206 let _ = stdfs::remove_file(entry.path());
1207 }
1208 }
1209}
1210
1211fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
1212 harn_vm::atomic_io::atomic_write(path, bytes)
1213 .map_err(|error| format!("write {}: {error}", path.display()))
1214}
1215
1216fn commit_entry(state: &SessionState, path: &Path, entry: &StagedEntry) -> Result<(), String> {
1217 match entry {
1218 StagedEntry::Write { body_hash, .. } => {
1219 let bytes = read_body(state, body_hash)
1220 .map_err(|err| format!("read staged body for {}: {err}", path.display()))?;
1221 atomic_write(path, &bytes)
1222 }
1223 StagedEntry::Delete { recursive, .. } => match stdfs::symlink_metadata(path) {
1224 Ok(metadata) if metadata.is_dir() => {
1225 if *recursive {
1226 stdfs::remove_dir_all(path)
1227 .map_err(|err| format!("remove_dir_all {}: {err}", path.display()))
1228 } else {
1229 stdfs::remove_dir(path)
1230 .map_err(|err| format!("remove_dir {}: {err}", path.display()))
1231 }
1232 }
1233 Ok(_) => stdfs::remove_file(path)
1234 .map_err(|err| format!("remove_file {}: {err}", path.display())),
1235 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1236 Err(err) => Err(format!("stat {}: {err}", path.display())),
1237 },
1238 }
1239}
1240
1241fn overlay_read(state: &SessionState, path: &Path) -> Option<std::io::Result<Vec<u8>>> {
1242 let key = normalize_logical(path);
1243 if let Some(entry) = state.entries.get(&key) {
1244 return Some(match entry {
1245 StagedEntry::Write { body_hash, .. } => read_body(state, body_hash),
1246 StagedEntry::Delete { .. } => Err(not_found(&key)),
1247 });
1248 }
1249 if deleted_ancestor(state, &key) {
1250 return Some(Err(not_found(&key)));
1251 }
1252 None
1253}
1254
1255fn overlay_read_dir(state: &SessionState, path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
1256 let dir_key = normalize_logical(path);
1257 if matches!(state.entries.get(&dir_key), Some(StagedEntry::Write { .. }))
1258 || deleted_ancestor(state, &dir_key)
1259 || matches!(
1260 state.entries.get(&dir_key),
1261 Some(StagedEntry::Delete { .. })
1262 )
1263 {
1264 return Err(not_found(&dir_key));
1265 }
1266 if !path.exists() && !has_staged_descendant(state, &dir_key) {
1267 return Err(not_found(&dir_key));
1268 }
1269
1270 let mut entries: BTreeMap<String, OverlayDirEntry> = BTreeMap::new();
1271 if path.exists() {
1272 for entry in stdfs::read_dir(path)? {
1273 let entry = entry?;
1274 let name = entry.file_name().to_string_lossy().into_owned();
1275 let file_type = entry.file_type().ok();
1276 let metadata = entry.metadata().ok();
1277 entries.insert(
1278 name.clone(),
1279 OverlayDirEntry {
1280 name,
1281 is_dir: file_type.is_some_and(|ty| ty.is_dir()),
1282 is_symlink: file_type.is_some_and(|ty| ty.is_symlink()),
1283 size: metadata.map(|m| m.len()).unwrap_or(0),
1284 },
1285 );
1286 }
1287 }
1288
1289 for (path, entry) in &state.entries {
1290 let Some(name) = overlay_child_name(path, &dir_key) else {
1291 continue;
1292 };
1293 match entry {
1294 StagedEntry::Write { len, .. } => {
1295 let is_dir = path.parent() != Some(dir_key.as_path());
1296 entries.insert(
1297 name.clone(),
1298 OverlayDirEntry {
1299 name,
1300 is_dir,
1301 is_symlink: false,
1302 size: if is_dir { 0 } else { *len },
1303 },
1304 );
1305 }
1306 StagedEntry::Delete { .. } => {
1307 if path.parent() == Some(dir_key.as_path()) {
1308 entries.remove(&name);
1309 }
1310 }
1311 }
1312 }
1313
1314 Ok(entries.into_values().collect())
1315}
1316
1317fn overlay_child_name(path: &Path, dir: &Path) -> Option<String> {
1318 let suffix = path.strip_prefix(dir).ok()?;
1319 let mut components = suffix.components();
1320 let first = components.next()?;
1321 match first {
1322 Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
1323 _ => None,
1324 }
1325}
1326
1327fn overlay_exists(state: &SessionState, path: &Path) -> bool {
1328 if let Some(entry) = state.entries.get(path) {
1329 return matches!(entry, StagedEntry::Write { .. });
1330 }
1331 if deleted_ancestor(state, path) {
1332 return false;
1333 }
1334 if has_staged_descendant(state, path) {
1335 return true;
1336 }
1337 path.exists()
1338}
1339
1340fn parent_exists(state: &SessionState, path: &Path) -> bool {
1341 let Some(parent) = path.parent() else {
1342 return true;
1343 };
1344 if parent.as_os_str().is_empty() {
1345 return true;
1346 }
1347 if let Some(entry) = state.entries.get(parent) {
1348 return !matches!(entry, StagedEntry::Delete { .. });
1349 }
1350 if deleted_ancestor(state, parent) {
1351 return false;
1352 }
1353 if has_staged_descendant(state, parent) {
1354 return true;
1355 }
1356 parent.is_dir()
1357}
1358
1359fn deleted_ancestor(state: &SessionState, path: &Path) -> bool {
1360 state.entries.iter().any(|(candidate, entry)| {
1361 matches!(entry, StagedEntry::Delete { .. })
1362 && path != candidate.as_path()
1363 && path.starts_with(candidate)
1364 })
1365}
1366
1367fn has_staged_descendant(state: &SessionState, path: &Path) -> bool {
1368 state.entries.iter().any(|(candidate, entry)| {
1369 matches!(entry, StagedEntry::Write { .. })
1370 && candidate != path
1371 && candidate.starts_with(path)
1372 })
1373}
1374
1375fn staged_paths_under(state: &SessionState, path: &Path) -> Vec<PathBuf> {
1376 state
1377 .entries
1378 .keys()
1379 .filter(|candidate| *candidate == path || candidate.starts_with(path))
1380 .cloned()
1381 .collect()
1382}
1383
1384fn validate_delete_shape(
1385 builtin: &'static str,
1386 path: &Path,
1387 recursive: bool,
1388) -> Result<(), HostlibError> {
1389 let Ok(metadata) = stdfs::symlink_metadata(path) else {
1390 return Ok(());
1391 };
1392 if metadata.is_dir() && !recursive {
1393 let mut entries = stdfs::read_dir(path).map_err(|err| HostlibError::Backend {
1394 builtin,
1395 message: format!("read_dir `{}`: {err}", path.display()),
1396 })?;
1397 if entries.next().is_some() {
1398 return Err(HostlibError::Backend {
1399 builtin,
1400 message: format!(
1401 "remove_dir `{}` (pass recursive=true to delete non-empty dirs): directory not empty",
1402 path.display()
1403 ),
1404 });
1405 }
1406 }
1407 Ok(())
1408}
1409
1410fn status_from_state(state: &SessionState) -> StagedStatus {
1411 let now = now_ms();
1412 let mut pending_writes = Vec::new();
1413 let mut total_bytes_pending = 0u64;
1414 let mut oldest = None;
1415 for (path, entry) in &state.entries {
1416 total_bytes_pending = total_bytes_pending.saturating_add(entry.body_len());
1417 oldest = Some(oldest.map_or(entry.created_at_ms(), |old: i64| {
1418 old.min(entry.created_at_ms())
1419 }));
1420 let (kind, change_kind, bytes_added, bytes_removed) = match entry {
1421 StagedEntry::Write { len, .. } => match disk_size(path) {
1422 Some(previous_len) => ("write", "modify", *len, previous_len),
1423 None => ("write", "create", *len, 0),
1424 },
1425 StagedEntry::Delete { .. } => ("delete", "delete", 0, disk_size(path).unwrap_or(0)),
1426 };
1427 pending_writes.push(PendingWrite {
1428 path: to_agent_path(path),
1429 kind,
1430 bytes_added,
1431 bytes_removed,
1432 snapshot_id: entry.snapshot_id().map(str::to_string),
1433 change_kind,
1434 });
1435 }
1436 StagedStatus {
1437 pending_writes,
1438 total_bytes_pending,
1439 oldest_pending_age_ms: oldest.map(|old| now.saturating_sub(old)).unwrap_or(0),
1440 }
1441}
1442
1443fn disk_size(path: &Path) -> Option<u64> {
1444 let metadata = stdfs::symlink_metadata(path).ok()?;
1445 if metadata.is_file() {
1446 return Some(metadata.len());
1447 }
1448 if metadata.is_dir() {
1449 let mut total = 0u64;
1450 for entry in walkdir::WalkDir::new(path)
1451 .into_iter()
1452 .filter_map(Result::ok)
1453 {
1454 if let Ok(metadata) = entry.metadata() {
1455 if metadata.is_file() {
1456 total = total.saturating_add(metadata.len());
1457 }
1458 }
1459 }
1460 return Some(total);
1461 }
1462 Some(metadata.len())
1463}
1464
1465fn selected_paths(state: &SessionState, paths: &[String]) -> Vec<PathBuf> {
1466 if paths.is_empty() {
1467 return state.entries.keys().cloned().collect();
1468 }
1469 let selected: BTreeSet<PathBuf> = paths
1470 .iter()
1471 .map(|path| normalize_logical(Path::new(path)))
1472 .collect();
1473 state
1474 .entries
1475 .keys()
1476 .filter(|path| selected.contains(*path))
1477 .cloned()
1478 .collect()
1479}
1480
1481fn now_ms() -> i64 {
1482 std::time::SystemTime::now()
1483 .duration_since(std::time::UNIX_EPOCH)
1484 .map(|duration| duration.as_millis() as i64)
1485 .unwrap_or(0)
1486}
1487
1488fn emit_staged_update(state: &SessionState) {
1489 let status = status_from_state(state);
1490 harn_vm::agent_events::emit_event(&AgentEvent::StagedWritesPending {
1491 session_id: state.session_id.clone(),
1492 pending_count: status.pending_writes.len(),
1493 total_bytes: status.total_bytes_pending,
1494 pending_writes: status
1495 .pending_writes
1496 .iter()
1497 .map(PendingWrite::event_summary)
1498 .collect(),
1499 });
1500}