lifeloop-cli 0.3.1

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! `lifeloop renewal status` — inspect token-safe renewal automation state.

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

use lifeloop::{
    FailureClass, RenewalAutomationState, RenewalAutomationStatus, RetryClass, SCHEMA_VERSION,
};
use serde_json::Value;

use super::{CliError, print_json};

pub const STATUS_FILE: &str = "renewal-status.json";

#[derive(Debug)]
struct RenewalStatusArgs {
    path: PathBuf,
    state_dir: Option<PathBuf>,
    host: String,
    client_id: String,
}

pub fn run<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
    let action = args
        .next()
        .ok_or_else(|| CliError::Usage("renewal requires a subcommand: status".to_string()))?;
    match action.as_str() {
        "status" => {
            let args = RenewalStatusArgs::parse(args)?;
            let status = current_status(
                &args.path,
                args.state_dir.as_deref(),
                &args.host,
                &args.client_id,
                epoch_s(),
            )?;
            print_json(&status)
        }
        other => Err(CliError::Usage(format!(
            "renewal: unknown subcommand `{other}` (expected: status)"
        ))),
    }
}

impl RenewalStatusArgs {
    fn parse<I: Iterator<Item = String>>(mut args: I) -> Result<Self, CliError> {
        let mut parsed = Self {
            path: PathBuf::from("."),
            state_dir: None,
            host: String::new(),
            client_id: String::new(),
        };

        while let Some(arg) = args.next() {
            match arg.as_str() {
                "--path" => parsed.path = PathBuf::from(require_value(&arg, args.next())?),
                "--state-dir" => {
                    parsed.state_dir = Some(PathBuf::from(require_value(&arg, args.next())?));
                }
                "--host" => parsed.host = require_value(&arg, args.next())?,
                "--client-id" => parsed.client_id = require_value(&arg, args.next())?,
                other => {
                    return Err(CliError::Usage(format!(
                        "renewal status: unknown flag `{other}`"
                    )));
                }
            }
        }

        if parsed.host.is_empty() {
            return Err(CliError::Usage(
                "renewal status: --host <id> is required".into(),
            ));
        }
        if parsed.client_id.is_empty() {
            return Err(CliError::Usage(
                "renewal status: --client-id <id> is required".into(),
            ));
        }
        Ok(parsed)
    }
}

pub fn write_status(state_dir: &Path, status: &RenewalAutomationStatus) -> Result<(), CliError> {
    status
        .validate()
        .map_err(|err| CliError::Validation(format!("renewal status failed validation: {err}")))?;
    fs::create_dir_all(state_dir).map_err(|err| {
        CliError::Input(format!(
            "renewal status: failed to create state dir {}: {err}",
            state_dir.display()
        ))
    })?;
    let json = serde_json::to_string_pretty(status).map_err(|err| {
        CliError::Input(format!("renewal status: failed to serialize status: {err}"))
    })?;
    fs::write(status_path(state_dir), json).map_err(|err| {
        CliError::Input(format!(
            "renewal status: failed to write status {}: {err}",
            status_path(state_dir).display()
        ))
    })
}

pub fn current_status(
    path: &Path,
    state_dir: Option<&Path>,
    adapter_id: &str,
    client_id: &str,
    now: u64,
) -> Result<RenewalAutomationStatus, CliError> {
    let state_dir = state_dir
        .map(Path::to_path_buf)
        .unwrap_or_else(|| default_state_dir(path));
    let status_path = status_path(&state_dir);
    let pending_path = pending_path(&state_dir, client_id)?;
    let pending_exists = pending_path.is_file();

    let mut status = if status_path.is_file() {
        read_status(&status_path)?
    } else if pending_exists {
        status_from_pending_file(&pending_path, adapter_id, client_id, now)?
    } else {
        base_status(
            RenewalAutomationState::NotAttempted,
            adapter_id,
            client_id,
            now,
            "renewal automation has not recorded a lifecycle decision",
        )
    };

    if status.adapter_id != adapter_id {
        return Err(CliError::Validation(format!(
            "renewal status adapter `{}` does not match requested host `{adapter_id}`",
            status.adapter_id
        )));
    }
    if status.client_id != client_id {
        return Err(CliError::Validation(format!(
            "renewal status client `{}` does not match requested client `{client_id}`",
            status.client_id
        )));
    }

    status.pending_token_present = pending_exists;
    if pending_exists {
        status.pending_path = Some(pending_path.display().to_string());
    } else if matches!(status.state, RenewalAutomationState::PendingContinuation) {
        status.state = RenewalAutomationState::Failed;
        status.failure_class = Some(FailureClass::StateConflict);
        status.retry_class = Some(RetryClass::RetryAfterReread);
        status.message = Some(
            "pending renewal status exists, but the continuation token file is missing".into(),
        );
        status.pending_path = None;
    }

    status
        .validate()
        .map_err(|err| CliError::Validation(format!("renewal status failed validation: {err}")))?;
    Ok(status)
}

