Skip to main content

liminal_cli/network/
lifecycle.rs

1//! The service lifecycle tools (SDK-019 R1): `services`, `register`, `start`, `stop`, `retire`.
2//!
3//! Each is one request on the server's lifecycle subject (SRV-028), sent on
4//! the seat's own pass and answered with the server's reply or refusal in its
5//! words. The ticket decides nothing about access.
6
7use std::fmt::Write as _;
8use std::io::Read;
9
10use serde_json::{Map, Value, json};
11
12use super::Rail;
13use super::call::{Outcome, request};
14use crate::mcp::tools::ToolResult;
15
16/// The five tools' entries in the tool table.
17#[must_use]
18pub fn tool_entries() -> Vec<Value> {
19    let id = json!({
20        "type": "object",
21        "required": ["id"],
22        "properties": {
23            "id": { "type": "string", "description": "the service id, svc-..." },
24            "operation": { "type": "string", "description": "an operation id, so a request sent again does nothing twice" }
25        },
26        "additionalProperties": false
27    });
28    vec![
29        json!({
30            "name": "services",
31            "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.",
32            "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false }
33        }),
34        json!({
35            "name": "register",
36            "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.",
37            "inputSchema": {
38                "type": "object",
39                "required": ["template", "name"],
40                "properties": {
41                    "template": { "type": "string" },
42                    "name": { "type": "string", "description": "one segment: letters, digits, '-' or '_'" },
43                    "operation": { "type": "string" }
44                },
45                "additionalProperties": false
46            }
47        }),
48        json!({
49            "name": "start",
50            "description": "Start an asleep or held service's process; it is live once its worker registers. Lys decides (deploy_service on the service).",
51            "inputSchema": id
52        }),
53        json!({
54            "name": "stop",
55            "description": "Stop a running service and hold it; it can be started again. Lys decides (deploy_service on the service).",
56            "inputSchema": id
57        }),
58        json!({
59            "name": "retire",
60            "description": "Stop a service for good. It is kept with its history and never started again. Lys decides (deploy_service on the service).",
61            "inputSchema": id
62        }),
63    ]
64}
65
66/// Serves `name` when it is a lifecycle tool; `None` otherwise.
67pub fn tool<R: Rail>(rail: &R, name: &str, arguments: &Map<String, Value>) -> Option<ToolResult> {
68    let text = |field: &str| arguments.get(field).and_then(Value::as_str);
69    let operation =
70        match text("operation").map_or_else(fresh_operation, |given| Ok(given.to_owned())) {
71            Ok(operation) => operation,
72            Err(words) => return Some(ToolResult::failed(&words, json!({ "tool": name }))),
73        };
74    let answer = match name {
75        "services" => send(rail, "list", &operation, &Value::Null),
76        "register" => match (text("template"), text("name")) {
77            (Some(template), Some(service)) => send(
78                rail,
79                "create",
80                &operation,
81                &json!({ "template": template, "name": service }),
82            ),
83            _ => Err("register needs template and name".to_owned()),
84        },
85        "start" | "stop" | "retire" => text("id").map_or_else(
86            || Err(format!("{name} needs id")),
87            |id| {
88                let body = json!({ "id": id });
89                match name {
90                    "start" => send(rail, "deploy", &operation, &body).or_else(|refusal| {
91                        if refusal.contains("not_now") && refusal.contains("Held") {
92                            send(rail, "wake", &operation, &body)
93                        } else {
94                            Err(refusal)
95                        }
96                    }),
97                    "stop" => send(rail, "hold", &operation, &body),
98                    _ => send(rail, "retire", &operation, &body),
99                }
100            },
101        ),
102        _ => return None,
103    };
104    Some(match answer {
105        Ok(reply) => ToolResult::ok(reply),
106        Err(words) => ToolResult::failed(&words, json!({ "tool": name })),
107    })
108}
109
110/// One lifecycle request on `services.<verb>`.
111fn send<R: Rail>(rail: &R, verb: &str, operation: &str, body: &Value) -> Result<Value, String> {
112    let input = json!({ "operation": operation, "payload": body });
113    match request(rail, &format!("services.{verb}"), &input, true, None) {
114        Outcome::Reply(reply) => Ok(reply),
115        Outcome::Sent => Err("the server sent no reply".to_owned()),
116        Outcome::Refused(words) => Err(words),
117    }
118}
119
120/// A new operation id, `op-` and 32 random hex digits; refused by name when
121/// no randomness can be read, never made from anything guessable.
122fn fresh_operation() -> Result<String, String> {
123    let mut bytes = [0_u8; 16];
124    std::fs::File::open("/dev/urandom")
125        .and_then(|mut source| source.read_exact(&mut bytes))
126        .map_err(|error| format!("no operation id could be made: {}", error.kind()))?;
127    Ok(bytes.iter().fold(String::from("op-"), |mut out, byte| {
128        let _infallible = write!(out, "{byte:02x}");
129        out
130    }))
131}
132
133#[cfg(test)]
134#[path = "lifecycle_tests.rs"]
135mod tests;