Skip to main content

harn_hostlib/
fs.rs

1//! Session-scoped staged filesystem mode.
2//!
3//! `hostlib_fs_set_mode({session_id, mode: "staged"})` makes hostlib file
4//! mutations land in a durable per-session overlay under
5//! `.harn/state/staged/<session_id>/`. Reads made by the same session consult
6//! that overlay first, so agent loops see their own pending writes without
7//! touching the working tree until `hostlib_fs_commit_staged`.
8
9use 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/// Hostlib filesystem capability handle.
55#[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        // `safe_text_patch` and `read_text` touch arbitrary host paths, so
74        // they share the deterministic-tools gate with `tools::*` file I/O.
75        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/// Filesystem mode for one hostlib session.
92#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
93#[serde(rename_all = "lowercase")]
94pub enum FsMode {
95    /// Mutations apply to the working tree immediately.
96    Immediate,
97    /// Mutations are recorded in the staging layer until committed.
98    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    /// Wire string used by hostlib schemas.
115    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/// Summary of staged filesystem changes for one session.
198#[derive(Clone, Debug)]
199pub struct StagedStatus {
200    /// Pending path changes, sorted by path.
201    pub pending_writes: Vec<PendingWrite>,
202    /// Bytes stored in staged write bodies.
203    pub total_bytes_pending: u64,
204    /// Age in milliseconds of the oldest pending change, or 0 when empty.
205    pub oldest_pending_age_ms: i64,
206}
207
208#[derive(Clone, Debug)]
209/// One pending staged filesystem change.
210pub struct PendingWrite {
211    /// Absolute path affected by this staged change.
212    pub path: String,
213    /// Staging operation kind (`write`, `delete`, or reserved future `move`).
214    pub kind: &'static str,
215    /// Bytes the final staged view adds at this path.
216    pub bytes_added: u64,
217    /// Bytes the final staged view removes at this path.
218    pub bytes_removed: u64,
219    /// ACP tool-call id of the mutation that produced the final staged view.
220    pub snapshot_id: Option<String>,
221    change_kind: &'static str,
222}
223
224impl PendingWrite {
225    /// Convert the hostlib status row into the shared agent-event projection.
226    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/// Result returned after changing a session's filesystem mode.
239#[derive(Clone, Debug)]
240pub struct SetModeResult {
241    /// Mode active before the change.
242    pub previous_mode: FsMode,
243}
244
245/// Result returned after applying staged changes to disk.
246#[derive(Clone, Debug)]
247pub struct CommitResult {
248    /// Paths successfully applied to disk.
249    pub committed_paths: Vec<String>,
250    /// Paths that failed to apply, with human-readable reasons.
251    pub failed_paths_with_reasons: Vec<(String, String)>,
252}
253
254/// Result returned after dropping staged changes.
255#[derive(Clone, Debug)]
256pub struct DiscardResult {
257    /// Paths whose staged entries were removed.
258    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
267/// Lock the session map, panicking with one canonical message if a prior
268/// holder poisoned the mutex. Every accessor goes through here so the poison
269/// policy and message live in exactly one place.
270fn lock_sessions() -> std::sync::MutexGuard<'static, BTreeMap<String, SessionState>> {
271    sessions()
272        .lock()
273        .expect("hostlib fs session mutex poisoned")
274}
275
276/// Remember the workspace root associated with a live session.
277///
278/// ACP calls this when a prompt starts so Harn code can call
279/// `hostlib_fs_set_mode({session_id, mode})` without also passing a root.
280pub 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
303/// Return the root currently associated with a hostlib session.
304pub 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
312/// Set a session's filesystem mode.
313pub 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
331/// Return the staged status for a session.
332pub 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
341/// Return native filesystem paths for every pending staged entry.
342///
343/// Public staged status normalizes paths for the agent/tool surface. Internal
344/// callers that need to read from the filesystem must keep the native
345/// [`PathBuf`]s, especially on Windows where slash-normalized display strings
346/// are not always valid filesystem paths.
347pub(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
356/// Commit staged changes for all paths or for a filtered path list.
357pub 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        // The overlay always lives inside the workspace, but commit flushes
371        // to the *target* working-tree path. Enforce workspace-root scope
372        // against that target so a staged entry — possibly persisted under
373        // a looser policy in an earlier session — can never write outside
374        // the roots active at commit time.
375        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
404/// Discard staged changes for all paths or for a filtered path list.
405pub 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
425/// Remove all persisted staged-fs state for a caller-owned throw-away session.
426///
427/// Normal agent sessions keep their manifest after `discard_staged` so hosts can
428/// continue reporting session state. Transient dry-run sessions own their ids,
429/// though, and should remove both the in-memory entry and on-disk overlay after
430/// their preview is rendered.
431pub 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/// Outcome of one [`safe_text_patch`] call. `applied` says whether the
611/// on-disk (or staged-overlay) bytes changed; `result` carries the
612/// structured discriminant used by the wire/JSON shape.
613#[derive(Clone, Debug)]
614pub struct SafeTextPatchOutcome {
615    /// Discriminant: `"applied"`, `"stale_base"`, or `"no_op"`.
616    pub result: SafeTextPatchResult,
617    /// `sha256:HEX` of the pre-image (overlay-aware) the call observed.
618    pub current_hash: String,
619    /// `sha256:HEX` of the requested post-image.
620    pub after_hash: String,
621    /// `true` when the file did not exist before the call.
622    pub created: bool,
623    /// Bytes written; `0` on `stale_base` or `no_op`.
624    pub bytes_written: usize,
625}
626
627/// Discriminant for a [`safe_text_patch`] outcome.
628#[derive(Clone, Copy, Debug, Eq, PartialEq)]
629pub enum SafeTextPatchResult {
630    /// Pre-image hash matched (or no expected hash supplied) and the
631    /// post-image differs from the pre-image — bytes were written.
632    Applied,
633    /// `expected_hash` did not match the observed pre-image hash; no
634    /// bytes were written. Callers should re-read and retry.
635    StaleBase,
636    /// Pre-image hash matched and the post-image equals the pre-image —
637    /// skipped the write to avoid spurious timestamps and overlay churn.
638    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
651/// Format `bytes` as the `sha256:HEX` label used in `before_sha256` /
652/// `after_sha256` / `current_hash` / `expected_hash` everywhere in the
653/// safe-text-patch surface.
654fn hash_label(bytes: &[u8]) -> String {
655    format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
656}
657
658/// Atomic compare-and-swap-style text write.
659///
660/// Reads the current bytes at `path` through the staged-fs overlay (when a
661/// session is active) so concurrent agent edits see each other's pending
662/// writes. If `expected_hash` is supplied and differs from the observed
663/// `sha256:HEX`, returns `SafeTextPatchResult::StaleBase` without
664/// mutating any state. On a hash match the post-image is written through
665/// the same overlay path, keeping the read and the write atomic with
666/// respect to other staged-fs consumers in the same process.
667///
668/// Atomicity:
669///
670/// - When a session is in staged mode, the read, hash check, and write
671///   all happen under a single acquisition of the sessions mutex, so a
672///   sibling thread cannot stage a write into the window between the
673///   pre-image snapshot and the commit.
674/// - When the call routes through disk (no active session, or session in
675///   immediate mode), a canonical-path file lock spans the read, hash check,
676///   and atomic rename. Competing Harn processes therefore observe the winner's
677///   post-image and return `stale_base` instead of silently overwriting it.
678pub 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/// Atomic CAS path for a session in `staged` mode. Holds the sessions
705/// mutex through the entire read → hash → check → write so concurrent
706/// agents in the same process cannot race the snapshot. Returns `None`
707/// when no session is active or the session is in `immediate` mode, so
708/// the caller can fall through to the disk path.
709#[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
823/// Disk path for callers without an active staged session. The shared VM
824/// replacement boundary owns locking, digest comparison, snapshot timing,
825/// and the atomic namespace update.
826fn 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
870/// Read the pre-image through the staged-fs overlay (when active),
871/// falling back to disk. Returns `(bytes, existed_on_disk_or_overlay)`.
872/// `builtin` is the caller's tag — used so backend errors point at the
873/// right builtin name in diagnostics.
874fn 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        // Silently no-op when no session is active — telemetry without a
1016        // session has nowhere to route. Caller can opt in by always
1017        // passing session_id explicitly.
1018        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()), // agent-path-ok: on-disk journal.jsonl audit line, never returned to the agent
1155        "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}