runx-cli 0.6.19

Cargo-installed launcher for the runx governed agent workflow CLI.
Documentation
use std::io::{self, Write};
use std::path::Path;
use std::process::ExitCode;

use runx_contracts::{JsonObject, JsonValue};

pub(super) fn write_skill_output(
    value: &JsonValue,
    json: bool,
    exit_code: ExitCode,
    resume: SkillOutputResume<'_>,
) -> ExitCode {
    if !json {
        return write_text_with_exit(value, exit_code, resume);
    }
    write_json_with_exit(value, exit_code)
}

#[derive(Clone, Copy)]
pub(super) struct SkillOutputResume<'a> {
    pub(super) skill_ref: Option<&'a str>,
    pub(super) selected_runner: Option<&'a str>,
    pub(super) receipt_dir: Option<&'a Path>,
    pub(super) answers_path: Option<&'a Path>,
}

pub(super) fn skill_result_exit_code(value: &JsonValue) -> ExitCode {
    match value {
        JsonValue::Object(object) => match object.get("status") {
            Some(JsonValue::String(status)) if status == "needs_agent" => ExitCode::from(2),
            _ => ExitCode::SUCCESS,
        },
        _ => ExitCode::SUCCESS,
    }
}

fn write_json_with_exit(value: &JsonValue, exit_code: ExitCode) -> ExitCode {
    match serde_json::to_string_pretty(value) {
        Ok(json) => {
            let mut stdout = io::stdout().lock();
            let result = stdout
                .write_all(json.as_bytes())
                .and_then(|_| stdout.write_all(b"\n"));
            match result {
                Ok(()) => exit_code,
                Err(_) => ExitCode::from(1),
            }
        }
        Err(error) => {
            let _ignored = writeln!(
                io::stderr(),
                "runx: failed to serialize skill result: {error}"
            );
            ExitCode::from(1)
        }
    }
}

fn write_text_with_exit(
    value: &JsonValue,
    exit_code: ExitCode,
    resume: SkillOutputResume<'_>,
) -> ExitCode {
    let mut stdout = io::stdout().lock();
    let result = write_skill_text(&mut stdout, value, resume);
    match result {
        Ok(()) => exit_code,
        Err(_) => ExitCode::from(1),
    }
}

fn write_skill_text(
    writer: &mut dyn Write,
    value: &JsonValue,
    resume: SkillOutputResume<'_>,
) -> io::Result<()> {
    let Some(object) = value.as_object() else {
        let text = serde_json::to_string(value).unwrap_or_else(|_| "null".to_owned());
        return writeln!(writer, "{text}");
    };
    writeln!(
        writer,
        "status: {}",
        object_string(object, "status").unwrap_or("unknown")
    )?;
    if let Some(skill_name) = object_string(object, "skill_name") {
        writeln!(writer, "skill: {skill_name}")?;
    }
    if let Some(run_id) = object_string(object, "run_id") {
        writeln!(writer, "run_id: {run_id}")?;
    }
    if let Some(receipt_id) = object_string(object, "receipt_id") {
        writeln!(writer, "receipt_id: {receipt_id}")?;
    }
    if let Some(provenance) = object
        .get("registry_provenance")
        .and_then(JsonValue::as_object)
    {
        writeln!(writer, "registry:")?;
        write_registry_provenance(writer, provenance)?;
    }
    if let Some(summary) = summary_from_payload(object).or_else(|| closure_summary(object)) {
        writeln!(writer, "summary: {summary}")?;
    }
    if let Some(requests) = object.get("requests").and_then(JsonValue::as_array) {
        write_pending_requests(writer, object, requests, resume)?;
    }
    Ok(())
}

fn write_pending_requests(
    writer: &mut dyn Write,
    object: &JsonObject,
    requests: &[JsonValue],
    resume: SkillOutputResume<'_>,
) -> io::Result<()> {
    writeln!(writer, "pending_requests: {}", requests.len())?;
    for request in requests {
        if let Some(request) = request.as_object() {
            let id = object_string(request, "id").unwrap_or("<unknown>");
            let kind = object_string(request, "kind").unwrap_or("<unknown>");
            writeln!(writer, "- {kind}: {id}")?;
        }
    }
    if let Some(template) = answers_template(requests) {
        writeln!(writer, "answers_template:")?;
        write_indented_json(writer, &template)?;
    }
    if let Some(run_id) = object_string(object, "run_id") {
        let command =
            crate::resume::render_skill_resume_command(crate::resume::SkillResumeCommand {
                skill_ref: resume
                    .skill_ref
                    .or_else(|| object_string(object, "skill_name")),
                run_id,
                selected_runner: resume.selected_runner,
                receipt_dir: resume.receipt_dir,
                answers_path: resume.answers_path,
            });
        writeln!(writer, "next: resolve the request, then rerun: {command}")?;
    }
    Ok(())
}

fn answers_template(requests: &[JsonValue]) -> Option<JsonValue> {
    let mut answers = JsonObject::new();
    for request in requests {
        let Some(request) = request.as_object() else {
            continue;
        };
        let Some(id) = object_string(request, "id") else {
            continue;
        };
        answers.insert(id.to_owned(), JsonValue::Object(JsonObject::new()));
    }
    if answers.is_empty() {
        return None;
    }
    Some(JsonValue::Object(JsonObject::from([(
        "answers".to_owned(),
        JsonValue::Object(answers),
    )])))
}

