liminal-cli 0.2.1

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! The service lifecycle tools (SDK-019 R1): `services`, `register`, `start`, `stop`, `retire`.
//!
//! Each is one request on the server's lifecycle subject (SRV-028), sent on
//! the seat's own pass and answered with the server's reply or refusal in its
//! words. The ticket decides nothing about access.

use std::fmt::Write as _;
use std::io::Read;

use serde_json::{Map, Value, json};

use super::Rail;
use super::call::{Outcome, request};
use crate::mcp::tools::ToolResult;

/// The five tools' entries in the tool table.
#[must_use]
pub fn tool_entries() -> Vec<Value> {
    let id = json!({
        "type": "object",
        "required": ["id"],
        "properties": {
            "id": { "type": "string", "description": "the service id, svc-..." },
            "operation": { "type": "string", "description": "an operation id, so a request sent again does nothing twice" }
        },
        "additionalProperties": false
    });
    vec![
        json!({
            "name": "services",
            "description": "The templates services can be made from, and every service this seat created or may call, with its state (asleep, live, held, retired), subjects, whether its process runs, and its history.",
            "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false }
        }),
        json!({
            "name": "register",
            "description": "Create a service from one of the server's templates, named once per template. Lys decides whether this seat may (deploy_service on the network). The service starts asleep and serves <template subject>.<name> once started.",
            "inputSchema": {
                "type": "object",
                "required": ["template", "name"],
                "properties": {
                    "template": { "type": "string" },
                    "name": { "type": "string", "description": "one segment: letters, digits, '-' or '_'" },
                    "operation": { "type": "string" }
                },
                "additionalProperties": false
            }
        }),
        json!({
            "name": "start",
            "description": "Start an asleep or held service's process; it is live once its worker registers. Lys decides (deploy_service on the service).",
            "inputSchema": id
        }),
        json!({
            "name": "stop",
            "description": "Stop a running service and hold it; it can be started again. Lys decides (deploy_service on the service).",
            "inputSchema": id
        }),
        json!({
            "name": "retire",
            "description": "Stop a service for good. It is kept with its history and never started again. Lys decides (deploy_service on the service).",
            "inputSchema": id
        }),
    ]
}

/// Serves `name` when it is a lifecycle tool; `None` otherwise.
pub fn tool<R: Rail>(rail: &R, name: &str, arguments: &Map<String, Value>) -> Option<ToolResult> {
    let text = |field: &str| arguments.get(field).and_then(Value::as_str);
    let operation =
        match text("operation").map_or_else(fresh_operation, |given| Ok(given.to_owned())) {
            Ok(operation) => operation,
            Err(words) => return Some(ToolResult::failed(&words, json!({ "tool": name }))),
        };
    let answer = match name {
        "services" => send(rail, "list", &operation, &Value::Null),
        "register" => match (text("template"), text("name")) {
            (Some(template), Some(service)) => send(
                rail,
                "create",
                &operation,
                &json!({ "template": template, "name": service }),
            ),
            _ => Err("register needs template and name".to_owned()),
        },
        "start" | "stop" | "retire" => text("id").map_or_else(
            || Err(format!("{name} needs id")),
            |id| {
                let body = json!({ "id": id });
                match name {
                    "start" => send(rail, "deploy", &operation, &body).or_else(|refusal| {
                        if refusal.contains("not_now") && refusal.contains("Held") {
                            send(rail, "wake", &operation, &body)
                        } else {
                            Err(refusal)
                        }
                    }),
                    "stop" => send(rail, "hold", &operation, &body),
                    _ => send(rail, "retire", &operation, &body),
                }
            },
        ),
        _ => return None,
    };
    Some(match answer {
        Ok(reply) => ToolResult::ok(reply),
        Err(words) => ToolResult::failed(&words, json!({ "tool": name })),
    })
}

/// One lifecycle request on `services.<verb>`.
fn send<R: Rail>(rail: &R, verb: &str, operation: &str, body: &Value) -> Result<Value, String> {
    let input = json!({ "operation": operation, "payload": body });
    match request(rail, &format!("services.{verb}"), &input, true, None) {
        Outcome::Reply(reply) => Ok(reply),
        Outcome::Sent => Err("the server sent no reply".to_owned()),
        Outcome::Refused(words) => Err(words),
    }
}

/// A new operation id, `op-` and 32 random hex digits; refused by name when
/// no randomness can be read, never made from anything guessable.
fn fresh_operation() -> Result<String, String> {
    let mut bytes = [0_u8; 16];
    std::fs::File::open("/dev/urandom")
        .and_then(|mut source| source.read_exact(&mut bytes))
        .map_err(|error| format!("no operation id could be made: {}", error.kind()))?;
    Ok(bytes.iter().fold(String::from("op-"), |mut out, byte| {
        let _infallible = write!(out, "{byte:02x}");
        out
    }))
}

#[cfg(test)]
#[path = "lifecycle_tests.rs"]
mod tests;