lifeloop-cli 0.3.2

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Host context-pressure status persistence.
//!
//! Captures host-observed context-pressure signals — today the harness
//! compaction signal at minimum; transcript size and used-percentage land
//! in later slices — and persists them in a session-scoped JSON file
//! under the same state directory family as `renewal-status.json`.
//! Downstream consumers read the persisted signals (today via environment
//! variables set on the next consumer invocation) to upgrade their risk
//! assessment without re-deriving the signal themselves.
//!
//! Naming is host-neutral on purpose: the file shape is the same for any
//! host whose adapter reports a compaction signal. The `host` field
//! identifies the producer; only the CLI flag `--host <id>` is
//! provider-named.

use std::fs;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use super::CliError;

/// Filename for the persisted host context-pressure status. Lives alongside
/// `renewal-status.json` under the same state directory.
pub const STATUS_FILE: &str = "host-context-status.json";

/// Schema version for the persisted file. Bumped on any breaking field
/// change; additive fields keep the same version.
pub const SCHEMA_VERSION: &str = "1";

/// One snapshot of host-observed context-pressure signals for a specific
/// host session. `serde(deny_unknown_fields)` keeps producers and
/// consumers in lockstep — any future field addition is a schema bump.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HostContextStatus {
    pub schema_version: String,
    /// Host identifier as supplied via `--host` (`claude`, `codex`, etc.).
    pub host: String,
    /// Host-supplied session identifier the observation belongs to.
    pub session_id: String,
    /// True iff the host has reported a compaction signal during this session.
    pub compacted: bool,
    /// Epoch seconds when this status was last written.
    pub observed_at_epoch_s: u64,
}

/// Absolute path of the status file under `state_dir`.
pub fn status_path(state_dir: &Path) -> PathBuf {
    state_dir.join(STATUS_FILE)
}

/// Persist `status` to `state_dir/host-context-status.json`, creating the
/// directory if absent.
pub fn write_status(state_dir: &Path, status: &HostContextStatus) -> Result<(), CliError> {
    fs::create_dir_all(state_dir).map_err(|err| {
        CliError::Input(format!(
            "host-context status: failed to create state dir {}: {err}",
            state_dir.display()
        ))
    })?;
    let json = serde_json::to_string_pretty(status).map_err(|err| {
        CliError::Input(format!(
            "host-context status: failed to serialize status: {err}"
        ))
    })?;
    let path = status_path(state_dir);
    fs::write(&path, json).map_err(|err| {
        CliError::Input(format!(
            "host-context status: failed to write status {}: {err}",
            path.display()
        ))
    })
}

/// Read the persisted status from `state_dir`, returning `Ok(None)` when
/// the file is absent. Malformed files return `Err`.
pub fn read_status(state_dir: &Path) -> Result<Option<HostContextStatus>, CliError> {
    let path = status_path(state_dir);
    if !path.is_file() {
        return Ok(None);
    }
    let raw = fs::read_to_string(&path).map_err(|err| {
        CliError::Input(format!(
            "host-context status: failed to read status {}: {err}",
            path.display()
        ))
    })?;
    let status = serde_json::from_str::<HostContextStatus>(&raw).map_err(|err| {
        CliError::Input(format!(
            "host-context status: invalid status {}: {err}",
            path.display()
        ))
    })?;
    Ok(Some(status))
}

/// Remove the status file if (and only if) it currently belongs to
/// `session_id`. Returns `Ok(true)` when a removal happened, `Ok(false)`
/// when the file was absent or owned by a different session. Used on
/// session-end so stale signals do not leak into the next session.
pub fn clear_for_session(state_dir: &Path, session_id: &str) -> Result<bool, CliError> {
    let Some(status) = read_status(state_dir)? else {
        return Ok(false);
    };
    if status.session_id != session_id {
        return Ok(false);
    }
    let path = status_path(state_dir);
    fs::remove_file(&path).map_err(|err| {
        CliError::Input(format!(
            "host-context status: failed to clear status {}: {err}",
            path.display()
        ))
    })?;
    Ok(true)
}

