openlatch-client 0.6.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! `openlatch system aip refresh` — request one immediate daemon-owned bundle poll.

use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::AipCommands;
use crate::daemon::policy_poller::{PolicyRefreshOutcome, PolicyRefreshResult};
use crate::error::{OlError, ERR_BUNDLE_FETCH_FAILED, ERR_INVALID_CONFIG};

const DAEMON_WAIT: std::time::Duration = std::time::Duration::from_secs(17);

pub fn run(cmd: &AipCommands, output: &OutputConfig) -> Result<(), OlError> {
    match cmd {
        AipCommands::Refresh => refresh(output),
    }
}

fn refresh(output: &OutputConfig) -> Result<(), OlError> {
    let cfg = crate::config::Config::load(None, None, false)?;
    let token_path = crate::config::openlatch_dir().join("daemon.token");
    let token = std::fs::read_to_string(&token_path).map_err(|error| {
        OlError::new(
            ERR_INVALID_CONFIG,
            format!("Cannot read daemon.token: {error}"),
        )
        .with_suggestion("Run `openlatch init` to restore the local daemon credentials.")
    })?;
    let response = crate::egress::blocking_client()
        .post(format!(
            "http://127.0.0.1:{}/admin/policy/refresh",
            cfg.port
        ))
        .bearer_auth(token.trim())
        .timeout(DAEMON_WAIT)
        .send()
        .map_err(|error| {
            OlError::new(
                ERR_BUNDLE_FETCH_FAILED,
                format!("Cannot reach the running daemon to refresh AIP policy: {error}"),
            )
            .with_suggestion("Start the daemon with `openlatch start`, then retry.")
        })?;

    let status = response.status();
    let body = response.json::<serde_json::Value>().map_err(|error| {
        OlError::new(
            ERR_BUNDLE_FETCH_FAILED,
            format!("The daemon returned an unreadable AIP refresh result: {error}"),
        )
        .with_suggestion("Run `openlatch doctor`, then restart the daemon.")
    })?;
    if !status.is_success() {
        return Err(daemon_error(status.as_u16(), &body));
    }
    let result: PolicyRefreshResult = serde_json::from_value(body).map_err(|error| {
        OlError::new(
            ERR_BUNDLE_FETCH_FAILED,
            format!("The daemon returned an invalid AIP refresh result: {error}"),
        )
        .with_suggestion("Update and restart the daemon so the CLI and daemon versions match.")
    })?;

    render(&result, output);
    let exit = outcome_exit_code(result.outcome);
    if exit != 0 {
        crate::cli::report::record_exit_code(exit);
    }
    Ok(())
}

fn outcome_exit_code(outcome: PolicyRefreshOutcome) -> i32 {
    match outcome {
        PolicyRefreshOutcome::Changed | PolicyRefreshOutcome::Unchanged => 0,
        PolicyRefreshOutcome::Pending => crate::cli::report::EXIT_DEGRADED,
        PolicyRefreshOutcome::Refused | PolicyRefreshOutcome::Offline => 1,
    }
}

fn daemon_error(status: u16, body: &serde_json::Value) -> OlError {
    let error = body.get("error").unwrap_or(body);
    let message = error
        .get("message")
        .and_then(serde_json::Value::as_str)
        .map(str::to_owned)
        .unwrap_or_else(|| format!("AIP refresh failed with daemon HTTP {status}"));
    let mut result = OlError::new(ERR_BUNDLE_FETCH_FAILED, message);
    if let Some(suggestion) = error.get("suggestion").and_then(serde_json::Value::as_str) {
        result = result.with_suggestion(suggestion);
    }
    result
}

fn render(result: &PolicyRefreshResult, output: &OutputConfig) {
    if output.format == OutputFormat::Json {
        output.print_json(result);
        return;
    }
    let headline = match result.outcome {
        PolicyRefreshOutcome::Changed => "AIP bundle refreshed — changed",
        PolicyRefreshOutcome::Unchanged if result.response_status == Some(304) => {
            "AIP bundle refreshed — unchanged (304)"
        }
        PolicyRefreshOutcome::Unchanged => {
            "AIP bundle refreshed — verified download matches the resident bundle"
        }
        PolicyRefreshOutcome::Pending => "AIP bundle refresh pending",
        PolicyRefreshOutcome::Refused => "AIP bundle refresh refused",
        PolicyRefreshOutcome::Offline => "AIP bundle refresh unavailable",
    };
    output.print_step(headline);
    if let Some(reason) = result.reason.as_deref() {
        output.print_substep(&format!("Reason: {reason}"));
    }
    if result.has_bundle {
        output.print_substep(&format!(
            "Resident: selected {}, schema {}, bundle {}",
            display(result.selected_version),
            display(result.schema_version),
            display(result.bundle_revision)
        ));
        output.print_substep(&format!(
            "Digest: {}",
            result.digest.as_deref().unwrap_or("unknown")
        ));
        output.print_substep(&format!(
            "Verified body received: {}",
            result
                .verified_body_received_at
                .as_deref()
                .unwrap_or("unknown")
        ));
        match result.resident_aip_count {
            Some(count) => output.print_substep(&format!("Resident AIPs: {count}")),
            None => output.print_substep("Resident AIPs: unknown for this bundle"),
        }
    } else {
        output.print_substep("Resident: none — no AIP bundle is currently enforcing");
    }
    output.print_substep(&format!(
        "Last platform contact: {}",
        result
            .last_platform_contact_at
            .as_deref()
            .unwrap_or("never")
    ));
    if matches!(
        result.outcome,
        PolicyRefreshOutcome::Refused | PolicyRefreshOutcome::Offline
    ) {
        output.print_notice("Run `openlatch doctor` for the cause and remedy.");
    }
}

fn display<T: std::fmt::Display>(value: Option<T>) -> String {
    value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
}

#[cfg(test)]
mod tests {
    use clap::Parser;

    use super::*;

    #[test]
    fn update_is_a_visible_alias_of_refresh() {
        for verb in ["refresh", "update"] {
            let cli = crate::cli::Cli::try_parse_from(["openlatch", "system", "aip", verb])
                .expect("command parses");
            assert!(matches!(
                cli.command,
                Some(crate::cli::Commands::System {
                    cmd: crate::cli::SystemCommands::Aip {
                        cmd: crate::cli::AipCommands::Refresh
                    }
                })
            ));
        }
    }

    #[test]
    fn daemon_error_preserves_structured_remedy() {
        let error = daemon_error(
            503,
            &serde_json::json!({
                "error": {
                    "code": ERR_BUNDLE_FETCH_FAILED,
                    "message": "poller unavailable",
                    "suggestion": "restart"
                }
            }),
        );
        assert_eq!(error.code, ERR_BUNDLE_FETCH_FAILED);
        assert_eq!(error.message, "poller unavailable");
        assert_eq!(error.suggestion.as_deref(), Some("restart"));
    }

    #[test]
    fn refresh_outcomes_have_scriptable_exit_codes() {
        assert_eq!(outcome_exit_code(PolicyRefreshOutcome::Changed), 0);
        assert_eq!(outcome_exit_code(PolicyRefreshOutcome::Unchanged), 0);
        assert_eq!(
            outcome_exit_code(PolicyRefreshOutcome::Pending),
            crate::cli::report::EXIT_DEGRADED
        );
        assert_eq!(outcome_exit_code(PolicyRefreshOutcome::Refused), 1);
        assert_eq!(outcome_exit_code(PolicyRefreshOutcome::Offline), 1);
    }
}