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