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