plana-dispatch-client 0.1.0

Broker dispatch client for the terminal command routing contract: request envelope, idempotency window, Unix-socket transport, error classification and target-scope guarding.
//! Request envelope for the PoleMos ↔ evernight terminal route (spec §4.1).
//!
//! The envelope is what the entelecheia adapter layer constructs locally; only
//! the *wire subset* returned by [`DispatchEnvelope::wire_params`] ever
//! reaches the broker as JSON-RPC `params`. The additional bookkeeping fields
//! (`request_id`, `origin_agent`, `target_scope`) are adapter-local and must
//! never be presented as evernight protocol fields.

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use uuid::Uuid;

/// Upper bound for the `timeout` wire field (seconds). The adapter layer
/// pre-truncates any larger envelope timeout to this cap before the request
/// goes out (spec §4.1: "M2 设上限 300 并在适配层预截断").
pub const MAX_EXEC_TIMEOUT_SECS: u64 = 300;

/// Where a dispatched command is allowed to run.
///
/// First version of the contract contains exactly one variant: the single
/// designated node resolved from deployment configuration (spec §3.1 — any
/// multi-node or free-text target is rejected). Serialized with an internal
/// tag, e.g. `{"kind":"designated_single_node","alias":"hydro-lab"}`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum TargetScope {
    /// The one designated node identified by its logical alias. The alias is
    /// configuration-injected; it is never a free-text address.
    #[serde(rename = "designated_single_node")]
    DesignatedSingleNode {
        /// Logical node alias resolved from deployment configuration.
        alias: String,
    },
}

/// Local dispatch envelope for one terminal command execution.
///
/// Field table (spec §4.1): `command` / `cwd` / `timeout` are the broker-side
/// "现状" fields; `request_id` (UUIDv4 idempotency key), `origin_agent` and
/// `target_scope` are adapter-side "建议" fields introduced by the M2
/// contract. Use [`DispatchEnvelope::wire_params`] to produce the exact
/// JSON-RPC params object — it emits only the three wire fields, because
/// those are the only params evernight's `Command.Exec` accepts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DispatchEnvelope {
    /// UUIDv4 idempotency key generated by the adapter; doubles as the
    /// JSON-RPC request id so audit records correlate with the wire message.
    pub request_id: String,
    /// Originating agent, fixed to `PoleMos` by the contract (qualified tool
    /// name `PoleMos.host_command_exec`).
    pub origin_agent: String,
    /// Target scope; validated against the designated alias before dispatch.
    pub target_scope: TargetScope,
    /// Shell string, executed by the broker as `sh -c <command>`.
    pub command: String,
    /// Optional working directory (host path).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Requested execution timeout in seconds, capped at
    /// [`MAX_EXEC_TIMEOUT_SECS`] on the wire.
    pub timeout_secs: u64,
}

impl DispatchEnvelope {
    /// Builds an envelope with a freshly generated UUIDv4 `request_id`.
    pub fn new(
        origin_agent: impl Into<String>,
        target_scope: TargetScope,
        command: impl Into<String>,
        cwd: Option<String>,
        timeout_secs: u64,
    ) -> Self {
        Self {
            request_id: Uuid::new_v4().to_string(),
            origin_agent: origin_agent.into(),
            target_scope,
            command: command.into(),
            cwd,
            timeout_secs,
        }
    }

    /// The timeout that will actually go on the wire: the envelope timeout
    /// pre-truncated to [`MAX_EXEC_TIMEOUT_SECS`].
    pub fn effective_timeout_secs(&self) -> u64 {
        self.timeout_secs.min(MAX_EXEC_TIMEOUT_SECS)
    }

