From a65c66db018b28907e089574fe1af4ab6c1e1e28 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Mon, 2 Nov 2026 13:36:32 +0100 Subject: [PATCH] feat: define versioned Runa protocol DTOs Add the runa-protocol crate with version-1 request, response, and event DTOs, serde serialization, and basic envelope and input validation. Document the wire contract and clarify that transport, dispatch, and authentication remain future work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 1 + CHANGELOG.md | 2 + Cargo.lock | 27 ++ Cargo.toml | 1 + README.md | 7 +- crates/runa-protocol/Cargo.toml | 10 + crates/runa-protocol/src/lib.rs | 440 ++++++++++++++++++++++++++++++++ docs/architecture.md | 14 +- docs/protocol.md | 49 ++++ docs/runtime.md | 2 + 10 files changed, 545 insertions(+), 8 deletions(-) create mode 100644 crates/runa-protocol/Cargo.toml create mode 100644 crates/runa-protocol/src/lib.rs create mode 100644 docs/protocol.md diff --git a/AGENTS.md b/AGENTS.md index f9d3b4d..f742f78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ proposed capabilities as plans, not existing behavior. - `crates/runa-actor/`: actor value and lifecycle contracts. - `crates/runa-runtime/`: single-worker scheduling and in-memory transitions. - `crates/runa-image-api/`: trusted in-process services for operating on an image. +- `crates/runa-protocol/`: versioned transport-neutral request, response, and event DTOs. - `runa-std/actors/`: default Rune actor scripts loaded by the local CLI. - `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario. - `docs/decisions/`: architectural decisions. diff --git a/CHANGELOG.md b/CHANGELOG.md index ef04e87..b2ca9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ All notable changes to this project will be documented in this file. instruction pointer, and call-frame depth. - Trusted in-process `runa-image-api` service boundary for code and actor operations, now used by the local CLI. +- Versioned, transport-neutral `runa-protocol` request, response, and event + DTOs with serde support and request validation. - Execution-model decision and Phase 1 runtime documentation. - Lifecycle tests proving failed-message retry from committed state and stop/restart preservation of reserved-first FIFO order. diff --git a/Cargo.lock b/Cargo.lock index 815904e..77b8776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -431,6 +431,14 @@ dependencies = [ "runa-runtime", ] +[[package]] +name = "runa-protocol" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "runa-rune" version = "0.0.0" @@ -590,6 +598,19 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -800,3 +821,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index cd28a1b..709b7a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/runa-code", "crates/runa-core", "crates/runa-image-api", + "crates/runa-protocol", "crates/runa-rune", "crates/runa-runtime", ] diff --git a/README.md b/README.md index 323910e..d61fdb3 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ network development API, graphical world, and VS Code extension are not implemented yet. The code library now supports an in-memory versioned behavior registry, and the CLI exposes publishing, history, rollback, safe-boundary installation, pause/resume, and cancellation through a trusted in-process image -API. It is not a network API and does not provide authentication. +API. A versioned transport-neutral protocol contract is now defined, but no +network server or authentication is implemented. The prototype uses worker-local invocation stacks and cooperative instruction slices, and actor snapshots report basic active-execution progress. Native @@ -106,9 +107,11 @@ test task runs `cargo test --workspace`. │ ├── runa-code/ # Source identities and compile cache │ ├── runa-rune/ # Phase 0 Rune experiments │ ├── runa-runtime/ # Single-worker actor prototype -│ └── runa-image-api/ # Trusted in-process image operations +│ ├── runa-image-api/ # Trusted in-process image operations +│ └── runa-protocol/ # Versioned transport-neutral wire DTOs ├── docs/ │ ├── architecture.md +│ ├── protocol.md │ └── decisions/ ├── runa-std/ │ ├── core/ diff --git a/crates/runa-protocol/Cargo.toml b/crates/runa-protocol/Cargo.toml new file mode 100644 index 0000000..93b25c4 --- /dev/null +++ b/crates/runa-protocol/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "runa-protocol" +version = "0.0.0" +edition = "2024" + +[dependencies] +serde = { version = "1.0.229", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/runa-protocol/src/lib.rs b/crates/runa-protocol/src/lib.rs new file mode 100644 index 0000000..ba82e97 --- /dev/null +++ b/crates/runa-protocol/src/lib.rs @@ -0,0 +1,440 @@ +//! Versioned, transport-neutral wire contracts for Runa clients and servers. + +use serde::{Deserialize, Serialize}; + +pub const PROTOCOL_VERSION: u16 = 1; + +#[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, + .. + } => { + validate_name(behavior_name)?; + if *mailbox_capacity == 0 { + return Err(invalid_request("mailbox_capacity must be positive")); + } + } + Command::Send { actor_id, .. } + | Command::InspectActor { actor_id } + | Command::StopActor { actor_id } + | Command::RestartActor { actor_id } + | Command::PauseActor { actor_id } + | Command::ResumeActor { actor_id } + | Command::CancelActor { actor_id } => validate_actor_id(*actor_id)?, + Command::InstallBehavior { + actor_id, + behavior_name, + } => { + validate_actor_id(*actor_id)?; + 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: i64, + mailbox_capacity: usize, + }, + Send { + actor_id: u64, + message: MessageDto, + }, + Tick { + ticks: usize, + instruction_budget: usize, + }, + InspectActor { + actor_id: u64, + }, + ListActors, + GetEvents, + StopActor { + actor_id: u64, + }, + RestartActor { + actor_id: u64, + }, + PauseActor { + actor_id: u64, + }, + ResumeActor { + actor_id: u64, + }, + CancelActor { + actor_id: u64, + }, + InstallBehavior { + actor_id: u64, + 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_id: u64 }, + 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_id: u64, + }, + Committed { + actor_id: u64, + }, + Failed { + actor_id: u64, + message: String, + }, + Stopped { + actor_id: u64, + }, + Restarted { + actor_id: u64, + }, + Paused { + actor_id: u64, + }, + Resumed { + actor_id: u64, + }, + InvocationCancelled { + actor_id: u64, + }, + BehaviorInstalled { + actor_id: u64, + implementation_id: String, + }, + StateMigrated { + actor_id: u64, + previous_state: i64, + current_state: i64, + }, +} + +#[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, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MessageDto { + pub kind: i64, + pub value: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActorSnapshotDto { + pub actor_id: u64, + pub mailbox_id: u64, + pub implementation_id: String, + pub incarnation: u64, + pub state: i64, + 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_id(actor_id: u64) -> Result<(), ProtocolError> { + if actor_id == 0 { + return Err(invalid_request("actor_id must be positive")); + } + 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 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_id: 7, + message: MessageDto { kind: 2, value: 9 }, + })) + .unwrap(); + assert_eq!( + value, + serde_json::json!({ + "version": PROTOCOL_VERSION, + "request_id": "req-1", + "command": { + "operation": "send", + "parameters": { + "actor_id": 7, + "message": { "kind": 2, "value": 9 } + } + } + }) + ); + } + + #[test] + fn requests_validate_versions_and_required_positive_values() { + let mut request = request(Command::Spawn { + behavior_name: "counter".to_owned(), + state: 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: 0, + mailbox_capacity: 0, + }; + assert_eq!( + request.validate().unwrap_err().code, + ErrorCode::InvalidRequest + ); + } + + #[test] + fn requests_reject_empty_correlation_ids_and_zero_actor_ids() { + 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_id: 0 }; + assert_eq!( + request.validate().unwrap_err().code, + ErrorCode::InvalidRequest + ); + } + + #[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 event_envelopes_are_versioned_and_serializable() { + let event = ProtocolEvent { + version: PROTOCOL_VERSION, + event: EventDto::StateMigrated { + actor_id: 3, + previous_state: 4, + current_state: 40, + }, + }; + let encoded = serde_json::to_string(&event).unwrap(); + let decoded: ProtocolEvent = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, event); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 71548f3..bb1edcc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -309,7 +309,7 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen | `runa-rune` | Rune compilation, VM execution, suspension, interruption hooks, language-specific diagnostics | `runa-core`, Rune libraries | | `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-code`, `runa-rune` | | `runa-image-api` | Trusted in-process application services for spawning, inspecting, sending, editing, and upgrading | `runa-core`, `runa-actor`, `runa-code`, `runa-runtime` | -| `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `runa-core` | +| `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `serde` | | `runa-server` | WebSocket/JSON-RPC transport, sessions, authentication, subscriptions | `runa-protocol`, `runa-image-api` | | `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary | | `runa-world` | Morphic-style graphical composition and in-world development UI | `runa-image-api`, `runa-graphics` | @@ -325,8 +325,9 @@ authentication or authorization; callers must be trusted by the host. The initial `runa-image-api` provides process-local behavior source retrieval, publication, history and rollback, actor lifecycle and messaging, snapshots, ticks, and event retrieval. The CLI uses these application services rather -than reaching into the code store and runtime directly. Versioned wire DTOs -and transport are the next separate layer. +than reaching into the code store and runtime directly. `runa-protocol` now +defines versioned, serializable request, response, and event DTOs without +depending on a transport or the runtime implementation. **Important:** `runa-core` must not contain schedulers, mailbox implementations, persistence engines, Rune VM types, service traits used by only one crate, generic helper collections, or GUI abstractions. Prefer a type defined in its owning crate until two independent consumers demonstrably need a stable shared representation. @@ -430,9 +431,10 @@ inspection remain future work. - Headless server, versioned protocol, actor and mailbox operations, source editing, events. - CLI client that can spawn, inspect, send messages, and replace behaviors. -The first Phase 5 increment is an in-process trusted application API used by -the CLI. A transport-neutral versioned protocol and network server are not yet -implemented. +The first Phase 5 increments are a trusted in-process application API used by +the CLI and a transport-neutral versioned protocol contract. The protocol is a +Rust DTO crate with serde serialization; a network server and authentication +are not yet implemented. ### Phase 6 — Graphical world diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..e72e98e --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,49 @@ +# Runa Protocol + +`runa-protocol` defines versioned request, response, and event data contracts +for clients and a future server. It is independent of transport and runtime +implementation; the current crate does not dispatch requests, expose a network +endpoint, or provide authentication. + +## Version 1 + +Every request and response carries `version: 1`. Requests also carry a +non-empty `request_id` used to correlate the response. A server must reject +unsupported versions with `unsupported_version` rather than interpreting a +request using a different contract. Events carry the protocol version and do +not have a request ID. + +Requests have a tagged command object: + +```json +{ + "version": 1, + "request_id": "req-1", + "command": { + "operation": "send", + "parameters": { + "actor_id": 7, + "message": { "kind": 2, "value": 9 } + } + } +} +``` + +The version 1 operations are behavior publication, rollback, lookup, source +retrieval, actor spawn, send, tick, inspect, actor listing, event retrieval, +stop, restart, pause, resume, cancellation, and behavior installation. +Migration closures are intentionally excluded: they are host callbacks, not +portable wire values. + +Responses echo the request ID and carry either a typed reply or a protocol +error. Events use a separate versioned envelope. Actor and behavior snapshots +are wire DTOs, not serialized runtime objects; implementation identities are +represented by their stable display strings. Runtime errors and validation +errors must be mapped by a future server to the defined error codes without +claiming a more specific category than it can establish. + +The current validation checks the protocol version, request ID, positive +actor/revision identifiers where required, non-empty behavior names, mailbox +capacity, and instruction budget. Behavior-name grammar, actor existence, +current behavior revisions, mailbox capacity at send time, and all runtime +invariants remain authoritative at the application-service boundary. diff --git a/docs/runtime.md b/docs/runtime.md index f46be41..4f38a5e 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -49,6 +49,8 @@ The default per-actor slice budget is 32 instructions. state, scheduling, message effects and lifecycle coordination. - `runa-image-api` composes code publication and runtime operations behind a trusted in-process service boundary. +- `runa-protocol` defines versioned transport-neutral request, response, and + event DTOs; it does not dispatch requests or access runtime state. - `runa` provides the local CLI client for that boundary. The image API is process-local and intended for trusted callers. It has no