lifeloop-cli 0.5.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! `lifeloop status` — a read-only workspace lifecycle overview.
//!
//! Consolidates, in one zero-required-args view, the lifecycle state that
//! Lifeloop actually has a producer for:
//!
//! - the built-in **adapter registry** (`manifest_registry`) with a
//!   conformance breakdown — the catalog of harnesses Lifeloop knows how
//!   to normalize;
//! - the workspace's **host compaction signal**
//!   (`host-context-status.json`), which today has no CLI read surface at
//!   all — it is only written by the `on-compaction-notice` host hook; and
//! - the workspace's **renewal automation snapshot**
//!   (`renewal-status.json`), the last recorded renewal lifecycle state.
//!
//! ## Deliberately omitted (no producer)
//!
//! The original "dashboard" idea also imagined attached *clients*, a
//! *recent-events* feed, and *receipt gaps*. Lifeloop is a contract and
//! routing library, not a host: it has no runtime client registry, it does
//! not persist an event log (events flow through the router and are gone),
//! and no code path detects receipt gaps (every adapter manifest reports
//! `receipt_ledger: unavailable`). Surfacing empty panels for those would
//! be a façade, so `status` reports only what has real backing. When a host
//! integration starts persisting events or a ledger, this is where they
//! would surface.
//!
//! Output is pretty JSON on stdout, matching the other read commands in
//! this crate (`manifest list`, `renewal status`). There is no `--output`
//! switch — the global one is reserved for `host-hook`.

use std::collections::BTreeMap;
use std::path::PathBuf;

use serde::Serialize;

use crate::cli::host_context_status::{self, HostContextStatus};
use crate::cli::{CliError, print_json, renewal};
use lifeloop::{ConformanceLevel, RenewalAutomationStatus, manifest_registry};

#[derive(Debug)]
struct StatusArgs {
    /// Workspace root used to resolve the lifecycle state directory.
    path: PathBuf,
    /// Explicit state directory override (wins over `path` resolution),
    /// mirroring `renewal status --state-dir`.
    state_dir: Option<PathBuf>,
}

impl StatusArgs {
    fn parse<I: Iterator<Item = String>>(mut args: I) -> Result<Self, CliError> {
        let mut parsed = Self {
            path: PathBuf::from("."),
            state_dir: None,
        };
        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())?));
                }
                other => {
                    return Err(CliError::Usage(format!("status: unknown flag `{other}`")));
                }
            }
        }
        Ok(parsed)
    }
}

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

pub fn run<I: Iterator<Item = String>>(args: I) -> Result<(), CliError> {
    let args = StatusArgs::parse(args)?;
    let state_dir = args
        .state_dir
        .unwrap_or_else(|| renewal::default_state_dir(&args.path));

    // Reconcile the renewal snapshot through the canonical `current_status`
    // path so this read surface can never disagree with `renewal status`:
    // a stored `pending_continuation` whose token file has since vanished
    // must report as `failed`/`state_conflict`, not as a live pending token.
    // The snapshot carries its own adapter/client id, so reconciliation
    // needs no extra flags. Absent snapshot ⇒ no renewal panel (not a
    // `not_attempted` placeholder, which would imply we looked per-client).
    let renewal = match renewal::read_status_if_present(&state_dir)? {
        Some(snapshot) => Some(renewal::current_status(
            &args.path,
            Some(&state_dir),
            &snapshot.adapter_id,
            &snapshot.client_id,
            renewal::epoch_s(),
        )?),
        None => None,
    };

    let report = StatusReport {
        adapters: AdaptersSummary::from_registry(),
        state_dir: state_dir.display().to_string(),
        host_context: host_context_status::read_status(&state_dir)?,
        renewal,
    };
    print_json(&report)
}

/// `lifeloop status` JSON output.
#[derive(Serialize)]
struct StatusReport {
    adapters: AdaptersSummary,
    /// Workspace lifecycle state directory the snapshots were read from.
    state_dir: String,
    /// Host compaction signal for this workspace. The key is omitted
    /// entirely when none has been recorded (not emitted as `null`).
    #[serde(skip_serializing_if = "Option::is_none")]
    host_context: Option<HostContextStatus>,
    /// Renewal automation state for this workspace; the key is omitted
    /// entirely when no snapshot has been written (not emitted as `null`).
    /// Reconciled against the live pending-token file through the same path
    /// as `renewal status`, so it agrees with that command rather than
    /// echoing a possibly-stale stored snapshot.
    #[serde(skip_serializing_if = "Option::is_none")]
    renewal: Option<RenewalAutomationStatus>,
}

#[derive(Serialize)]
struct AdaptersSummary {
    count: usize,
    /// Conformance label (`v1_conformance` / `pre_conformance`) → count.
    by_conformance: BTreeMap<String, usize>,
    adapters: Vec<AdapterLine>,
}

#[derive(Serialize)]
struct AdapterLine {
    adapter_id: String,
    display_name: String,
    adapter_version: String,
    conformance: ConformanceLevel,
}

impl AdaptersSummary {
    fn from_registry() -> Self {
        let registry = manifest_registry();
        let mut by_conformance: BTreeMap<String, usize> = BTreeMap::new();
        let mut adapters = Vec::with_capacity(registry.len());
        for entry in registry {
            *by_conformance
                .entry(conformance_label(entry.conformance))
                .or_insert(0) += 1;
            adapters.push(AdapterLine {
                adapter_id: entry.manifest.adapter_id,
                display_name: entry.manifest.display_name,
                adapter_version: entry.manifest.adapter_version,
                conformance: entry.conformance,
            });
        }
        Self {
            count: adapters.len(),
            by_conformance,
            adapters,
        }
    }
}

/// The wire label for a [`ConformanceLevel`] — the same snake_case string
/// it serializes to — used as a `by_conformance` map key so the breakdown
/// stays in lockstep with the per-adapter `conformance` field.
fn conformance_label(level: ConformanceLevel) -> String {
    match level {
        ConformanceLevel::V1Conformance => "v1_conformance",
        ConformanceLevel::PreConformance => "pre_conformance",
    }
    .to_string()
}