    /// Builds the JSON-RPC `params` payload for `Command.Exec`.
    ///
    /// Emits exactly `{"command", "cwd"?, "timeout"}` — the only fields
    /// evernight's `Command.Exec` accepts (spec §4.1 / F6). `cwd` is omitted
    /// when absent, `timeout` is always present and pre-truncated to the
    /// [`MAX_EXEC_TIMEOUT_SECS`] cap. Envelope bookkeeping fields
    /// (`request_id`, `origin_agent`, `target_scope`) are deliberately
    /// excluded; request-scoped authentication is attached by the client as a
    /// separate top-level params field, never here.
    pub fn wire_params(&self) -> Value {
        let mut params = Map::new();
        params.insert("command".to_string(), Value::String(self.command.clone()));
        if let Some(cwd) = &self.cwd {
            params.insert("cwd".to_string(), Value::String(cwd.clone()));
        }
        params.insert(
            "timeout".to_string(),
            Value::Number(self.effective_timeout_secs().into()),
        );
        Value::Object(params)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn envelope(cwd: Option<String>, timeout_secs: u64) -> DispatchEnvelope {
        DispatchEnvelope::new(
            "PoleMos",
            TargetScope::DesignatedSingleNode {
                alias: "hydro-lab".to_string(),
            },
            "uname -a",
            cwd,
            timeout_secs,
        )
    }

    #[test]
    fn wire_shape_has_exactly_command_cwd_timeout() {
        let value = envelope(Some("/workspace".to_string()), 60).wire_params();
        let obj = value.as_object().expect("params must be an object");
        assert_eq!(obj.len(), 3, "wire params must carry exactly three keys");
        assert_eq!(obj["command"], "uname -a");
        assert_eq!(obj["cwd"], "/workspace");
        assert_eq!(obj["timeout"], 60);
    }

    #[test]
    fn wire_shape_omits_absent_cwd() {
        let value = envelope(None, 60).wire_params();
        let obj = value.as_object().expect("params must be an object");
        assert_eq!(obj.len(), 2, "only command and timeout expected");
        assert!(obj.get("cwd").is_none(), "absent cwd must be omitted");
        assert_eq!(obj["command"], "uname -a");
        assert_eq!(obj["timeout"], 60);
    }

    #[test]
    fn wire_never_leaks_envelope_bookkeeping() {
        let value = envelope(Some("/workspace".to_string()), 60).wire_params();
        let serialized = value.to_string();
        for forbidden in ["request_id", "origin_agent", "target_scope"] {
            assert!(
                !serialized.contains(forbidden),
                "wire params must not contain {forbidden}"
            );
        }
    }

    #[test]
    fn timeout_is_truncated_to_the_cap() {
        assert_eq!(envelope(None, 10_000).effective_timeout_secs(), 300);
        assert_eq!(envelope(None, 10_000).wire_params()["timeout"], 300);
        assert_eq!(
            envelope(None, MAX_EXEC_TIMEOUT_SECS).effective_timeout_secs(),
            300
        );
    }

    #[test]
    fn timeout_under_the_cap_passes_through() {
        assert_eq!(envelope(None, 42).effective_timeout_secs(), 42);
        assert_eq!(envelope(None, 42).wire_params()["timeout"], 42);
        assert_eq!(envelope(None, 0).effective_timeout_secs(), 0);
    }

    #[test]
    fn request_id_is_uuid_v4_shaped() {
        let id = envelope(None, 60).request_id;
        let bytes = id.as_bytes();
        assert_eq!(bytes.len(), 36, "expected hyphenated UUID, got {id}");
        assert_eq!(bytes[14], b'4', "expected UUID v4, got {id}");
        assert!(
            matches!(bytes[19], b'8' | b'9' | b'a' | b'b'),
            "expected RFC 4122 variant nibble, got {id}"
        );
    }

    #[test]
    fn envelope_serialization_round_trips() {
        let original = envelope(Some("/workspace".to_string()), 90);
        let serialized = serde_json::to_string(&original).unwrap();
        let back: DispatchEnvelope = serde_json::from_str(&serialized).unwrap();
        assert_eq!(back, original);
    }

    #[test]
    fn target_scope_serializes_as_designated_single_node() {
        let scope = TargetScope::DesignatedSingleNode {
            alias: "hydro-lab".to_string(),
        };
        let value = serde_json::to_value(&scope).unwrap();
        assert_eq!(value["kind"], "designated_single_node");
        assert_eq!(value["alias"], "hydro-lab");
        let back: TargetScope = serde_json::from_value(value).unwrap();
        assert_eq!(back, scope);
    }
}