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>
This commit is contained in:
Steve Biedermann 2026-11-02 13:36:32 +01:00
parent 515a6439b5
commit a65c66db01
10 changed files with 545 additions and 8 deletions

View File

@ -67,6 +67,7 @@ proposed capabilities as plans, not existing behavior.
- `crates/runa-actor/`: actor value and lifecycle contracts. - `crates/runa-actor/`: actor value and lifecycle contracts.
- `crates/runa-runtime/`: single-worker scheduling and in-memory transitions. - `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-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. - `runa-std/actors/`: default Rune actor scripts loaded by the local CLI.
- `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario. - `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario.
- `docs/decisions/`: architectural decisions. - `docs/decisions/`: architectural decisions.

View File

@ -33,6 +33,8 @@ All notable changes to this project will be documented in this file.
instruction pointer, and call-frame depth. instruction pointer, and call-frame depth.
- Trusted in-process `runa-image-api` service boundary for code and actor - Trusted in-process `runa-image-api` service boundary for code and actor
operations, now used by the local CLI. 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. - Execution-model decision and Phase 1 runtime documentation.
- Lifecycle tests proving failed-message retry from committed state and - Lifecycle tests proving failed-message retry from committed state and
stop/restart preservation of reserved-first FIFO order. stop/restart preservation of reserved-first FIFO order.

27
Cargo.lock generated
View File

@ -431,6 +431,14 @@ dependencies = [
"runa-runtime", "runa-runtime",
] ]
[[package]]
name = "runa-protocol"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "runa-rune" name = "runa-rune"
version = "0.0.0" version = "0.0.0"
@ -590,6 +598,19 @@ dependencies = [
"syn 3.0.9", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@ -800,3 +821,9 @@ dependencies = [
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View File

@ -6,6 +6,7 @@ members = [
"crates/runa-code", "crates/runa-code",
"crates/runa-core", "crates/runa-core",
"crates/runa-image-api", "crates/runa-image-api",
"crates/runa-protocol",
"crates/runa-rune", "crates/runa-rune",
"crates/runa-runtime", "crates/runa-runtime",
] ]

View File

@ -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 implemented yet. The code library now supports an in-memory versioned behavior
registry, and the CLI exposes publishing, history, rollback, safe-boundary registry, and the CLI exposes publishing, history, rollback, safe-boundary
installation, pause/resume, and cancellation through a trusted in-process image 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 The prototype uses worker-local invocation stacks and cooperative instruction
slices, and actor snapshots report basic active-execution progress. Native 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-code/ # Source identities and compile cache
│ ├── runa-rune/ # Phase 0 Rune experiments │ ├── runa-rune/ # Phase 0 Rune experiments
│ ├── runa-runtime/ # Single-worker actor prototype │ ├── 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/ ├── docs/
│ ├── architecture.md │ ├── architecture.md
│ ├── protocol.md
│ └── decisions/ │ └── decisions/
├── runa-std/ ├── runa-std/
│ ├── core/ │ ├── core/

View File

@ -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"

View File

@ -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<u64>,
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<Reply, ProtocolError>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum Reply {
Acknowledged,
Behavior(BehaviorDto),
Behaviors(Vec<BehaviorDto>),
BehaviorSource { source: String },
ActorSpawned { actor_id: u64 },
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 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<BehaviorVersionDto>,
}
#[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<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, 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<String>) -> 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<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 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);
}
}

View File

@ -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-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-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-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-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-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` | | `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, The initial `runa-image-api` provides process-local behavior source retrieval,
publication, history and rollback, actor lifecycle and messaging, snapshots, publication, history and rollback, actor lifecycle and messaging, snapshots,
ticks, and event retrieval. The CLI uses these application services rather ticks, and event retrieval. The CLI uses these application services rather
than reaching into the code store and runtime directly. Versioned wire DTOs than reaching into the code store and runtime directly. `runa-protocol` now
and transport are the next separate layer. 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. **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. - Headless server, versioned protocol, actor and mailbox operations, source editing, events.
- CLI client that can spawn, inspect, send messages, and replace behaviors. - 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 first Phase 5 increments are a trusted in-process application API used by
the CLI. A transport-neutral versioned protocol and network server are not yet the CLI and a transport-neutral versioned protocol contract. The protocol is a
implemented. Rust DTO crate with serde serialization; a network server and authentication
are not yet implemented.
### Phase 6 — Graphical world ### Phase 6 — Graphical world

49
docs/protocol.md Normal file
View File

@ -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.

View File

@ -49,6 +49,8 @@ The default per-actor slice budget is 32 instructions.
state, scheduling, message effects and lifecycle coordination. state, scheduling, message effects and lifecycle coordination.
- `runa-image-api` composes code publication and runtime operations behind a - `runa-image-api` composes code publication and runtime operations behind a
trusted in-process service boundary. 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. - `runa` provides the local CLI client for that boundary.
The image API is process-local and intended for trusted callers. It has no The image API is process-local and intended for trusted callers. It has no