pub fn base_status(
    state: RenewalAutomationState,
    adapter_id: &str,
    client_id: &str,
    now: u64,
    message: &str,
) -> RenewalAutomationStatus {
    RenewalAutomationStatus {
        schema_version: SCHEMA_VERSION.to_string(),
        state,
        client_id: client_id.to_string(),
        adapter_id: adapter_id.to_string(),
        updated_at_epoch_s: now,
        pending_token_present: false,
        reset_path: None,
        thread_id: None,
        renewal_lease_id: None,
        prepared_at_epoch_s: None,
        fulfilled_at_epoch_s: None,
        pending_path: None,
        reset_prepare_receipt_path: None,
        failure_class: None,
        retry_class: None,
        message: Some(message.to_string()),
    }
}

pub fn status_path(state_dir: &Path) -> PathBuf {
    state_dir.join(STATUS_FILE)
}

pub fn pending_path(state_dir: &Path, client_id: &str) -> Result<PathBuf, CliError> {
    Ok(state_dir.join(pending_file_name(client_id)?))
}

pub fn pending_file_name(client_id: &str) -> Result<String, CliError> {
    if !client_id
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
        || client_id.is_empty()
    {
        return Err(CliError::Usage(
            "renewal status: --client-id must contain only ASCII letters, digits, '-', '_', or '.'"
                .into(),
        ));
    }
    Ok(format!("{client_id}-renewal-pending.json"))
}

pub fn default_state_dir(path: &Path) -> PathBuf {
    for candidate in path.ancestors() {
        let git_dir = candidate.join(".git");
        if git_dir.is_dir() {
            return git_dir.join("lifeloop").join("renewal");
        }
    }
    path.join(".lifeloop").join("renewal")
}

fn read_status(path: &Path) -> Result<RenewalAutomationStatus, CliError> {
    let raw = fs::read_to_string(path).map_err(|err| {
        CliError::Input(format!(
            "renewal status: failed to read status {}: {err}",
            path.display()
        ))
    })?;
    serde_json::from_str(&raw).map_err(|err| {
        CliError::Input(format!(
            "renewal status: invalid status {}: {err}",
            path.display()
        ))
    })
}

fn status_from_pending_file(
    path: &Path,
    adapter_id: &str,
    client_id: &str,
    now: u64,
) -> Result<RenewalAutomationStatus, CliError> {
    let raw = fs::read_to_string(path).map_err(|err| {
        CliError::Input(format!(
            "renewal status: failed to read pending renewal {}: {err}",
            path.display()
        ))
    })?;
    let pending: Value = serde_json::from_str(&raw).map_err(|err| {
        CliError::Input(format!(
            "renewal status: invalid pending renewal {}: {err}",
            path.display()
        ))
    })?;
    let mut status = base_status(
        RenewalAutomationState::PendingContinuation,
        json_str(&pending, "adapter_id").unwrap_or(adapter_id),
        client_id,
        now,
        "pending continuation token is stored out of band",
    );
    status.pending_token_present = true;
    status.pending_path = Some(path.display().to_string());
    status.reset_path = json_string(&pending, "reset_path");
    status.thread_id = json_string(&pending, "thread_id");
    status.renewal_lease_id = json_string(&pending, "renewal_lease_id");
    status.prepared_at_epoch_s = pending.get("prepared_at_epoch_s").and_then(Value::as_u64);
    status.reset_prepare_receipt_path = json_string(&pending, "reset_prepare_receipt_path");
    Ok(status)
}

fn json_string(value: &Value, key: &str) -> Option<String> {
    json_str(value, key).map(str::to_owned)
}

fn json_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
    value
        .get(key)
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
}

pub fn epoch_s() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

fn require_value(flag: &str, value: Option<String>) -> Result<String, CliError> {
    value.ok_or_else(|| CliError::Usage(format!("flag `{flag}` requires a value")))
}