lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! `lifeloop event invoke` — drive the host lifecycle pipeline.
//!
//! Reads a [`DispatchEnvelope`] JSON document from stdin (carrying the
//! [`CallbackRequest`] alongside any opaque [`PayloadEnvelope`] bodies
//! to deliver), then runs:
//! 1. router validation + adapter resolution (`router::route`)
//! 2. capability/placement negotiation (`router::negotiate` with an
//!    empty default `CapabilityRequest` and the envelope's payloads)
//! 3. subprocess callback against `--client-cmd` (with optional
//!    `--client-arg`s and `--timeout-ms`); the same payloads flow into
//!    the invoker so subprocess clients can reach them through the
//!    transport-boundary [`DispatchEnvelope`].
//! 4. receipt synthesis (`LifeloopReceiptEmitter::synthesize_and_emit`)
//!
//! Emits the synthesized `LifecycleReceipt` JSON on stdout. Failure
//! paths (router error, subprocess error, receipt error) all become
//! validation-class CLI errors so callers can distinguish "ran but the
//! contract said no" from "I couldn't read your input".

use std::time::Duration;

use lifeloop::router::{
    BuiltinAdapterRegistry, CapabilityRequest, LifeloopReceiptEmitter, ReceiptContext,
    SubprocessCallbackInvoker, SubprocessInvokerConfig, negotiate, route,
};
use lifeloop::{CallbackResponse, DispatchEnvelope};

use super::{CliError, parse_stdin_json, print_json};

/// Top-level entry: `lifeloop event <action> ...`.
pub fn run<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
    let action = args
        .next()
        .ok_or_else(|| CliError::Usage("event requires a subcommand: invoke".to_string()))?;
    match action.as_str() {
        "invoke" => run_invoke(args),
        other => Err(CliError::Usage(format!(
            "event: unknown subcommand `{other}` (expected: invoke)"
        ))),
    }
}

#[derive(Debug)]
struct InvokeArgs {
    client_cmd: Option<String>,
    client_args: Vec<String>,
    timeout_ms: u64,
    client_id: String,
    receipt_id: String,
    at_epoch_s: u64,
    in_process: bool,
}

impl InvokeArgs {
    fn parse<I: Iterator<Item = String>>(mut args: I) -> Result<Self, CliError> {
        let mut parsed = Self {
            client_cmd: None,
            client_args: Vec::new(),
            timeout_ms: 5_000,
            client_id: "lifeloop-cli".to_string(),
            receipt_id: "rcpt-cli".to_string(),
            at_epoch_s: 0,
            in_process: false,
        };

        while let Some(arg) = args.next() {
            match arg.as_str() {
                "--client-cmd" => {
                    parsed.client_cmd = Some(require_value(&arg, args.next())?);
                }
                "--client-arg" => {
                    parsed.client_args.push(require_value(&arg, args.next())?);
                }
                "--timeout-ms" => {
                    let value = require_value(&arg, args.next())?;
                    parsed.timeout_ms = parse_u64_flag("--timeout-ms", &value)?;
                }
                "--client-id" => {
                    parsed.client_id = require_value(&arg, args.next())?;
                }
                "--receipt-id" => {
                    parsed.receipt_id = require_value(&arg, args.next())?;
                }
                "--at-epoch-s" => {
                    let value = require_value(&arg, args.next())?;
                    parsed.at_epoch_s = parse_u64_flag("--at-epoch-s", &value)?;
                }
                "--in-process" => {
                    parsed.in_process = true;
                }
                other => {
                    return Err(CliError::Usage(format!(
                        "event invoke: unknown flag `{other}`"
                    )));
                }
            }
        }

        if !parsed.in_process && parsed.client_cmd.is_none() {
            return Err(CliError::Usage(
                "event invoke: --client-cmd <path> is required (or pass --in-process)".into(),
            ));
        }

        Ok(parsed)
    }

    fn receipt_context(&self, request: &lifeloop::CallbackRequest) -> ReceiptContext {
        ReceiptContext {
            client_id: self.client_id.clone(),
            receipt_id: self.receipt_id.clone(),
            parent_receipt_id: None,
            at_epoch_s: self.at_epoch_s,
            harness_session_id: request.harness_session_id.clone(),
            harness_run_id: request.harness_run_id.clone(),
            harness_task_id: request.harness_task_id.clone(),
        }
    }

    fn invoke_client(
        &self,
        plan: &lifeloop::router::RoutingPlan,
        payloads: &[lifeloop::PayloadEnvelope],
    ) -> Result<CallbackResponse, CliError> {
        if self.in_process {
            let response = CallbackResponse::ok(lifeloop::ReceiptStatus::Delivered);
            response.validate().map_err(|e| {
                CliError::Validation(format!("in-process response failed validation: {e}"))
            })?;
            return Ok(response);
        }

        let mut config = SubprocessInvokerConfig::new(
            self.client_cmd.as_ref().expect("checked by parse"),
            Duration::from_millis(self.timeout_ms),
        );
        config = config.args(self.client_args.iter().cloned());
        let invoker = SubprocessCallbackInvoker::new(config);

        use lifeloop::router::CallbackInvoker;
        invoker
            .invoke(plan, payloads)
            .map_err(|e| CliError::Validation(format!("subprocess callback failed: {e}")))
    }
}

fn run_invoke<I: Iterator<Item = String>>(args: I) -> Result<(), CliError> {
    let args = InvokeArgs::parse(args)?;
    let envelope: DispatchEnvelope = parse_stdin_json("DispatchEnvelope")?;
    envelope
        .validate()
        .map_err(|e| CliError::Validation(format!("DispatchEnvelope failed validation: {e}")))?;

    let registry = BuiltinAdapterRegistry;
    let plan = route(&envelope.request, &registry)
        .map_err(|e| CliError::Validation(format!("router rejected request: {e}")))?;

    // Negotiate against an empty capability request: the CLI doesn't
    // know what the caller wants beyond what's on the envelope. The
    // dispatch envelope's payloads feed real placement decisions
    // (issue #22) — passing the empty slice here would silently skip
    // payload-bearing negotiation.
    let cap_request = CapabilityRequest::new();
    let negotiated = negotiate(&plan, &cap_request, envelope.payloads.as_slice());

    let response = if negotiated.blocks_dispatch() {
        // Synthesize a receipt for the blocked path so the caller still
        // gets a structured result rather than just an error.
        CallbackResponse::ok(lifeloop::ReceiptStatus::Failed)
    } else {
        args.invoke_client(&plan, envelope.payloads.as_slice())?
    };

    let ctx = args.receipt_context(&envelope.request);
    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&negotiated, &response, &ctx)
        .map_err(|e| CliError::Validation(format!("receipt emission failed: {e}")))?;
    print_json(&receipt)
}

fn parse_u64_flag(flag: &str, value: &str) -> Result<u64, CliError> {
    value
        .parse::<u64>()
        .map_err(|e| CliError::Usage(format!("{flag} must be a non-negative integer: {e}")))
}
fn require_value(flag: &str, value: Option<String>) -> Result<String, CliError> {
    value.ok_or_else(|| CliError::Usage(format!("flag `{flag}` requires a value")))
}