//! Versioned, transport-neutral wire contracts for Runa clients and servers. pub use runa_core::Value; use serde::{Deserialize, Serialize}; pub const PROTOCOL_VERSION: u16 = 3; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Request { pub version: u16, pub request_id: String, pub command: Command, } impl Request { pub fn validate(&self) -> Result<(), ProtocolError> { if self.version != PROTOCOL_VERSION { return Err(ProtocolError::new( ErrorCode::UnsupportedVersion, format!("unsupported protocol version {}", self.version), )); } if self.request_id.trim().is_empty() { return Err(ProtocolError::new( ErrorCode::InvalidRequest, "request_id must not be empty", )); } match &self.command { Command::PublishBehavior { name, expected_revision, .. } => { validate_name(name)?; validate_optional_revision(*expected_revision)?; } Command::RollbackBehavior { name, expected_revision, target_revision, } => { validate_name(name)?; validate_revision(*expected_revision)?; validate_revision(*target_revision)?; } Command::GetBehavior { name } => validate_name(name)?, Command::GetBehaviorSource { name, revision } => { validate_name(name)?; validate_revision(*revision)?; } Command::Spawn { behavior_name, mailbox_capacity, state, } => { validate_name(behavior_name)?; state.validate().map_err(invalid_request)?; if *mailbox_capacity == 0 { return Err(invalid_request("mailbox_capacity must be positive")); } } Command::Send { actor_ref, message } => { validate_actor_ref(actor_ref)?; message.validate().map_err(invalid_request)?; } Command::InspectActor { actor_ref } | Command::StopActor { actor_ref } | Command::RestartActor { actor_ref } | Command::PauseActor { actor_ref } | Command::ResumeActor { actor_ref } | Command::CancelActor { actor_ref } => validate_actor_ref(actor_ref)?, Command::InstallBehavior { actor_ref, behavior_name, } => { validate_actor_ref(actor_ref)?; validate_name(behavior_name)?; } Command::Tick { instruction_budget, .. } if *instruction_budget == 0 => { return Err(invalid_request("instruction_budget must be positive")); } Command::GetBehaviors | Command::ListActors | Command::GetEvents | Command::Tick { .. } => {} } Ok(()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "operation", content = "parameters", rename_all = "snake_case")] pub enum Command { PublishBehavior { name: String, expected_revision: Option, source: String, }, RollbackBehavior { name: String, expected_revision: u64, target_revision: u64, }, GetBehavior { name: String, }, GetBehaviors, GetBehaviorSource { name: String, revision: u64, }, Spawn { behavior_name: String, state: Value, mailbox_capacity: usize, }, Send { actor_ref: String, message: MessageDto, }, Tick { ticks: usize, instruction_budget: usize, }, InspectActor { actor_ref: String, }, ListActors, GetEvents, StopActor { actor_ref: String, }, RestartActor { actor_ref: String, }, PauseActor { actor_ref: String, }, ResumeActor { actor_ref: String, }, CancelActor { actor_ref: String, }, InstallBehavior { actor_ref: String, behavior_name: String, }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Response { pub version: u16, pub request_id: String, pub result: Result, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", content = "data", rename_all = "snake_case")] pub enum Reply { Acknowledged, Behavior(BehaviorDto), Behaviors(Vec), BehaviorSource { source: String }, ActorSpawned { actor_ref: String }, Actor(ActorSnapshotDto), Actors(Vec), Events(Vec), TickCompleted { ticks: usize }, BehaviorInstalled { implementation_id: String }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProtocolEvent { pub version: u16, pub event: EventDto, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "event", content = "data", rename_all = "snake_case")] pub enum EventDto { Spawned { actor_ref: String, }, Committed { actor_ref: String, }, Failed { actor_ref: String, message: String, }, Stopped { actor_ref: String, }, Restarted { actor_ref: String, }, Paused { actor_ref: String, }, Resumed { actor_ref: String, }, InvocationCancelled { actor_ref: String, }, BehaviorInstalled { actor_ref: String, implementation_id: String, }, StateMigrated { actor_ref: String, previous_state: Value, current_state: Value, }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BehaviorDto { pub name: String, pub revision: u64, pub current_implementation_id: String, pub history: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BehaviorVersionDto { pub revision: u64, pub implementation_id: String, } pub type MessageDto = Value; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ActorSnapshotDto { pub actor_ref: String, pub mailbox_id: u64, pub implementation_id: String, pub incarnation: u64, pub state: Value, pub status: ActorStatusDto, pub execution: Option, pub queued: Vec, pub reserved: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "status", content = "message", rename_all = "snake_case")] pub enum ActorStatusDto { Idle, Running, Awaiting, Paused, Stopped, Failed(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ExecutionSnapshotDto { pub vm_steps: u64, pub instruction_pointer: usize, pub call_depth: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ErrorCode { InvalidRequest, UnsupportedVersion, NotFound, Conflict, CapacityExceeded, RuntimeFailure, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProtocolError { pub code: ErrorCode, pub message: String, } impl ProtocolError { pub fn new(code: ErrorCode, message: impl Into) -> Self { Self { code, message: message.into(), } } } fn validate_name(name: &str) -> Result<(), ProtocolError> { if name.trim().is_empty() { return Err(invalid_request("behavior name must not be empty")); } Ok(()) } fn validate_actor_ref(actor_ref: &str) -> Result<(), ProtocolError> { if actor_ref.len() != 21 || !actor_ref .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) { return Err(invalid_request( "actor_ref must be a 21-character URL-safe identifier", )); } Ok(()) } fn validate_revision(revision: u64) -> Result<(), ProtocolError> { if revision == 0 { return Err(invalid_request("revision must be positive")); } Ok(()) } fn validate_optional_revision(revision: Option) -> Result<(), ProtocolError> { if let Some(revision) = revision { validate_revision(revision)?; } Ok(()) } fn invalid_request(message: impl Into) -> ProtocolError { ProtocolError::new(ErrorCode::InvalidRequest, message) } #[cfg(test)] mod tests { use super::*; fn test_message(kind: i64, value: i64, target: Option) -> Value { Value::Record(std::collections::BTreeMap::from([ ("kind".to_owned(), Value::Integer(kind)), ("value".to_owned(), Value::Integer(value)), ( "target".to_owned(), target.map_or(Value::Null, Value::ActorRef), ), ])) } fn request(command: Command) -> Request { Request { version: PROTOCOL_VERSION, request_id: "req-1".to_owned(), command, } } #[test] fn request_uses_a_stable_tagged_wire_shape() { let value = serde_json::to_value(request(Command::Send { actor_ref: "xK8_pQ2-Lm5_012345678".to_owned(), message: Value::Integer(9), })) .unwrap(); assert_eq!( value, serde_json::json!({ "version": PROTOCOL_VERSION, "request_id": "req-1", "command": { "operation": "send", "parameters": { "actor_ref": "xK8_pQ2-Lm5_012345678", "message": { "type": "integer", "data": 9 } } } }) ); } #[test] fn requests_validate_versions_and_required_positive_values() { let mut request = request(Command::Spawn { behavior_name: "counter".to_owned(), state: Value::Integer(0), mailbox_capacity: 1, }); assert_eq!(request.validate(), Ok(())); request.version += 1; assert_eq!( request.validate().unwrap_err().code, ErrorCode::UnsupportedVersion ); request.version = PROTOCOL_VERSION; request.command = Command::Spawn { behavior_name: "counter".to_owned(), state: Value::Integer(0), mailbox_capacity: 0, }; assert_eq!( request.validate().unwrap_err().code, ErrorCode::InvalidRequest ); } #[test] fn requests_reject_empty_correlation_ids_and_malformed_actor_refs() { let mut request = request(Command::GetEvents); request.request_id = " ".to_owned(); assert_eq!( request.validate().unwrap_err().code, ErrorCode::InvalidRequest ); request.request_id = "req-2".to_owned(); request.command = Command::StopActor { actor_ref: String::new(), }; assert_eq!( request.validate().unwrap_err().code, ErrorCode::InvalidRequest ); } #[test] fn nested_references_round_trip_and_malformed_references_are_rejected() { let request = request(Command::Send { actor_ref: "xK8_pQ2-Lm5_012345678".to_owned(), message: test_message(2, 9, runa_core::ActorRef::parse("Hj2_9sM0-pQ4_7aBcD3eF")), }); assert_eq!(request.validate(), Ok(())); let encoded = serde_json::to_string(&request).unwrap(); assert_eq!(serde_json::from_str::(&encoded).unwrap(), request); assert!(serde_json::from_str::(r#"{"type":"actor_ref","data":"bad"}"#).is_err()); } #[test] fn response_round_trips_with_typed_error_or_reply() { let response = Response { version: PROTOCOL_VERSION, request_id: "req-1".to_owned(), result: Err(ProtocolError::new(ErrorCode::NotFound, "unknown actor")), }; let encoded = serde_json::to_string(&response).unwrap(); let decoded: Response = serde_json::from_str(&encoded).unwrap(); assert_eq!(decoded, response); } #[test] fn structured_values_have_unambiguous_wire_types_and_reject_invalid_data() { let reference = runa_core::ActorRef::new(); let value = Value::Record(std::collections::BTreeMap::from([( "values".to_owned(), Value::List(vec![ Value::Null, Value::Bool(false), Value::Integer(i64::MAX), Value::String(reference.to_string()), Value::ActorRef(reference), ]), )])); let encoded = serde_json::to_string(&value).unwrap(); assert_eq!(serde_json::from_str::(&encoded).unwrap(), value); for encoded in [ r#"{"type":"integer","data":1.5}"#, r#"{"type":"integer","data":9223372036854775808}"#, r#"{"type":"actor_ref","data":"invalid"}"#, r#"{"type":"unknown","data":1}"#, r#"{"type":"bool","data":true,"extra":1}"#, ] { assert!(serde_json::from_str::(encoded).is_err(), "{encoded}"); } let invalid = Value::List(vec![Value::Null; runa_core::MAX_VALUE_NODES]); let spawn = request(Command::Spawn { behavior_name: "echo".to_owned(), state: invalid.clone(), mailbox_capacity: 2, }); assert_eq!( spawn.validate().unwrap_err().code, ErrorCode::InvalidRequest ); let send = request(Command::Send { actor_ref: reference.to_string(), message: invalid, }); assert_eq!(send.validate().unwrap_err().code, ErrorCode::InvalidRequest); let mut old = request(Command::ListActors); old.version = 2; assert_eq!( old.validate().unwrap_err().code, ErrorCode::UnsupportedVersion ); } #[test] fn event_envelopes_are_versioned_and_serializable() { let event = ProtocolEvent { version: PROTOCOL_VERSION, event: EventDto::StateMigrated { actor_ref: "actor_ref_000000000000".to_owned(), previous_state: Value::Integer(4), current_state: Value::Integer(40), }, }; let encoded = serde_json::to_string(&event).unwrap(); let decoded: ProtocolEvent = serde_json::from_str(&encoded).unwrap(); assert_eq!(decoded, event); } }