lifeloop-cli 0.1.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)"
        ))),
    }
}

fn run_invoke<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
    let mut client_cmd: Option<String> = None;
    let mut client_args: Vec<String> = Vec::new();
    let mut timeout_ms: u64 = 5_000;
    let mut client_id: String = "lifeloop-cli".to_string();
    let mut receipt_id: String = "rcpt-cli".to_string();
    let mut at_epoch_s: u64 = 0;
    // `--in-process` short-circuits the subprocess call and treats the
    // request as a no-op delivered response; useful for tests that want
    // to exercise the dispatch+receipt path without a child process.
    let mut in_process = false;

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--client-cmd" => {
                client_cmd = Some(require_value(&arg, args.next())?);
            }
            "--client-arg" => {
                client_args.push(require_value(&arg, args.next())?);
            }
            "--timeout-ms" => {
                let v = require_value(&arg, args.next())?;
                timeout_ms = v.parse::<u64>().map_err(|e| {
                    CliError::Usage(format!("--timeout-ms must be a non-negative integer: {e}"))
                })?;
            }
            "--client-id" => {
                client_id = require_value(&arg, args.next())?;
            }
            "--receipt-id" => {
                receipt_id = require_value(&arg, args.next())?;
            }
            "--at-epoch-s" => {
                let v = require_value(&arg, args.next())?;
                at_epoch_s = v.parse::<u64>().map_err(|e| {
                    CliError::Usage(format!("--at-epoch-s must be a non-negative integer: {e}"))
                })?;
            }
            "--in-process" => {
                in_process = true;
            }
            other => {
                return Err(CliError::Usage(format!(
                    "event invoke: unknown flag `{other}`"
                )));
            }
        }
    }

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

    let envelope: DispatchEnvelope = parse_stdin_json("DispatchEnvelope")?;
    envelope
        .validate()
        .map_err(|e| CliError::Validation(format!("DispatchEnvelope failed validation: {e}")))?;
    let request = &envelope.request;
    let payloads = envelope.payloads.as_slice();

    let registry = BuiltinAdapterRegistry;
    let plan = route(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, payloads);

    if negotiated.blocks_dispatch() {
        // Synthesize a receipt for the blocked path so the caller still
        // gets a structured result rather than just an error.
        let response = CallbackResponse::ok(lifeloop::ReceiptStatus::Failed);
        let ctx = ReceiptContext {
            client_id: client_id.clone(),
            receipt_id: receipt_id.clone(),
            parent_receipt_id: None,
            at_epoch_s,
            harness_session_id: envelope.request.harness_session_id.clone(),
            harness_run_id: envelope.request.harness_run_id.clone(),
            harness_task_id: envelope.request.harness_task_id.clone(),
        };
        let emitter = LifeloopReceiptEmitter::in_memory();
        // The receipt emitter rejects this combination if the response
        // body says success but negotiation says blocked; we lean on
        // status derivation in the emitter to produce a `Failed`
        // receipt with the appropriate failure_class.
        let receipt = emitter
            .synthesize_and_emit(&negotiated, &response, &ctx)
            .map_err(|e| CliError::Validation(format!("receipt emission failed: {e}")))?;
        return print_json(&receipt);
    }

    let response = if in_process {
        // Pure echo client: build a default delivered response. Useful
        // for exercising the dispatch+receipt path without spawning a
        // child process. The receipt is built off the negotiated plan,
        // not the synthesized request, so this is sufficient.
        let resp = CallbackResponse::ok(lifeloop::ReceiptStatus::Delivered);
        resp.validate().map_err(|e| {
            CliError::Validation(format!("in-process response failed validation: {e}"))
        })?;
        resp
    } else {
        let cmd = client_cmd.expect("checked above");
        let mut config = SubprocessInvokerConfig::new(cmd, Duration::from_millis(timeout_ms));
        for ca in client_args {
            config = config.arg(ca);
        }
        let invoker = SubprocessCallbackInvoker::new(config);
        // Use the trait's invoke directly so the same code path that
        // production callers exercise is what the CLI runs. The
        // dispatch envelope's payloads flow through the invoker so
        // subprocess clients see them on stdin.
        use lifeloop::router::CallbackInvoker;
        invoker
            .invoke(&plan, payloads)
            .map_err(|e| CliError::Validation(format!("subprocess callback failed: {e}")))?
    };

    let ctx = ReceiptContext {
        client_id,
        receipt_id,
        parent_receipt_id: None,
        at_epoch_s,
        harness_session_id: envelope.request.harness_session_id.clone(),
        harness_run_id: envelope.request.harness_run_id.clone(),
        harness_task_id: envelope.request.harness_task_id.clone(),
    };
    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 require_value(flag: &str, value: Option<String>) -> Result<String, CliError> {
    value.ok_or_else(|| CliError::Usage(format!("flag `{flag}` requires a value")))
}