lucy-cli 1.14.5

A small local JSONL agent harness
Documentation
use std::collections::hash_map::DefaultHasher;
use std::fs::{self, OpenOptions};
use std::hash::{Hash, Hasher};
use std::io::Write;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::config::{ensure_not_symlink, ensure_private_dir, ensure_private_file, lucy_dir};

#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;

pub(super) fn recover_journals(home: &Path, session_id: &str) -> Result<(), String> {
    let transcript = super::sessions_dir(home).join(format!("{session_id}.jsonl"));
    recover_tail(home, session_id, "session", &transcript, true)?;
    let lifecycle = lucy_dir(home)
        .join("turns")
        .join(format!("{session_id}.jsonl"));
    recover_tail(home, session_id, "turn", &lifecycle, false)
}

fn recover_tail(
    home: &Path,
    session_id: &str,
    journal: &str,
    path: &Path,
    required: bool,
) -> Result<(), String> {
    ensure_not_symlink(path).map_err(|_| format!("{journal} journal is unsafe"))?;
    if !path.exists() {
        return if required {
            Err("session not found".to_owned())
        } else {
            Ok(())
        };
    }
    ensure_private_file(path).map_err(|_| format!("{journal} journal is not private"))?;
    let raw = fs::read(path).map_err(|_| format!("unable to read {journal} journal"))?;
    if raw.is_empty() || raw.last() == Some(&b'\n') {
        return Ok(());
    }
    let Some(last_newline) = raw.iter().rposition(|byte| *byte == b'\n') else {
        return Err(format!("{journal} journal has no complete record"));
    };
    let committed_len = last_newline + 1;
    let tail = &raw[committed_len..];
    write_recovery_evidence(home, session_id, journal, tail)?;

    let mut options = OpenOptions::new();
    options.write(true);
    #[cfg(unix)]
    options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
    let file = options
        .open(path)
        .map_err(|_| format!("unable to recover {journal} journal"))?;
    file.set_len(committed_len as u64)
        .map_err(|_| format!("unable to recover {journal} journal"))?;
    file.sync_data()
        .map_err(|_| format!("unable to checkpoint recovered {journal} journal"))?;
    Ok(())
}

fn write_recovery_evidence(
    home: &Path,
    session_id: &str,
    journal: &str,
    tail: &[u8],
) -> Result<(), String> {
    let directory = lucy_dir(home).join("recovery");
    ensure_not_symlink(&directory)
        .map_err(|_| "session recovery directory is unsafe".to_owned())?;
    ensure_private_dir(&directory)
        .map_err(|_| "unable to secure session recovery directory".to_owned())?;
    let mut hasher = DefaultHasher::new();
    tail.hash(&mut hasher);
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis())
        .unwrap_or(0);
    let path = directory.join(format!(
        "{session_id}-{journal}-{timestamp}-{:016x}.json",
        hasher.finish()
    ));
    let record = serde_json::json!({
        "record": "recovered_trailing_fragment",
        "version": 1,
        "session_id": session_id,
        "journal": journal,
        "bytes": tail.len(),
        "hash": format!("{:016x}", hasher.finish()),
    });
    let mut encoded = serde_json::to_vec(&record)
        .map_err(|_| "unable to encode session recovery evidence".to_owned())?;
    encoded.push(b'\n');
    let mut options = OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    options.mode(0o600);
    let mut file = options
        .open(path)
        .map_err(|_| "unable to create session recovery evidence".to_owned())?;
    file.write_all(&encoded)
        .map_err(|_| "unable to write session recovery evidence".to_owned())?;
    file.sync_data()
        .map_err(|_| "unable to checkpoint session recovery evidence".to_owned())?;
    Ok(())
}