/// Remove the status file unconditionally if it exists. Used at boundaries
/// that reset the host's context regardless of session identity (for
/// example a Codex `SessionStart` whose `source` is `"clear"`, or a host
/// session-start hook that does not surface `session_id`). Returns
/// `Ok(true)` when a removal happened, `Ok(false)` when the file was
/// absent.
pub fn clear_unconditional(state_dir: &Path) -> Result<bool, CliError> {
    let path = status_path(state_dir);
    if !path.is_file() {
        return Ok(false);
    }
    fs::remove_file(&path).map_err(|err| {
        CliError::Input(format!(
            "host-context status: failed to clear status {}: {err}",
            path.display()
        ))
    })?;
    Ok(true)
}

/// Remove the status file if it exists *and* its persisted session_id
/// differs from `current_session_id`. Used at boundaries where the host
/// reports a SessionStart that may be either a fresh session (different
/// id → clear) or a resume of the same session (same id → preserve so
/// the compaction signal survives the resume). Returns `Ok(true)` when a
/// removal happened, `Ok(false)` when the file was absent or owned by
/// `current_session_id`.
pub fn clear_if_session_changed(
    state_dir: &Path,
    current_session_id: &str,
) -> Result<bool, CliError> {
    let Some(status) = read_status(state_dir)? else {
        return Ok(false);
    };
    if status.session_id == current_session_id {
        return Ok(false);
    }
    let path = status_path(state_dir);
    fs::remove_file(&path).map_err(|err| {
        CliError::Input(format!(
            "host-context status: failed to clear status {}: {err}",
            path.display()
        ))
    })?;
    Ok(true)
}

