Skip to main content

max / makenotwork

3.1 KB · 86 lines History Blame Raw
1 //! The `ops-agent` HTTP wire contract, shared by the `AgentRpc` client
2 //! ([`crate::rpc`]) and the agent server ([`crate::agent`]).
3 //!
4 //! `/run` streams a body of newline-delimited JSON [`Frame`]s: zero or more
5 //! `chunk` frames (merged stdout+stderr as it arrives), then exactly one
6 //! terminal frame — `exit` on a clean run or `error` if the agent refused or
7 //! failed before the child produced an exit code.
8
9 use crate::step::Step;
10 use serde::{Deserialize, Serialize};
11
12 /// The wire-protocol version the daemon and agent speak. Bump on a
13 /// breaking change to [`Frame`]/[`RunRequest`]; surfaced in [`HealthResponse`]
14 /// so a daemon can detect a skewed agent at preflight rather than mid-stream.
15 pub const PROTOCOL_VERSION: u32 = 1;
16
17 /// `POST /run` request body.
18 #[derive(Clone, Debug, Serialize, Deserialize)]
19 pub struct RunRequest {
20 pub step: Step,
21 }
22
23 /// One newline-delimited frame in a `/run` response stream.
24 #[derive(Clone, Debug, Serialize, Deserialize)]
25 #[serde(tag = "t", rename_all = "snake_case")]
26 pub enum Frame {
27 /// A slice of merged stdout/stderr (UTF-8-lossy; not line-aligned).
28 Chunk { text: String },
29 /// Terminal: the child exited with this code.
30 Exit { code: i32 },
31 /// Terminal: the agent refused (capability/identity) or failed to spawn.
32 Error { message: String },
33 /// A frame kind this client does not know — a newer agent emitted it.
34 /// Decoding to this (instead of erroring) keeps an older client
35 /// forward-compatible: it ignores unknown frames rather than failing the run.
36 #[serde(other)]
37 Unknown,
38 }
39
40 impl Frame {
41 /// Serialize as one NDJSON line (trailing `\n` included).
42 pub fn to_line(&self) -> String {
43 let mut s = serde_json::to_string(self).unwrap_or_else(|e| {
44 format!("{{\"t\":\"error\",\"message\":\"frame serialize failed: {e}\"}}")
45 });
46 s.push('\n');
47 s
48 }
49 }
50
51 #[cfg(test)]
52 mod tests {
53 use super::*;
54
55 #[test]
56 fn unknown_frame_kind_decodes_to_unknown_not_error() {
57 // A newer agent emits a frame this client doesn't know.
58 let f: Frame = serde_json::from_str(r#"{"t":"progress","pct":42}"#).unwrap();
59 assert!(matches!(f, Frame::Unknown));
60 // Known frames still decode normally.
61 let f: Frame = serde_json::from_str(r#"{"t":"exit","code":0}"#).unwrap();
62 assert!(matches!(f, Frame::Exit { code: 0 }));
63 }
64
65 #[test]
66 fn health_without_version_defaults_to_zero() {
67 let h: HealthResponse =
68 serde_json::from_str(r#"{"ok":true,"actuate":[],"observe":[]}"#).unwrap();
69 assert_eq!(h.version, 0);
70 }
71 }
72
73 /// `GET /health` response body.
74 #[derive(Clone, Debug, Serialize, Deserialize)]
75 pub struct HealthResponse {
76 pub ok: bool,
77 /// Wire-protocol version this agent speaks ([`PROTOCOL_VERSION`]). Defaults
78 /// to 0 when absent so an older agent (no field) still deserializes.
79 #[serde(default)]
80 pub version: u32,
81 /// The agent's own actuate grant tokens (introspection / audit).
82 pub actuate: Vec<String>,
83 /// The agent's own observe grant tokens.
84 pub observe: Vec<String>,
85 }
86