lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! `lifeloop receipt emit|show`.
//!
//! `emit` reads a `{ negotiated_plan: ..., response: ..., context: ... }`
//! envelope from stdin and runs `LifeloopReceiptEmitter::synthesize_and_emit`
//! to produce a `LifecycleReceipt`. To keep the CLI from needing to
//! reconstruct a full `NegotiatedPlan` (which carries non-serializable
//! crate-internal shapes), the wire envelope here is a CLI-only contract:
//!
//! ```json
//! {
//!   "request": <CallbackRequest>,
//!   "response": <CallbackResponse>,
//!   "context": {
//!     "client_id": "...",
//!     "receipt_id": "...",
//!     "at_epoch_s": 0,
//!     "parent_receipt_id": null,
//!     "harness_session_id": null,
//!     "harness_run_id": null,
//!     "harness_task_id": null
//!   }
//! }
//! ```
//!
//! The CLI re-runs `route` + `negotiate` (with an empty capability
//! request) to derive the `NegotiatedPlan` deterministically; this
//! mirrors the `event invoke` pipeline minus the subprocess hop. No
//! new public types are exposed on the lifeloop crate root.
//!
//! `show` reads a `LifecycleReceipt` JSON from stdin, validates it,
//! and re-emits the same JSON pretty-printed. This is the analogue of
//! `envelope echo` for receipts.

use lifeloop::router::{
    BuiltinAdapterRegistry, CapabilityRequest, LifeloopReceiptEmitter, ReceiptContext, negotiate,
    route,
};
use lifeloop::{CallbackRequest, CallbackResponse, LifecycleReceipt};
use serde::Deserialize;

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

#[derive(Deserialize)]
struct EmitInput {
    request: CallbackRequest,
    response: CallbackResponse,
    context: WireReceiptContext,
}

#[derive(Deserialize)]
struct WireReceiptContext {
    client_id: String,
    receipt_id: String,
    at_epoch_s: u64,
    #[serde(default)]
    parent_receipt_id: Option<String>,
    #[serde(default)]
    harness_session_id: Option<String>,
    #[serde(default)]
    harness_run_id: Option<String>,
    #[serde(default)]
    harness_task_id: Option<String>,
}

impl From<WireReceiptContext> for ReceiptContext {
    fn from(w: WireReceiptContext) -> Self {
        Self {
            client_id: w.client_id,
            receipt_id: w.receipt_id,
            parent_receipt_id: w.parent_receipt_id,
            at_epoch_s: w.at_epoch_s,
            harness_session_id: w.harness_session_id,
            harness_run_id: w.harness_run_id,
            harness_task_id: w.harness_task_id,
        }
    }
}

pub fn run<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
    let action = args
        .next()
        .ok_or_else(|| CliError::Usage("receipt requires a subcommand: emit | show".to_string()))?;
    if args.next().is_some() {
        return Err(CliError::Usage(format!(
            "receipt {action}: unexpected extra argument"
        )));
    }
    match action.as_str() {
        "emit" => run_emit(),
        "show" => run_show(),
        other => Err(CliError::Usage(format!(
            "receipt: unknown subcommand `{other}` (expected: emit | show)"
        ))),
    }
}

fn run_emit() -> Result<(), CliError> {
    let input: EmitInput = parse_stdin_json("receipt emit envelope")?;
    input
        .request
        .validate()
        .map_err(|e| CliError::Validation(format!("CallbackRequest failed validation: {e}")))?;
    input
        .response
        .validate()
        .map_err(|e| CliError::Validation(format!("CallbackResponse failed validation: {e}")))?;

    let registry = BuiltinAdapterRegistry;
    let plan = route(&input.request, &registry)
        .map_err(|e| CliError::Validation(format!("router rejected request: {e}")))?;
    let cap_request = CapabilityRequest::new();
    let negotiated = negotiate(&plan, &cap_request, &[]);

    let ctx: ReceiptContext = input.context.into();
    let emitter = LifeloopReceiptEmitter::in_memory();
    let receipt = emitter
        .synthesize_and_emit(&negotiated, &input.response, &ctx)
        .map_err(|e| CliError::Validation(format!("receipt emission failed: {e}")))?;
    print_json(&receipt)
}

fn run_show() -> Result<(), CliError> {
    let receipt: LifecycleReceipt = parse_stdin_json("LifecycleReceipt")?;
    receipt
        .validate()
        .map_err(|e| CliError::Validation(format!("receipt failed validation: {e}")))?;
    print_json(&receipt)
}