/// Record a compaction signal observation for `(host, session_id)`.
/// Overwrites any existing status for the same session — the compaction signal
/// is sticky once-per-session, so re-observing is a no-op semantically but
/// refreshes the timestamp.
pub fn record_compaction(
    state_dir: &Path,
    host: &str,
    session_id: &str,
    observed_at_epoch_s: u64,
) -> Result<(), CliError> {
    let status = HostContextStatus {
        schema_version: SCHEMA_VERSION.to_string(),
        host: host.to_string(),
        session_id: session_id.to_string(),
        compacted: true,
        observed_at_epoch_s,
    };
    write_status(state_dir, &status)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn sample(session_id: &str, ts: u64) -> HostContextStatus {
        HostContextStatus {
            schema_version: SCHEMA_VERSION.to_string(),
            host: "claude".into(),
            session_id: session_id.into(),
            compacted: true,
            observed_at_epoch_s: ts,
        }
    }

    #[test]
    fn read_returns_none_when_file_absent() {
        let dir = TempDir::new().unwrap();
        assert!(read_status(dir.path()).unwrap().is_none());
    }

    #[test]
    fn write_then_read_round_trips_status() {
        let dir = TempDir::new().unwrap();
        let status = sample("ses_abc", 100);
        write_status(dir.path(), &status).unwrap();
        let loaded = read_status(dir.path()).unwrap().unwrap();
        assert_eq!(loaded, status);
    }

    #[test]
    fn record_compaction_writes_expected_fields() {
        let dir = TempDir::new().unwrap();
        record_compaction(dir.path(), "claude", "ses_xyz", 200).unwrap();
        let loaded = read_status(dir.path()).unwrap().unwrap();
        assert_eq!(loaded.host, "claude");
        assert_eq!(loaded.session_id, "ses_xyz");
        assert!(loaded.compacted);
        assert_eq!(loaded.observed_at_epoch_s, 200);
        assert_eq!(loaded.schema_version, SCHEMA_VERSION);
    }

    #[test]
    fn record_compaction_creates_state_dir_when_absent() {
        let dir = TempDir::new().unwrap();
        let nested = dir.path().join("nested").join("state");
        record_compaction(&nested, "claude", "ses_z", 1).unwrap();
        assert!(nested.is_dir());
        assert!(status_path(&nested).is_file());
    }

    #[test]
    fn clear_for_matching_session_removes_file() {
        let dir = TempDir::new().unwrap();
        write_status(dir.path(), &sample("ses_abc", 100)).unwrap();
        assert!(clear_for_session(dir.path(), "ses_abc").unwrap());
        assert!(read_status(dir.path()).unwrap().is_none());
    }

    #[test]
    fn clear_for_other_session_preserves_file() {
        let dir = TempDir::new().unwrap();
        write_status(dir.path(), &sample("ses_abc", 100)).unwrap();
        assert!(!clear_for_session(dir.path(), "ses_other").unwrap());
        let loaded = read_status(dir.path()).unwrap().unwrap();
        assert_eq!(loaded.session_id, "ses_abc");
    }

    #[test]
    fn clear_when_status_absent_is_noop() {
        let dir = TempDir::new().unwrap();
        assert!(!clear_for_session(dir.path(), "ses_anything").unwrap());
    }

    #[test]
    fn read_rejects_malformed_json() {
        let dir = TempDir::new().unwrap();
        fs::create_dir_all(dir.path()).unwrap();
        fs::write(status_path(dir.path()), "{not json").unwrap();
        let err = read_status(dir.path()).unwrap_err();
        assert!(err.message().contains("invalid status"));
    }

    #[test]
    fn clear_unconditional_removes_file_when_present() {
        let dir = TempDir::new().unwrap();
        write_status(dir.path(), &sample("ses_any", 1)).unwrap();
        assert!(clear_unconditional(dir.path()).unwrap());
        assert!(read_status(dir.path()).unwrap().is_none());
    }

    #[test]
    fn clear_unconditional_returns_false_when_absent() {
        let dir = TempDir::new().unwrap();
        assert!(!clear_unconditional(dir.path()).unwrap());
    }

    #[test]
    fn clear_if_session_changed_removes_when_id_differs() {
        let dir = TempDir::new().unwrap();
        write_status(dir.path(), &sample("ses_old", 1)).unwrap();
        assert!(clear_if_session_changed(dir.path(), "ses_new").unwrap());
        assert!(read_status(dir.path()).unwrap().is_none());
    }

    #[test]
    fn clear_if_session_changed_preserves_when_id_matches() {
        let dir = TempDir::new().unwrap();
        write_status(dir.path(), &sample("ses_same", 1)).unwrap();
        assert!(!clear_if_session_changed(dir.path(), "ses_same").unwrap());
        let loaded = read_status(dir.path()).unwrap().unwrap();
        assert_eq!(loaded.session_id, "ses_same");
    }

    #[test]
    fn clear_if_session_changed_returns_false_when_absent() {
        let dir = TempDir::new().unwrap();
        assert!(!clear_if_session_changed(dir.path(), "anything").unwrap());
    }

    #[test]
    fn read_rejects_unknown_fields() {
        let dir = TempDir::new().unwrap();
        fs::create_dir_all(dir.path()).unwrap();
        fs::write(
            status_path(dir.path()),
            r#"{"schema_version":"1","host":"claude","session_id":"s","compacted":true,"observed_at_epoch_s":1,"extra":"nope"}"#,
        )
        .unwrap();
        assert!(read_status(dir.path()).is_err());
    }

    #[test]
    fn record_compaction_overwrites_earlier_status_same_session() {
        let dir = TempDir::new().unwrap();
        record_compaction(dir.path(), "claude", "ses_abc", 100).unwrap();
        record_compaction(dir.path(), "claude", "ses_abc", 200).unwrap();
        let loaded = read_status(dir.path()).unwrap().unwrap();
        assert_eq!(loaded.observed_at_epoch_s, 200);
    }
}