openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `tail` — live binding metrics + recent activity for the developer loop.
//!
//! v0.1 implementation polls `GET /api/v1/editor/bindings/{id}/metrics`
//! every 2 s and prints whichever fields advanced since the last tick. The
//! upstream `events:recent` endpoint described in PRD §23 is not yet live;
//! this fallback gives the same "did my recent change land?" signal at
//! coarser granularity. When the streaming endpoint is published, this
//! command flips to consuming it (tracked in `docs/api-contracts.md`).

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;

use clap::Args;
use serde_json::json;

use crate::api::bindings as api_bindings;
use crate::api::editor::list_bindings;
use crate::cli::commands::shared;
use crate::cli::GlobalArgs;
use crate::error::OlError;
use crate::ui::output::OutputConfig;

#[derive(Args, Debug)]
pub struct TailArgs {
    /// Filter to a single binding id. Default: stream all owned bindings.
    #[arg(long, value_name = "ID")]
    pub binding: Option<String>,

    /// Print metrics N seconds back before tailing forward.
    #[arg(long, default_value_t = 0, value_name = "N")]
    pub since: u32,

    /// Polling interval (seconds). Default 2.
    #[arg(long, default_value_t = 2, value_name = "SECS")]
    pub interval: u64,
}

pub async fn run(g: &GlobalArgs, args: TailArgs) -> Result<(), OlError> {
    let _ = args.since; // reserved for the streaming endpoint
    let out = OutputConfig::resolve(g);
    let client = shared::make_client().await?;

    let binding_ids: Vec<String> = match args.binding.clone() {
        Some(id) => vec![id],
        None => list_bindings(&client)
            .await?
            .into_iter()
            .map(|r| r.id)
            .collect(),
    };
    if binding_ids.is_empty() {
        out.print_step("No bindings to tail.");
        return Ok(());
    }

    let stop = Arc::new(AtomicBool::new(false));
    install_ctrlc(stop.clone());

    let mut tick: u64 = 0;
    while !stop.load(Ordering::Relaxed) {
        for id in &binding_ids {
            match api_bindings::metrics(&client, id).await {
                Ok(m) => {
                    if out.is_machine() {
                        out.print_json(&json!({
                            "tick": tick,
                            "binding_id": id,
                            "metrics": m,
                        }));
                    } else {
                        let p95 = m.latency_ms.as_ref().and_then(|s| s.p95);
                        let success = m.success_rate_24h;
                        let score = m.score;
                        let parts = vec![
                            p95.map(|n| format!("p95={}ms", n)),
                            success.map(|n| format!("success={:.1}%", n * 100.0)),
                            score.map(|n| format!("score={:.1}", n)),
                        ];
                        let summary = parts.into_iter().flatten().collect::<Vec<_>>().join("  ");
                        let timestamp = chrono::Utc::now().to_rfc3339();
                        println!("{timestamp}  {id}  {summary}");
                    }
                }
                Err(e) if e.code.code == "OL-4234" => {
                    out.print_substep(&format!("binding {id} no longer exists; skipping"));
                }
                Err(e) => {
                    out.print_error(&e);
                }
            }
        }
        tick += 1;
        let interval = Duration::from_secs(args.interval.max(1));
        for _ in 0..interval.as_millis() / 100 {
            if stop.load(Ordering::Relaxed) {
                break;
            }
            tokio::time::sleep(Duration::from_millis(100)).await;
        }
    }

    out.print_step(&format!("Stopped ({tick} ticks)."));
    Ok(())
}

fn install_ctrlc(stop: Arc<AtomicBool>) {
    let s = stop.clone();
    let _ = ctrlc::set_handler(move || {
        s.store(true, Ordering::Relaxed);
    });
}