fn write_indented_json(writer: &mut dyn Write, value: &JsonValue) -> io::Result<()> {
    let json = serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_owned());
    for line in json.lines() {
        writeln!(writer, "  {line}")?;
    }
    Ok(())
}

fn write_registry_provenance(writer: &mut dyn Write, object: &JsonObject) -> io::Result<()> {
    for key in [
        "skill_id",
        "version",
        "digest",
        "profile_digest",
        "registry_source",
        "registry_source_fingerprint",
        "trust_tier",
        "registry_key_id",
        "trust_state",
    ] {
        if let Some(value) = object_string(object, key) {
            writeln!(writer, "  {key}: {value}")?;
        }
    }
    Ok(())
}

fn summary_from_payload(object: &JsonObject) -> Option<&str> {
    object
        .get("payload")
        .and_then(JsonValue::as_object)
        .and_then(summary_from_object)
        .or_else(|| {
            object
                .get("execution")
                .and_then(JsonValue::as_object)
                .and_then(|execution| execution.get("structured_output"))
                .and_then(JsonValue::as_object)
                .and_then(summary_from_object)
        })
}

fn closure_summary(object: &JsonObject) -> Option<&str> {
    object
        .get("closure")
        .and_then(JsonValue::as_object)
        .and_then(|closure| object_string(closure, "summary"))
}

fn summary_from_object(object: &JsonObject) -> Option<&str> {
    object_string(object, "summary").or_else(|| {
        object
            .get("forecast_packet")
            .and_then(JsonValue::as_object)
            .and_then(|packet| object_string(packet, "summary"))
    })
}

fn object_string<'a>(object: &'a JsonObject, key: &str) -> Option<&'a str> {
    object.get(key).and_then(JsonValue::as_str)
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use runx_contracts::{JsonObject, JsonValue};

    use super::{SkillOutputResume, write_skill_text};

    #[test]
    fn text_output_prefers_operator_payload_summary_over_receipt_closure() {
        let mut payload = JsonObject::new();
        payload.insert(
            "summary".to_owned(),
            JsonValue::String("Forecast: wet morning, dry commute home.".to_owned()),
        );
        let mut closure = JsonObject::new();
        closure.insert(
            "summary".to_owned(),
            JsonValue::String("agent act closed with closed".to_owned()),
        );
        let mut value = base_result();
        value.insert("payload".to_owned(), JsonValue::Object(payload));
        value.insert("closure".to_owned(), JsonValue::Object(closure));

        let output = render(value);

        assert!(output.contains("summary: Forecast: wet morning, dry commute home."));
        assert!(!output.contains("summary: agent act closed with closed"));
    }

    #[test]
    fn text_output_uses_closure_summary_when_payload_has_no_summary() {
        let mut closure = JsonObject::new();
        closure.insert(
            "summary".to_owned(),
            JsonValue::String("graph nws-weather-forecast completed".to_owned()),
        );
        let mut value = base_result();
        value.insert("closure".to_owned(), JsonValue::Object(closure));

        let output = render(value);

        assert!(output.contains("summary: graph nws-weather-forecast completed"));
    }

    #[test]
    fn text_output_includes_resume_metadata_for_pending_requests() {
        let mut value = base_result();
        value.insert(
            "status".to_owned(),
            JsonValue::String("needs_agent".to_owned()),
        );
        value.insert(
            "requests".to_owned(),
            JsonValue::Array(vec![JsonValue::Object(JsonObject::from([
                ("id".to_owned(), JsonValue::String("request_1".to_owned())),
                ("kind".to_owned(), JsonValue::String("agent_act".to_owned())),
            ]))]),
        );

        let output = render_with_resume(
            value,
            SkillOutputResume {
                skill_ref: Some("registry/weather"),
                selected_runner: Some("operator runner"),
                receipt_dir: Some(Path::new("custom receipts")),
                answers_path: Some(Path::new("operator answers.json")),
            },
        );

        assert!(output.contains(
            "runx resume run_weather 'operator answers.json' --receipt-dir 'custom receipts'"
        ));
        assert!(output.contains("answers_template:"));
        assert!(output.contains(r#""request_1": {}"#));
    }

    fn base_result() -> JsonObject {
        JsonObject::from([
            ("status".to_owned(), JsonValue::String("sealed".to_owned())),
            (
                "skill_name".to_owned(),
                JsonValue::String("weather-forecast".to_owned()),
            ),
            (
                "run_id".to_owned(),
                JsonValue::String("run_weather".to_owned()),
            ),
            (
                "receipt_id".to_owned(),
                JsonValue::String("sha256:abc".to_owned()),
            ),
        ])
    }

    fn render(value: JsonObject) -> String {
        render_with_resume(
            value,
            SkillOutputResume {
                skill_ref: None,
                selected_runner: None,
                receipt_dir: None,
                answers_path: None,
            },
        )
    }

    fn render_with_resume(value: JsonObject, resume: SkillOutputResume<'_>) -> String {
        let mut output = Vec::new();
        let write_result = write_skill_text(&mut output, &JsonValue::Object(value), resume);
        assert!(write_result.is_ok(), "text output renders");
        let rendered = String::from_utf8(output);
        assert!(rendered.is_ok(), "text output is utf8");
        rendered.unwrap_or_default()
    }
}