//! The `ops-agent` HTTP wire contract, shared by the `AgentRpc` client //! ([`crate::rpc`]) and the agent server ([`crate::agent`]). //! //! `/run` streams a body of newline-delimited JSON [`Frame`]s: zero or more //! `chunk` frames (merged stdout+stderr as it arrives), then exactly one //! terminal frame — `exit` on a clean run or `error` if the agent refused or //! failed before the child produced an exit code. use crate::step::Step; use serde::{Deserialize, Serialize}; /// The wire-protocol version the daemon and agent speak. Bump on a /// breaking change to [`Frame`]/[`RunRequest`]; surfaced in [`HealthResponse`] /// so a daemon can detect a skewed agent at preflight rather than mid-stream. pub const PROTOCOL_VERSION: u32 = 1; /// `POST /run` request body. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RunRequest { pub step: Step, } /// One newline-delimited frame in a `/run` response stream. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "t", rename_all = "snake_case")] pub enum Frame { /// A slice of merged stdout/stderr (UTF-8-lossy; not line-aligned). Chunk { text: String }, /// Terminal: the child exited with this code. Exit { code: i32 }, /// Terminal: the agent refused (capability/identity) or failed to spawn. Error { message: String }, /// A frame kind this client does not know — a newer agent emitted it. /// Decoding to this (instead of erroring) keeps an older client /// forward-compatible: it ignores unknown frames rather than failing the run. #[serde(other)] Unknown, } impl Frame { /// Serialize as one NDJSON line (trailing `\n` included). pub fn to_line(&self) -> String { let mut s = serde_json::to_string(self).unwrap_or_else(|e| { format!("{{\"t\":\"error\",\"message\":\"frame serialize failed: {e}\"}}") }); s.push('\n'); s } } #[cfg(test)] mod tests { use super::*; #[test] fn unknown_frame_kind_decodes_to_unknown_not_error() { // A newer agent emits a frame this client doesn't know. let f: Frame = serde_json::from_str(r#"{"t":"progress","pct":42}"#).unwrap(); assert!(matches!(f, Frame::Unknown)); // Known frames still decode normally. let f: Frame = serde_json::from_str(r#"{"t":"exit","code":0}"#).unwrap(); assert!(matches!(f, Frame::Exit { code: 0 })); } #[test] fn health_without_version_defaults_to_zero() { let h: HealthResponse = serde_json::from_str(r#"{"ok":true,"actuate":[],"observe":[]}"#).unwrap(); assert_eq!(h.version, 0); } } /// `GET /health` response body. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct HealthResponse { pub ok: bool, /// Wire-protocol version this agent speaks ([`PROTOCOL_VERSION`]). Defaults /// to 0 when absent so an older agent (no field) still deserializes. #[serde(default)] pub version: u32, /// The agent's own actuate grant tokens (introspection / audit). pub actuate: Vec, /// The agent's own observe grant tokens. pub observe: Vec, }