runa/crates/runa-protocol/src/lib.rs

807 lines
23 KiB
Rust
Raw Normal View History

//! Versioned, transport-neutral wire contracts for Runa clients and servers.
pub use runa_core::Value;
pub use runa_core::{
DebugStopReason, DebuggerSnapshot, ExecutionInspection, SourceBreakpoint, StackFrame,
StackFrameKind,
};
pub use runa_core::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
pub use runa_core::{LogEntry, LogLevel};
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u16 = 5;
pub const WEBSOCKET_SUBPROTOCOL: &str = "runa-debug";
/// Coalescible invalidation, not a drain of the image's runtime event queue.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Notification {
pub version: u16,
pub event: ImageEvent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum ImageEvent {
ImageChanged { revision: u64 },
Log(LogEntry),
}
#[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::SetBreakpoints {
actor_ref,
implementation_id,
lines,
} => {
validate_actor_ref(actor_ref)?;
if implementation_id.is_empty()
|| lines.len() > 128
|| lines.iter().any(|line| *line > u32::MAX as usize)
{
return Err(invalid_request(
"expected implementation and at most 128 breakpoint lines are required",
));
}
}
Command::StepSource {
actor_ref,
implementation_id,
instruction_budget,
} => {
validate_actor_ref(actor_ref)?;
if implementation_id.is_empty()
|| *instruction_budget == 0
|| *instruction_budget > 10_000
{
return Err(invalid_request(
"expected implementation and a step budget from 1 to 10000 are required",
));
}
}
Command::CheckSource { .. } => {}
Command::RenameSource { path, target, .. } => {
validate_name(path)?;
validate_name(target)?;
}
Command::DeleteSource { path, .. } => validate_name(path)?,
Command::InspectExecution { actor_ref }
| Command::ActorBehavior { actor_ref }
| Command::WriteActorBehavior { actor_ref, .. } => validate_actor_ref(actor_ref)?,
Command::UpdateBehaviorSource {
actor_ref, path, ..
} => {
validate_actor_ref(actor_ref)?;
validate_name(path)?;
}
Command::EditActor {
actor_ref,
state,
messages,
} => {
validate_actor_ref(actor_ref)?;
if state.is_none() && messages.is_none() {
return Err(invalid_request("edit must specify state or messages"));
}
if let Some(state) = state {
state.validate().map_err(invalid_request)?;
}
if let Some(messages) = messages {
for message in messages.reserved.iter().chain(&messages.queued) {
message.validate().map_err(invalid_request)?;
}
}
}
Command::SetLogLevel { actor_ref, .. } => validate_actor_ref(actor_ref)?,
Command::Logs { actors, .. } => {
for actor in actors {
validate_actor_ref(actor)?;
}
}
Command::ListSources => {}
Command::ReadSource { path }
| Command::CommitSource { path }
| Command::SourceHistory { path } => validate_name(path)?,
Command::WriteSource { path, .. } => validate_name(path)?,
Command::RevisionSource { path, revision }
| Command::RollbackSource { path, revision } => {
validate_name(path)?;
if revision.is_empty()
|| revision.len() > 64
|| !revision.bytes().all(|byte| byte.is_ascii_alphanumeric())
{
return Err(invalid_request("invalid source revision"));
}
}
Command::System { actor_ref, message } => {
validate_actor_ref(actor_ref)?;
if let SystemMessageDto::Become { path } = message {
validate_name(path)?;
}
}
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 {
SetBreakpoints {
actor_ref: String,
implementation_id: String,
lines: Vec<usize>,
},
StepSource {
actor_ref: String,
implementation_id: String,
instruction_budget: usize,
},
InspectExecution {
actor_ref: String,
},
CheckSource {
source: String,
},
RenameSource {
path: String,
target: String,
expected_source_id: String,
update_actors: bool,
},
DeleteSource {
path: String,
expected_source_id: String,
},
ActorBehavior {
actor_ref: String,
},
WriteActorBehavior {
actor_ref: String,
expected_source_id: String,
source: String,
},
UpdateBehaviorSource {
actor_ref: String,
path: String,
expected_source_id: String,
temporary_source_id: String,
},
EditActor {
actor_ref: String,
state: Option<Value>,
messages: Option<MailboxEdit>,
},
SetLogLevel {
actor_ref: String,
level: LogLevel,
},
Logs {
actors: Vec<String>,
follow: bool,
after: u64,
},
ListSources,
ReadSource {
path: String,
},
WriteSource {
path: String,
expected_source_id: Option<String>,
source: String,
},
CommitSource {
path: String,
},
SourceHistory {
path: String,
},
RevisionSource {
path: String,
revision: String,
},
RollbackSource {
path: String,
revision: String,
},
System {
actor_ref: String,
message: SystemMessageDto,
},
#[serde(skip)]
PublishBehavior {
name: String,
expected_revision: Option<u64>,
source: String,
},
#[serde(skip)]
RollbackBehavior {
name: String,
expected_revision: u64,
target_revision: u64,
},
#[serde(skip)]
GetBehavior {
name: String,
},
#[serde(skip)]
GetBehaviors,
#[serde(skip)]
GetBehaviorSource {
name: String,
revision: u64,
},
Spawn {
#[serde(rename = "path")]
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,
},
#[serde(skip)]
InstallBehavior {
actor_ref: String,
behavior_name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MailboxEdit {
pub reserved: Option<Value>,
pub queued: Vec<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Response {
pub version: u16,
pub request_id: String,
pub result: Result<Reply, ProtocolError>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum Reply {
BreakpointsSet {
breakpoints: Vec<SourceBreakpoint>,
},
DebugStepCompleted {
reason: DebugStopReason,
},
ExecutionInspected(ExecutionInspectionDto),
SourceChecked {
diagnostics: Vec<SourceDiagnostic>,
},
ActorBehavior {
source_id: String,
source: String,
},
Logs {
entries: Vec<LogEntry>,
next_sequence: u64,
has_more: bool,
},
Sources(Vec<SourceFileDto>),
Source(SourceFileDto),
SourceCommitted(SourceCommitDto),
SourceHistory(Vec<SourceCommitDto>),
Acknowledged,
Behavior(BehaviorDto),
Behaviors(Vec<BehaviorDto>),
BehaviorSource {
source: String,
},
ActorSpawned {
actor_ref: String,
},
Actor(ActorSnapshotDto),
Actors(Vec<ActorSnapshotDto>),
Events(Vec<EventDto>),
TickCompleted {
ticks: usize,
},
BehaviorInstalled {
implementation_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceFileDto {
pub path: String,
pub source_id: String,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceCommitDto {
pub revision: String,
pub source_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum SystemMessageDto {
Stop,
Kill,
Restart,
Pause,
Resume,
Reload,
Become { path: 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<BehaviorVersionDto>,
}
#[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 {
#[serde(default)]
pub pending_behavior_path: Option<String>,
#[serde(default)]
pub behavior_path: Option<String>,
#[serde(default)]
pub temporary_behavior: bool,
pub log_level: LogLevel,
pub actor_ref: String,
pub mailbox_id: u64,
pub implementation_id: String,
pub incarnation: u64,
pub state: Value,
pub status: ActorStatusDto,
pub execution: Option<ExecutionSnapshotDto>,
pub queued: Vec<MessageDto>,
pub reserved: Option<MessageDto>,
}
#[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, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionInspectionDto {
pub actor_ref: String,
pub incarnation: u64,
pub implementation_id: String,
pub source_id: String,
pub source: String,
pub behavior_path: Option<String>,
pub execution: Option<ExecutionInspection>,
#[serde(default)]
pub debugger: DebuggerSnapshot,
}
#[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,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<SourceDiagnostic>,
}
impl ProtocolError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
diagnostics: Vec::new(),
}
}
}
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<u64>) -> Result<(), ProtocolError> {
if let Some(revision) = revision {
validate_revision(revision)?;
}
Ok(())
}
fn invalid_request(message: impl Into<String>) -> ProtocolError {
ProtocolError::new(ErrorCode::InvalidRequest, message)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_message(kind: i64, value: i64, target: Option<runa_core::ActorRef>) -> 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::<Request>(&encoded).unwrap(), request);
assert!(serde_json::from_str::<Value>(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::<Value>(&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::<Value>(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);
}
}