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