feat!: add structured actor state and messages

Introduce owned portable values with distinct actor references, bounded Rune conversion, JSON REPL literals, and nested reference completion. Preserve isolation and atomic transitions across state, messages, effects, migration, inspection, and TCP clients.

BREAKING CHANGE: protocol v3 replaces integer state and kind/value/target_ref messages with tagged values. Rune handlers now take (state, message) and return (new_state, [(actor_reference, message)]). The REPL send command accepts one JSON message.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-02 16:56:03 +01:00
parent 350ddba0ce
commit ad63e286d9
24 changed files with 1311 additions and 695 deletions

View File

@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Portable structured actor values: null, booleans, signed 64-bit integers,
strings, lists, records, and distinct NanoID actor references.
- JSON state/message literals in the REPL, including named `$actor` references,
`$record` escaping, and actor-reference completion inside nested values.
- Value conversion limits and tests for nested-state isolation, copied sends,
cyclic/unsupported Rune output, and all-or-nothing structured transitions.
- Reusable loopback TCP protocol client with bounded messages, correlation - Reusable loopback TCP protocol client with bounded messages, correlation
checks, explicit connection failures, and no automatic request retries. checks, explicit connection failures, and no automatic request retries.
- Multi-client and reconnect tests proving that actors remain in the - Multi-client and reconnect tests proving that actors remain in the
@ -74,6 +80,10 @@ All notable changes to this project will be documented in this file.
### Changed ### Changed
- Protocol v3 and Rune's two-argument `handle(state, message)` replace the
temporary integer-only state and kind/value/target_ref message envelope.
Returned sends now contain `(actor_reference, message)` pairs; migrations,
snapshots, and events preserve structured values.
- The REPL now connects to the localhost server rather than embedding its own - The REPL now connects to the localhost server rather than embedding its own
image. Start the server first; `--connect` selects a loopback endpoint. image. Start the server first; `--connect` selects a loopback endpoint.
- TCP server startup publishes the bundled counter behavior once; connecting - TCP server startup publishes the bundled counter behavior once; connecting

9
Cargo.lock generated
View File

@ -552,16 +552,17 @@ name = "runa"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"runa-client", "runa-client",
"runa-core",
"runa-protocol", "runa-protocol",
"runa-server", "runa-server",
"rustyline", "rustyline",
"serde_json",
] ]
[[package]] [[package]]
name = "runa-actor" name = "runa-actor"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"nanoid",
"runa-core", "runa-core",
] ]
@ -586,6 +587,10 @@ dependencies = [
[[package]] [[package]]
name = "runa-core" name = "runa-core"
version = "0.0.0" version = "0.0.0"
dependencies = [
"nanoid",
"serde",
]
[[package]] [[package]]
name = "runa-image-api" name = "runa-image-api"
@ -601,6 +606,7 @@ dependencies = [
name = "runa-protocol" name = "runa-protocol"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"runa-core",
"serde", "serde",
"serde_json", "serde_json",
] ]
@ -609,6 +615,7 @@ dependencies = [
name = "runa-rune" name = "runa-rune"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"runa-core",
"rune", "rune",
] ]

View File

@ -92,7 +92,7 @@ runa> let sender = spawn counter 0
sender = @<actor-reference> sender = @<actor-reference>
runa> let receiver = spawn counter 10 runa> let receiver = spawn counter 10
receiver = @<actor-reference> receiver = @<actor-reference>
runa> send sender 1 5 --to receiver runa> send sender {"op": "send", "value": 5, "target": {"$actor": "receiver"}}
message queued message queued
runa> tick 100 runa> tick 100
runa> inspect sender runa> inspect sender
@ -111,6 +111,15 @@ pausing and resuming or cancelling actor invocations between scheduler ticks.
Runtime users can also supply an explicit state migration closure during Runtime users can also supply an explicit state migration closure during
installation; the CLI's install command preserves state as-is. Use `help` in installation; the CLI's install command preserves state as-is. Use `help` in
the session for the complete command list. the session for the complete command list.
State and messages support null, booleans, signed 64-bit integers, strings,
lists, records, and distinct actor-reference values. `spawn <behavior> <json>`
sets initial state; `send <actor> <json>` sends any supported value. JSON
objects of the form `{"$actor": "name-or-@reference"}` embed an actor reference,
including inside a record or list. See the [runtime guide](docs/runtime.md)
for Rune conversions and value limits. This is protocol version 3; old
four-argument Rune handlers and kind/value/target message envelopes must be
updated to the two-argument handler contract.
See [the feasibility report](docs/rune-feasibility.md) for verified Rune See [the feasibility report](docs/rune-feasibility.md) for verified Rune
capabilities and limitations, and [the runtime guide](docs/runtime.md) for capabilities and limitations, and [the runtime guide](docs/runtime.md) for
the runtime contract. the runtime contract.
@ -142,7 +151,7 @@ test task runs `cargo test --workspace`.
├── Cargo.toml ├── Cargo.toml
├── crates/ ├── crates/
│ ├── runa/ # Main binary │ ├── runa/ # Main binary
│ ├── runa-core/ # Shared stable identity types │ ├── runa-core/ # Shared stable identities and portable actor values
│ ├── runa-actor/ # Actor value contracts │ ├── runa-actor/ # Actor value contracts
│ ├── 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

View File

@ -4,5 +4,4 @@ version = "0.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
nanoid = "0.4"
runa-core = { path = "../runa-core" } runa-core = { path = "../runa-core" }

View File

@ -1,82 +1,12 @@
//! Value contracts for the single-worker actor prototype. //! Value contracts for the single-worker actor prototype.
use runa_core::ImplementationId; use runa_core::ImplementationId;
use std::fmt; pub use runa_core::{ActorRef, Value};
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ActorRef([u8; 21]);
impl ActorRef {
pub fn new() -> Self {
Self::parse(&nanoid::nanoid!()).expect("Nano ID uses the supported alphabet")
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.0).expect("Nano ID is valid UTF-8")
}
pub fn parse(value: &str) -> Option<Self> {
if value.len() != 21
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return None;
}
let mut bytes = [0; 21];
bytes.copy_from_slice(value.as_bytes());
Some(Self(bytes))
}
}
impl Default for ActorRef {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for ActorRef {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "@{}", self.as_str())
}
}
impl fmt::Display for ActorRef {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::ActorRef;
#[test]
fn generated_actor_references_are_parseable_and_display_without_prefix() {
let actor_ref = ActorRef::new();
let parsed = ActorRef::parse(actor_ref.as_str()).expect("generated reference is valid");
assert_eq!(parsed, actor_ref);
assert_eq!(actor_ref.to_string().len(), 21);
assert_eq!(format!("{actor_ref:?}"), format!("@{actor_ref}"));
}
#[test]
fn parsing_rejects_references_outside_the_url_safe_nanoid_shape() {
assert!(ActorRef::parse("too-short").is_none());
assert!(ActorRef::parse("abcdefghijklmnopqrstu!").is_none());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct MailboxId(pub u64); pub struct MailboxId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub type Message = Value;
pub struct Message {
pub kind: i64,
pub value: i64,
pub target_ref: Option<ActorRef>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status { pub enum Status {
@ -94,7 +24,7 @@ pub struct ActorSnapshot {
pub mailbox: MailboxId, pub mailbox: MailboxId,
pub implementation: ImplementationId, pub implementation: ImplementationId,
pub incarnation: u64, pub incarnation: u64,
pub state: i64, pub state: Value,
pub status: Status, pub status: Status,
pub execution: Option<ExecutionSnapshot>, pub execution: Option<ExecutionSnapshot>,
pub queued: Vec<Message>, pub queued: Vec<Message>,
@ -119,5 +49,5 @@ pub enum Event {
Resumed(ActorRef), Resumed(ActorRef),
InvocationCancelled(ActorRef), InvocationCancelled(ActorRef),
BehaviorInstalled(ActorRef, ImplementationId), BehaviorInstalled(ActorRef, ImplementationId),
StateMigrated(ActorRef, i64, i64), StateMigrated(ActorRef, Value, Value),
} }

View File

@ -25,7 +25,7 @@ impl CompileFingerprint {
rune_version: runa_rune::RUNE_VERSION.to_owned(), rune_version: runa_rune::RUNE_VERSION.to_owned(),
compiler_settings: "default;features:default".to_owned(), compiler_settings: "default;features:default".to_owned(),
native_modules: runa_rune::COMPILER_CONFIGURATION_ID.to_owned(), native_modules: runa_rune::COMPILER_CONFIGURATION_ID.to_owned(),
runtime_abi: "runa-runtime-abi:v1".to_owned(), runtime_abi: "runa-runtime-abi:v3:structured-values".to_owned(),
target: env!("RUNA_BUILD_TARGET").to_owned(), target: env!("RUNA_BUILD_TARGET").to_owned(),
} }
} }

View File

@ -4,3 +4,5 @@ version = "0.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
nanoid = "0.4"
serde = { version = "1", features = ["derive"] }

View File

@ -2,6 +2,9 @@
use std::fmt; use std::fmt;
mod value;
pub use value::{ActorRef, MAX_VALUE_BYTES, MAX_VALUE_DEPTH, MAX_VALUE_NODES, Value};
macro_rules! digest_id { macro_rules! digest_id {
($name:ident, $prefix:literal) => { ($name:ident, $prefix:literal) => {
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]

View File

@ -0,0 +1,170 @@
use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
pub const MAX_VALUE_DEPTH: usize = 32;
pub const MAX_VALUE_NODES: usize = 4096;
pub const MAX_VALUE_BYTES: usize = 1024 * 1024;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ActorRef([u8; 21]);
impl ActorRef {
pub fn new() -> Self {
Self::parse(&nanoid::nanoid!()).expect("Nano ID uses the supported alphabet")
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.0).expect("Nano ID is valid UTF-8")
}
pub fn parse(value: &str) -> Option<Self> {
if value.len() != 21
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return None;
}
let mut bytes = [0; 21];
bytes.copy_from_slice(value.as_bytes());
Some(Self(bytes))
}
}
impl Default for ActorRef {
fn default() -> Self {
Self::new()
}
}
impl TryFrom<String> for ActorRef {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
.ok_or_else(|| "actor reference must be a 21-character URL-safe NanoID".to_owned())
}
}
impl From<ActorRef> for String {
fn from(value: ActorRef) -> Self {
value.to_string()
}
}
impl fmt::Debug for ActorRef {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "@{}", self.as_str())
}
}
impl fmt::Display for ActorRef {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
/// Owned actor data, never a shared Rune heap object or execution handle.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "type",
content = "data",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum Value {
Null,
Bool(bool),
Integer(i64),
String(String),
List(Vec<Value>),
Record(BTreeMap<String, Value>),
ActorRef(ActorRef),
}
impl Value {
pub fn validate(&self) -> Result<(), String> {
fn visit(
value: &Value,
depth: usize,
nodes: &mut usize,
bytes: &mut usize,
) -> Result<(), String> {
*nodes += 1;
if depth > MAX_VALUE_DEPTH || *nodes > MAX_VALUE_NODES {
return Err("actor value exceeds depth or node limit".to_owned());
}
match value {
Value::String(value) => *bytes += value.len(),
Value::List(values) => {
for value in values {
visit(value, depth + 1, nodes, bytes)?;
}
}
Value::Record(values) => {
for (key, value) in values {
*bytes += key.len();
visit(value, depth + 1, nodes, bytes)?;
}
}
_ => {}
}
if *bytes > MAX_VALUE_BYTES {
return Err("actor value exceeds string byte limit".to_owned());
}
Ok(())
}
visit(self, 0, &mut 0, &mut 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_references_round_trip_and_are_distinct_values() {
let reference = ActorRef::new();
assert_eq!(ActorRef::parse(reference.as_str()), Some(reference));
assert_eq!(format!("{reference:?}"), format!("@{reference}"));
assert_ne!(
Value::ActorRef(reference),
Value::String(reference.to_string())
);
assert!(ActorRef::parse("too-short").is_none());
assert!(ActorRef::parse("abcdefghijklmnopqrst!").is_none());
}
#[test]
fn value_limits_are_inclusive() {
let mut value = Value::Null;
for _ in 0..MAX_VALUE_DEPTH {
value = Value::List(vec![value]);
}
assert!(value.validate().is_ok());
assert!(Value::List(vec![value]).validate().is_err());
assert!(
Value::List(vec![Value::Null; MAX_VALUE_NODES - 1])
.validate()
.is_ok()
);
assert!(
Value::List(vec![Value::Null; MAX_VALUE_NODES])
.validate()
.is_err()
);
assert!(
Value::String("x".repeat(MAX_VALUE_BYTES))
.validate()
.is_ok()
);
assert!(
Value::String("x".repeat(MAX_VALUE_BYTES + 1))
.validate()
.is_err()
);
}
}

View File

@ -5,7 +5,7 @@ use std::sync::Arc;
use runa_actor::{ActorRef, ActorSnapshot, Event, Message}; use runa_actor::{ActorRef, ActorSnapshot, Event, Message};
use runa_code::{BehaviorSnapshot, CodeStore}; use runa_code::{BehaviorSnapshot, CodeStore};
use runa_core::ImplementationId; use runa_core::{ImplementationId, Value};
use runa_runtime::{Runtime, RuntimeResult}; use runa_runtime::{Runtime, RuntimeResult};
pub struct ImageApi { pub struct ImageApi {
@ -72,7 +72,7 @@ impl ImageApi {
pub fn spawn( pub fn spawn(
&mut self, &mut self,
behavior_name: &str, behavior_name: &str,
state: i64, state: Value,
capacity: NonZeroUsize, capacity: NonZeroUsize,
) -> RuntimeResult<ActorRef> { ) -> RuntimeResult<ActorRef> {
let behavior = self let behavior = self
@ -136,7 +136,7 @@ impl ImageApi {
&mut self, &mut self,
actor: ActorRef, actor: ActorRef,
behavior_name: &str, behavior_name: &str,
migrate: impl FnOnce(i64) -> RuntimeResult<i64>, migrate: impl FnOnce(Value) -> RuntimeResult<Value>,
) -> RuntimeResult<ImplementationId> { ) -> RuntimeResult<ImplementationId> {
let behavior = self let behavior = self
.code .code
@ -152,6 +152,17 @@ impl ImageApi {
mod tests { mod tests {
use super::*; 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 capacity(n: usize) -> NonZeroUsize { fn capacity(n: usize) -> NonZeroUsize {
NonZeroUsize::new(n).expect("capacity must be positive") NonZeroUsize::new(n).expect("capacity must be positive")
} }
@ -162,24 +173,17 @@ mod tests {
let behavior = image.publish_behavior( let behavior = image.publish_behavior(
"counter", "counter",
None, None,
"pub fn handle(state, kind, value, target_ref) { (state + value, []) }", "pub fn handle(state, message) { (state + message.value, []) }",
)?; )?;
assert_eq!( assert_eq!(
image.behavior_source("counter", behavior.revision)?, image.behavior_source("counter", behavior.revision)?,
"pub fn handle(state, kind, value, target_ref) { (state + value, []) }" "pub fn handle(state, message) { (state + message.value, []) }"
); );
let actor = image.spawn("counter", 5, capacity(2))?; let actor = image.spawn("counter", Value::Integer(5), capacity(2))?;
image.send( image.send(actor, test_message(0, 7, None))?;
actor,
Message {
kind: 0,
value: 7,
target_ref: None,
},
)?;
image.tick(100, capacity(10)); image.tick(100, capacity(10));
assert_eq!(image.inspect(actor)?.state, 12); assert_eq!(image.inspect(actor)?.state, Value::Integer(12));
assert_eq!(image.inspect(actor)?.implementation, behavior.current); assert_eq!(image.inspect(actor)?.implementation, behavior.current);
assert!( assert!(
image image
@ -196,14 +200,14 @@ mod tests {
let first = image.publish_behavior( let first = image.publish_behavior(
"counter", "counter",
None, None,
"pub fn handle(state, kind, value, target_ref) { (state + value, []) }", "pub fn handle(state, message) { (state + message.value, []) }",
)?; )?;
assert!( assert!(
image image
.publish_behavior( .publish_behavior(
"counter", "counter",
None, None,
"pub fn handle(state, kind, value, target_ref) { (state - value, []) }" "pub fn handle(state, message) { (state - message.value, []) }"
) )
.is_err() .is_err()
); );
@ -217,19 +221,24 @@ mod tests {
let first = image.publish_behavior( let first = image.publish_behavior(
"counter", "counter",
None, None,
"pub fn handle(state, kind, value, target_ref) { (state + value, []) }", "pub fn handle(state, message) { (state + message.value, []) }",
)?; )?;
image.publish_behavior( image.publish_behavior(
"counter", "counter",
Some(first.revision), Some(first.revision),
"pub fn handle(state, kind, value, target_ref) { (state * value, []) }", "pub fn handle(state, message) { (state * message.value, []) }",
)?; )?;
let actor = image.spawn("counter", 5, capacity(2))?; let actor = image.spawn("counter", Value::Integer(5), capacity(2))?;
let implementation = image.install_with_migration(actor, "counter", |state| { let implementation = image.install_with_migration(actor, "counter", |state| {
Ok(state.checked_mul(10).ok_or("migration overflow")?) let Value::Integer(state) = state else {
return Err("expected integer".into());
};
Ok(Value::Integer(
state.checked_mul(10).ok_or("migration overflow")?,
))
})?; })?;
assert_eq!(image.inspect(actor)?.state, 50); assert_eq!(image.inspect(actor)?.state, Value::Integer(50));
assert_eq!(image.inspect(actor)?.implementation, implementation); assert_eq!(image.inspect(actor)?.implementation, implementation);
Ok(()) Ok(())
} }

View File

@ -4,6 +4,7 @@ version = "0.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
runa-core = { path = "../runa-core" }
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
[dev-dependencies] [dev-dependencies]

View File

@ -1,8 +1,9 @@
//! Versioned, transport-neutral wire contracts for Runa clients and servers. //! Versioned, transport-neutral wire contracts for Runa clients and servers.
pub use runa_core::Value;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u16 = 2; pub const PROTOCOL_VERSION: u16 = 3;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@ -53,18 +54,17 @@ impl Request {
Command::Spawn { Command::Spawn {
behavior_name, behavior_name,
mailbox_capacity, mailbox_capacity,
.. state,
} => { } => {
validate_name(behavior_name)?; validate_name(behavior_name)?;
state.validate().map_err(invalid_request)?;
if *mailbox_capacity == 0 { if *mailbox_capacity == 0 {
return Err(invalid_request("mailbox_capacity must be positive")); return Err(invalid_request("mailbox_capacity must be positive"));
} }
} }
Command::Send { actor_ref, message } => { Command::Send { actor_ref, message } => {
validate_actor_ref(actor_ref)?; validate_actor_ref(actor_ref)?;
if let Some(target_ref) = &message.target_ref { message.validate().map_err(invalid_request)?;
validate_actor_ref(target_ref)?;
}
} }
Command::InspectActor { actor_ref } Command::InspectActor { actor_ref }
| Command::StopActor { actor_ref } | Command::StopActor { actor_ref }
@ -116,7 +116,7 @@ pub enum Command {
}, },
Spawn { Spawn {
behavior_name: String, behavior_name: String,
state: i64, state: Value,
mailbox_capacity: usize, mailbox_capacity: usize,
}, },
Send { Send {
@ -217,8 +217,8 @@ pub enum EventDto {
}, },
StateMigrated { StateMigrated {
actor_ref: String, actor_ref: String,
previous_state: i64, previous_state: Value,
current_state: i64, current_state: Value,
}, },
} }
@ -238,14 +238,7 @@ pub struct BehaviorVersionDto {
pub implementation_id: String, pub implementation_id: String,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub type MessageDto = Value;
#[serde(deny_unknown_fields)]
pub struct MessageDto {
pub kind: i64,
pub value: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@ -254,7 +247,7 @@ pub struct ActorSnapshotDto {
pub mailbox_id: u64, pub mailbox_id: u64,
pub implementation_id: String, pub implementation_id: String,
pub incarnation: u64, pub incarnation: u64,
pub state: i64, pub state: Value,
pub status: ActorStatusDto, pub status: ActorStatusDto,
pub execution: Option<ExecutionSnapshotDto>, pub execution: Option<ExecutionSnapshotDto>,
pub queued: Vec<MessageDto>, pub queued: Vec<MessageDto>,
@ -349,6 +342,17 @@ fn invalid_request(message: impl Into<String>) -> ProtocolError {
mod tests { mod tests {
use super::*; 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 { fn request(command: Command) -> Request {
Request { Request {
version: PROTOCOL_VERSION, version: PROTOCOL_VERSION,
@ -361,11 +365,7 @@ mod tests {
fn request_uses_a_stable_tagged_wire_shape() { fn request_uses_a_stable_tagged_wire_shape() {
let value = serde_json::to_value(request(Command::Send { let value = serde_json::to_value(request(Command::Send {
actor_ref: "xK8_pQ2-Lm5_012345678".to_owned(), actor_ref: "xK8_pQ2-Lm5_012345678".to_owned(),
message: MessageDto { message: Value::Integer(9),
kind: 2,
value: 9,
target_ref: None,
},
})) }))
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@ -377,7 +377,7 @@ mod tests {
"operation": "send", "operation": "send",
"parameters": { "parameters": {
"actor_ref": "xK8_pQ2-Lm5_012345678", "actor_ref": "xK8_pQ2-Lm5_012345678",
"message": { "kind": 2, "value": 9 } "message": { "type": "integer", "data": 9 }
} }
} }
}) })
@ -388,7 +388,7 @@ mod tests {
fn requests_validate_versions_and_required_positive_values() { fn requests_validate_versions_and_required_positive_values() {
let mut request = request(Command::Spawn { let mut request = request(Command::Spawn {
behavior_name: "counter".to_owned(), behavior_name: "counter".to_owned(),
state: 0, state: Value::Integer(0),
mailbox_capacity: 1, mailbox_capacity: 1,
}); });
assert_eq!(request.validate(), Ok(())); assert_eq!(request.validate(), Ok(()));
@ -402,7 +402,7 @@ mod tests {
request.version = PROTOCOL_VERSION; request.version = PROTOCOL_VERSION;
request.command = Command::Spawn { request.command = Command::Spawn {
behavior_name: "counter".to_owned(), behavior_name: "counter".to_owned(),
state: 0, state: Value::Integer(0),
mailbox_capacity: 0, mailbox_capacity: 0,
}; };
assert_eq!( assert_eq!(
@ -431,29 +431,15 @@ mod tests {
} }
#[test] #[test]
fn send_target_reference_is_optional_and_validated_when_present() { fn nested_references_round_trip_and_malformed_references_are_rejected() {
let mut request = request(Command::Send { let request = request(Command::Send {
actor_ref: "xK8_pQ2-Lm5_012345678".to_owned(), actor_ref: "xK8_pQ2-Lm5_012345678".to_owned(),
message: MessageDto { message: test_message(2, 9, runa_core::ActorRef::parse("Hj2_9sM0-pQ4_7aBcD3eF")),
kind: 2,
value: 9,
target_ref: Some("Hj2_9sM0-pQ4_7aBcD3eF".to_owned()),
},
}); });
assert_eq!(request.validate(), Ok(())); assert_eq!(request.validate(), Ok(()));
let encoded = serde_json::to_string(&request).unwrap();
request.command = Command::Send { assert_eq!(serde_json::from_str::<Request>(&encoded).unwrap(), request);
actor_ref: "xK8_pQ2-Lm5_012345678".to_owned(), assert!(serde_json::from_str::<Value>(r#"{"type":"actor_ref","data":"bad"}"#).is_err());
message: MessageDto {
kind: 2,
value: 9,
target_ref: Some("bad".to_owned()),
},
};
assert_eq!(
request.validate().unwrap_err().code,
ErrorCode::InvalidRequest
);
} }
#[test] #[test]
@ -468,14 +454,61 @@ mod tests {
assert_eq!(decoded, response); 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] #[test]
fn event_envelopes_are_versioned_and_serializable() { fn event_envelopes_are_versioned_and_serializable() {
let event = ProtocolEvent { let event = ProtocolEvent {
version: PROTOCOL_VERSION, version: PROTOCOL_VERSION,
event: EventDto::StateMigrated { event: EventDto::StateMigrated {
actor_ref: "actor_ref_000000000000".to_owned(), actor_ref: "actor_ref_000000000000".to_owned(),
previous_state: 4, previous_state: Value::Integer(4),
current_state: 40, current_state: Value::Integer(40),
}, },
}; };
let encoded = serde_json::to_string(&event).unwrap(); let encoded = serde_json::to_string(&event).unwrap();

View File

@ -4,4 +4,5 @@ version = "0.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
runa-core = { path = "../runa-core" }
rune = "=0.14.2" rune = "=0.14.2"

View File

@ -14,10 +14,12 @@ use rune::{Context, Diagnostics, Module, Source, Sources, Vm};
pub const RUNE_VERSION: &str = "0.14.2"; pub const RUNE_VERSION: &str = "0.14.2";
pub const COMPILER_CONFIGURATION_ID: &str = pub const COMPILER_CONFIGURATION_ID: &str =
"default-modules; native-functions:v1:native_double,native_suspend"; "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1";
mod execution; mod execution;
pub use execution::{Invocation, InvocationProgress, Slice}; pub use execution::{Invocation, InvocationProgress, Slice};
mod value;
pub use value::{WireTransition, value_from_rune, value_to_rune};
/// Errors remain visible to the caller; compilation also emits source diagnostics. /// Errors remain visible to the caller; compilation also emits source diagnostics.
pub type ExperimentResult<T> = Result<T, Box<dyn Error>>; pub type ExperimentResult<T> = Result<T, Box<dyn Error>>;
@ -58,6 +60,24 @@ impl CompiledProgram {
pub fn compile_with_module(source: &str, module: Module) -> ExperimentResult<Self> { pub fn compile_with_module(source: &str, module: Module) -> ExperimentResult<Self> {
let mut context = Context::with_default_modules()?; let mut context = Context::with_default_modules()?;
let mut values = Module::with_crate("runa")?;
values.ty::<value::ActorReference>()?;
values.associated_function(
&rune::runtime::Protocol::PARTIAL_EQ,
|left: &value::ActorReference, right: &value::ActorReference| left.0 == right.0,
)?;
values.associated_function(
&rune::runtime::Protocol::EQ,
|left: &value::ActorReference, right: &value::ActorReference| left.0 == right.0,
)?;
values.associated_function(
&rune::runtime::Protocol::CLONE,
|reference: &value::ActorReference| reference.clone(),
)?;
values.associated_function("clone", |reference: &value::ActorReference| {
reference.clone()
})?;
context.install(values)?;
context.install(module)?; context.install(module)?;
let mut sources = Sources::new(); let mut sources = Sources::new();

View File

@ -0,0 +1,185 @@
use runa_core::{ActorRef, MAX_VALUE_BYTES, MAX_VALUE_DEPTH, MAX_VALUE_NODES, Value};
use rune::runtime::{Object, Vec as RuneVec};
use rune::{Any, TypeHash, Value as RuneValue};
use crate::ExperimentResult;
pub type WireTransition = (RuneValue, Vec<(RuneValue, RuneValue)>);
#[derive(Any, Clone)]
#[rune(item = ::runa)]
pub(crate) struct ActorReference(pub ActorRef);
/// Allocate a fresh Rune object graph for each invocation input.
pub fn value_to_rune(value: &Value) -> ExperimentResult<RuneValue> {
value.validate()?;
fn convert(value: &Value) -> ExperimentResult<RuneValue> {
Ok(match value {
Value::Null => RuneValue::from(()),
Value::Bool(value) => rune::to_value(*value)?,
Value::Integer(value) => rune::to_value(*value)?,
Value::String(value) => rune::to_value(value.as_str())?,
Value::ActorRef(value) => RuneValue::new(ActorReference(*value))?,
Value::List(values) => {
let mut result = RuneVec::with_capacity(values.len())?;
for value in values {
result.push(convert(value)?)?;
}
RuneValue::new(result)?
}
Value::Record(values) => {
let mut result = Object::with_capacity(values.len())?;
for (key, value) in values {
result.insert(
rune::alloc::String::try_from(key.as_str())?,
convert(value)?,
)?;
}
RuneValue::new(result)?
}
})
}
convert(value)
}
/// Copy only portable data out of Rune; cycles and unsupported objects fail.
pub fn value_from_rune(value: &RuneValue) -> ExperimentResult<Value> {
fn convert(
value: &RuneValue,
depth: usize,
nodes: &mut usize,
bytes: &mut usize,
) -> ExperimentResult<Value> {
*nodes += 1;
if depth > MAX_VALUE_DEPTH || *nodes > MAX_VALUE_NODES {
return Err("Rune actor value exceeds depth or node limit (possibly cyclic)".into());
}
let hash = value.type_hash();
let result = if value.into_unit().is_ok() {
Value::Null
} else if hash == bool::HASH {
Value::Bool(value.as_bool()?)
} else if hash == i64::HASH {
Value::Integer(value.as_integer()?)
} else if hash == rune::alloc::String::HASH {
let value = value.borrow_string_ref()?;
*bytes += value.len();
if *bytes > MAX_VALUE_BYTES {
return Err("Rune actor value exceeds string byte limit".into());
}
Value::String(value.to_string())
} else if hash == ActorReference::HASH {
Value::ActorRef(value.borrow_ref::<ActorReference>()?.0)
} else if hash == RuneVec::HASH {
let values = value.borrow_ref::<RuneVec>()?;
let mut result = Vec::new();
for value in values.iter() {
result.push(convert(value, depth + 1, nodes, bytes)?);
}
Value::List(result)
} else if hash == Object::HASH {
let values = value.borrow_ref::<Object>()?;
let mut result = std::collections::BTreeMap::new();
for (key, value) in values.iter() {
*bytes += key.len();
if *bytes > MAX_VALUE_BYTES {
return Err("Rune actor value exceeds string byte limit".into());
}
result.insert(key.to_string(), convert(value, depth + 1, nodes, bytes)?);
}
Value::Record(result)
} else {
return Err(format!("unsupported Rune actor value: {}", value.type_info()).into());
};
Ok(result)
}
convert(value, 0, &mut 0, &mut 0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CompiledProgram;
use std::collections::BTreeMap;
#[test]
fn portable_values_round_trip_without_turning_strings_into_references() -> ExperimentResult<()>
{
let actor = ActorRef::new();
let value = Value::Record(BTreeMap::from([(
"values".to_owned(),
Value::List(vec![
Value::Null,
Value::Bool(true),
Value::Integer(i64::MIN),
Value::Integer(i64::MAX),
Value::String("hello world".to_owned()),
Value::ActorRef(actor),
Value::String(actor.to_string()),
Value::List(vec![]),
Value::Record(BTreeMap::new()),
]),
)]));
assert_eq!(value_from_rune(&value_to_rune(&value)?)?, value);
Ok(())
}
#[test]
fn rune_mutations_do_not_alias_host_values_or_other_invocation_inputs() -> ExperimentResult<()>
{
let original = Value::Record(BTreeMap::from([(
"items".to_owned(),
Value::List(vec![Value::Integer(1)]),
)]));
let program =
CompiledProgram::compile("pub fn change(state) { state.items.push(2); state }")?;
let first = value_to_rune(&original)?;
let second = value_to_rune(&original)?;
let result = program.vm().call(["change"], (first,))?;
assert_eq!(value_from_rune(&second)?, original);
assert_ne!(value_from_rune(&result)?, original);
Ok(())
}
#[test]
fn references_can_be_compared_cloned_and_retained_in_rune_records() -> ExperimentResult<()> {
let actor = ActorRef::new();
let other = ActorRef::new();
let program = CompiledProgram::compile(
"pub fn check(left, right) { #{same: left == right, reference: left.clone()} }",
)?;
for (right, expected) in [(actor, true), (other, false)] {
let result = program.vm().call(
["check"],
(
value_to_rune(&Value::ActorRef(actor))?,
value_to_rune(&Value::ActorRef(right))?,
),
)?;
assert_eq!(
value_from_rune(&result)?,
Value::Record(BTreeMap::from([
("same".to_owned(), Value::Bool(expected)),
("reference".to_owned(), Value::ActorRef(actor)),
]))
);
}
Ok(())
}
#[test]
fn cyclic_and_unsupported_rune_values_are_explicit_errors() -> ExperimentResult<()> {
for source in [
"pub fn make() { 1.5 }",
"pub fn make() { (1, 2) }",
"pub fn make() { Some(1) }",
"pub fn make() { let x = []; x.push(x); x }",
] {
let program = CompiledProgram::compile(source)?;
let value = program.vm().call(["make"], ())?;
assert!(value_from_rune(&value).is_err(), "{source}");
}
Ok(())
}
}

File diff suppressed because it is too large Load Diff

View File

@ -6,12 +6,11 @@ use std::num::NonZeroUsize;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use runa_actor::{ActorRef, Event, Message, Status}; use runa_actor::{ActorRef, Event, Status};
use runa_image_api::ImageApi; use runa_image_api::ImageApi;
use runa_protocol::{ use runa_protocol::{
ActorSnapshotDto, ActorStatusDto, BehaviorDto, BehaviorVersionDto, Command, ErrorCode, ActorSnapshotDto, ActorStatusDto, BehaviorDto, BehaviorVersionDto, Command, ErrorCode,
EventDto, ExecutionSnapshotDto, MessageDto, PROTOCOL_VERSION, ProtocolError, Reply, Request, EventDto, ExecutionSnapshotDto, PROTOCOL_VERSION, ProtocolError, Reply, Request, Response,
Response,
}; };
pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024; pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024;
@ -347,7 +346,7 @@ impl Server {
Command::Send { actor_ref, message } => { Command::Send { actor_ref, message } => {
let actor_ref = parse_actor_ref(&actor_ref)?; let actor_ref = parse_actor_ref(&actor_ref)?;
self.image self.image
.send(actor_ref, message_from_dto(message)?) .send(actor_ref, message)
.map(|()| Reply::Acknowledged) .map(|()| Reply::Acknowledged)
.map_err(api_error) .map_err(api_error)
} }
@ -447,7 +446,7 @@ fn actor_snapshot_dto(snapshot: &runa_actor::ActorSnapshot) -> ActorSnapshotDto
mailbox_id: snapshot.mailbox.0, mailbox_id: snapshot.mailbox.0,
implementation_id: snapshot.implementation.to_string(), implementation_id: snapshot.implementation.to_string(),
incarnation: snapshot.incarnation, incarnation: snapshot.incarnation,
state: snapshot.state, state: snapshot.state.clone(),
status: match &snapshot.status { status: match &snapshot.status {
Status::Idle => ActorStatusDto::Idle, Status::Idle => ActorStatusDto::Idle,
Status::Running => ActorStatusDto::Running, Status::Running => ActorStatusDto::Running,
@ -461,33 +460,11 @@ fn actor_snapshot_dto(snapshot: &runa_actor::ActorSnapshot) -> ActorSnapshotDto
instruction_pointer: execution.instruction_pointer, instruction_pointer: execution.instruction_pointer,
call_depth: execution.call_depth, call_depth: execution.call_depth,
}), }),
queued: snapshot.queued.iter().copied().map(message_dto).collect(), queued: snapshot.queued.clone(),
reserved: snapshot.reserved.map(message_dto), reserved: snapshot.reserved.clone(),
} }
} }
fn message_dto(message: Message) -> MessageDto {
MessageDto {
kind: message.kind,
value: message.value,
target_ref: message
.target_ref
.map(|actor_ref| actor_ref.as_str().to_owned()),
}
}
fn message_from_dto(message: MessageDto) -> Result<Message, ProtocolError> {
Ok(Message {
kind: message.kind,
value: message.value,
target_ref: message
.target_ref
.as_deref()
.map(parse_actor_ref)
.transpose()?,
})
}
fn event_dto(event: &Event) -> EventDto { fn event_dto(event: &Event) -> EventDto {
match event { match event {
Event::Spawned(actor_ref) => EventDto::Spawned { Event::Spawned(actor_ref) => EventDto::Spawned {
@ -521,8 +498,8 @@ fn event_dto(event: &Event) -> EventDto {
}, },
Event::StateMigrated(actor_ref, previous_state, current_state) => EventDto::StateMigrated { Event::StateMigrated(actor_ref, previous_state, current_state) => EventDto::StateMigrated {
actor_ref: actor_ref.as_str().to_owned(), actor_ref: actor_ref.as_str().to_owned(),
previous_state: *previous_state, previous_state: previous_state.clone(),
current_state: *current_state, current_state: current_state.clone(),
}, },
} }
} }
@ -564,6 +541,19 @@ fn parse_actor_ref(value: &str) -> Result<ActorRef, ProtocolError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use runa_protocol::Value;
fn test_message(kind: i64, value: i64, target: Option<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),
),
]))
}
use runa_protocol::{Command, PROTOCOL_VERSION}; use runa_protocol::{Command, PROTOCOL_VERSION};
use std::io::{BufRead, BufReader, Cursor}; use std::io::{BufRead, BufReader, Cursor};
use std::net::Shutdown; use std::net::Shutdown;
@ -580,8 +570,7 @@ mod tests {
let response = server.dispatch(request(Command::PublishBehavior { let response = server.dispatch(request(Command::PublishBehavior {
name: "counter".to_owned(), name: "counter".to_owned(),
expected_revision: None, expected_revision: None,
source: "pub fn handle(state, kind, value, target_ref) { (state + value, []) }" source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned(),
.to_owned(),
})); }));
match response.result.unwrap() { match response.result.unwrap() {
Reply::Behavior(behavior) => behavior, Reply::Behavior(behavior) => behavior,
@ -598,7 +587,7 @@ mod tests {
let spawned = server.dispatch(request(Command::Spawn { let spawned = server.dispatch(request(Command::Spawn {
behavior_name: "counter".to_owned(), behavior_name: "counter".to_owned(),
state: 5, state: Value::Integer(5),
mailbox_capacity: 2, mailbox_capacity: 2,
})); }));
let actor_ref = match spawned.result.unwrap() { let actor_ref = match spawned.result.unwrap() {
@ -610,11 +599,7 @@ mod tests {
let sent = server.dispatch(request(Command::Send { let sent = server.dispatch(request(Command::Send {
actor_ref: actor_ref.clone(), actor_ref: actor_ref.clone(),
message: MessageDto { message: test_message(0, 7, None),
kind: 0,
value: 7,
target_ref: None,
},
})); }));
assert_eq!(sent.result, Ok(Reply::Acknowledged)); assert_eq!(sent.result, Ok(Reply::Acknowledged));
server.dispatch(request(Command::Tick { server.dispatch(request(Command::Tick {
@ -626,7 +611,7 @@ mod tests {
actor_ref: actor_ref.clone(), actor_ref: actor_ref.clone(),
})); }));
match inspected.result.unwrap() { match inspected.result.unwrap() {
Reply::Actor(snapshot) => assert_eq!(snapshot.state, 12), Reply::Actor(snapshot) => assert_eq!(snapshot.state, Value::Integer(12)),
other => panic!("unexpected reply: {other:?}"), other => panic!("unexpected reply: {other:?}"),
} }
@ -664,7 +649,7 @@ mod tests {
let conflict = server.dispatch(request(Command::PublishBehavior { let conflict = server.dispatch(request(Command::PublishBehavior {
name: "counter".to_owned(), name: "counter".to_owned(),
expected_revision: Some(99), expected_revision: Some(99),
source: "pub fn handle(state, kind, value, target_ref) { (state, []) }".to_owned(), source: "pub fn handle(state, message) { (state, []) }".to_owned(),
})); }));
assert_eq!(conflict.result.unwrap_err().code, ErrorCode::Conflict); assert_eq!(conflict.result.unwrap_err().code, ErrorCode::Conflict);
} }
@ -676,8 +661,7 @@ mod tests {
let updated = server.dispatch(request(Command::PublishBehavior { let updated = server.dispatch(request(Command::PublishBehavior {
name: "counter".to_owned(), name: "counter".to_owned(),
expected_revision: Some(first.revision), expected_revision: Some(first.revision),
source: "pub fn handle(state, kind, value, target_ref) { (state * value, []) }" source: "pub fn handle(state, message) { (state * message.value, []) }".to_owned(),
.to_owned(),
})); }));
assert!(matches!(updated.result, Ok(Reply::Behavior(_)))); assert!(matches!(updated.result, Ok(Reply::Behavior(_))));
@ -688,15 +672,14 @@ mod tests {
assert_eq!( assert_eq!(
source.result, source.result,
Ok(Reply::BehaviorSource { Ok(Reply::BehaviorSource {
source: "pub fn handle(state, kind, value, target_ref) { (state + value, []) }" source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned()
.to_owned()
}) })
); );
let actor_ref = match server let actor_ref = match server
.dispatch(request(Command::Spawn { .dispatch(request(Command::Spawn {
behavior_name: "counter".to_owned(), behavior_name: "counter".to_owned(),
state: 3, state: Value::Integer(3),
mailbox_capacity: 1, mailbox_capacity: 1,
})) }))
.result .result
@ -720,12 +703,11 @@ mod tests {
let publish_request = request(Command::PublishBehavior { let publish_request = request(Command::PublishBehavior {
name: "counter".to_owned(), name: "counter".to_owned(),
expected_revision: None, expected_revision: None,
source: "pub fn handle(state, kind, value, target_ref) { (state + value, []) }" source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned(),
.to_owned(),
}); });
let mut spawn_request = request(Command::Spawn { let mut spawn_request = request(Command::Spawn {
behavior_name: "counter".to_owned(), behavior_name: "counter".to_owned(),
state: 4, state: Value::Integer(4),
mailbox_capacity: 2, mailbox_capacity: 2,
}); });
spawn_request.request_id = "spawn-request".to_owned(); spawn_request.request_id = "spawn-request".to_owned();
@ -764,7 +746,7 @@ mod tests {
)); ));
assert!(matches!( assert!(matches!(
&responses[2].result, &responses[2].result,
Ok(Reply::Actors(actors)) if actors.len() == 1 && actors[0].state == 4 Ok(Reply::Actors(actors)) if actors.len() == 1 && actors[0].state == Value::Integer(4)
)); ));
} }

View File

@ -1,10 +1,11 @@
use runa_protocol::Value;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::net::{Shutdown, SocketAddr, TcpStream}; use std::net::{Shutdown, SocketAddr, TcpStream};
use std::process::{Child, Command as ProcessCommand, Stdio}; use std::process::{Child, Command as ProcessCommand, Stdio};
use std::time::Duration; use std::time::Duration;
use runa_client::{LocalClient, TcpClient}; use runa_client::{LocalClient, TcpClient};
use runa_protocol::{Command, MessageDto, PROTOCOL_VERSION, Reply, Request, Response}; use runa_protocol::{Command, PROTOCOL_VERSION, Reply, Request, Response};
struct ServerProcess(Child); struct ServerProcess(Child);
@ -23,8 +24,7 @@ fn local_client_launches_server_and_operates_the_same_live_image() {
.request(Command::PublishBehavior { .request(Command::PublishBehavior {
name: "counter".to_owned(), name: "counter".to_owned(),
expected_revision: None, expected_revision: None,
source: "pub fn handle(state, kind, value, target_ref) { (state + value, []) }" source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned(),
.to_owned(),
}) })
.unwrap(); .unwrap();
assert_eq!(published.request_id, "runa-client-1"); assert_eq!(published.request_id, "runa-client-1");
@ -33,7 +33,7 @@ fn local_client_launches_server_and_operates_the_same_live_image() {
let spawned = client let spawned = client
.request(Command::Spawn { .request(Command::Spawn {
behavior_name: "counter".to_owned(), behavior_name: "counter".to_owned(),
state: 5, state: Value::Integer(5),
mailbox_capacity: 2, mailbox_capacity: 2,
}) })
.unwrap(); .unwrap();
@ -47,11 +47,7 @@ fn local_client_launches_server_and_operates_the_same_live_image() {
client client
.request(Command::Send { .request(Command::Send {
actor_ref: actor_ref.clone(), actor_ref: actor_ref.clone(),
message: MessageDto { message: test_message(0, 7, None),
kind: 0,
value: 7,
target_ref: None,
},
}) })
.unwrap(); .unwrap();
client client
@ -63,7 +59,7 @@ fn local_client_launches_server_and_operates_the_same_live_image() {
let inspected = client.request(Command::InspectActor { actor_ref }).unwrap(); let inspected = client.request(Command::InspectActor { actor_ref }).unwrap();
assert!(matches!( assert!(matches!(
inspected.result, inspected.result,
Ok(Reply::Actor(actor)) if actor.state == 12 Ok(Reply::Actor(actor)) if actor.state == Value::Integer(12)
)); ));
assert!(client.shutdown().unwrap().success()); assert!(client.shutdown().unwrap().success());
} }
@ -110,7 +106,7 @@ fn tcp_server_accepts_local_protocol_requests() {
let actor_ref = match first let actor_ref = match first
.request(Command::Spawn { .request(Command::Spawn {
behavior_name: "counter".to_owned(), behavior_name: "counter".to_owned(),
state: 5, state: Value::Integer(5),
mailbox_capacity: 2, mailbox_capacity: 2,
}) })
.unwrap() .unwrap()
@ -122,7 +118,7 @@ fn tcp_server_accepts_local_protocol_requests() {
}; };
assert!(matches!(second.request(Command::InspectActor { assert!(matches!(second.request(Command::InspectActor {
actor_ref: actor_ref.clone(), actor_ref: actor_ref.clone(),
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == 5)); }).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == Value::Integer(5)));
assert!(matches!( assert!(matches!(
first first
.request(Command::RollbackBehavior { .request(Command::RollbackBehavior {
@ -150,11 +146,10 @@ fn tcp_server_accepts_local_protocol_requests() {
reconnected reconnected
.request(Command::Send { .request(Command::Send {
actor_ref: actor_ref.clone(), actor_ref: actor_ref.clone(),
message: MessageDto { message: Value::Record(std::collections::BTreeMap::from([
kind: 0, ("op".to_owned(), Value::String("add".to_owned())),
value: 7, ("value".to_owned(), Value::Integer(7)),
target_ref: None, ])),
},
}) })
.unwrap() .unwrap()
.result .result
@ -168,6 +163,72 @@ fn tcp_server_accepts_local_protocol_requests() {
.result .result
.unwrap(); .unwrap();
assert!(matches!(reconnected.request(Command::InspectActor { assert!(matches!(reconnected.request(Command::InspectActor {
actor_ref, actor_ref: actor_ref.clone(),
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == 12)); }).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == Value::Integer(12)));
reconnected
.request(Command::PublishBehavior {
name: "echo".to_owned(),
expected_revision: None,
source: "pub fn handle(state, message) { (message, []) }".to_owned(),
})
.unwrap()
.result
.unwrap();
let nested = Value::Record(std::collections::BTreeMap::from([(
"items".to_owned(),
Value::List(vec![
Value::Null,
Value::Bool(true),
Value::Integer(-42),
Value::String("hello world".to_owned()),
Value::ActorRef(runa_actor::ActorRef::parse(&actor_ref).unwrap()),
]),
)]));
let echo = match reconnected
.request(Command::Spawn {
behavior_name: "echo".to_owned(),
state: nested.clone(),
mailbox_capacity: 2,
})
.unwrap()
.result
.unwrap()
{
Reply::ActorSpawned { actor_ref } => actor_ref,
other => panic!("unexpected spawn: {other:?}"),
};
assert!(matches!(reconnected.request(Command::InspectActor {
actor_ref: echo.clone(),
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == nested));
reconnected
.request(Command::Send {
actor_ref: echo.clone(),
message: nested.clone(),
})
.unwrap()
.result
.unwrap();
reconnected
.request(Command::Tick {
ticks: 100,
instruction_budget: 10,
})
.unwrap()
.result
.unwrap();
assert!(matches!(reconnected.request(Command::InspectActor {
actor_ref: echo,
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == nested && actor.reserved.is_none()));
}
fn test_message(kind: i64, value: i64, target: Option<runa_actor::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),
),
]))
} }

View File

@ -5,8 +5,10 @@ edition = "2024"
[dependencies] [dependencies]
runa-client = { path = "../runa-client" } runa-client = { path = "../runa-client" }
runa-core = { path = "../runa-core" }
runa-protocol = { path = "../runa-protocol" } runa-protocol = { path = "../runa-protocol" }
rustyline = "15.0" rustyline = "15.0"
serde_json = "1"
[dev-dependencies] [dev-dependencies]
runa-server = { path = "../runa-server" } runa-server = { path = "../runa-server" }

View File

@ -6,7 +6,8 @@ use std::net::SocketAddr;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use runa_client::TcpClient; use runa_client::TcpClient;
use runa_protocol::{Command, MessageDto, Reply}; use runa_core::ActorRef;
use runa_protocol::{Command, Reply, Value};
#[cfg(test)] #[cfg(test)]
use runa_server::Server; use runa_server::Server;
use rustyline::completion::{Completer, Pair}; use rustyline::completion::{Completer, Pair};
@ -94,9 +95,14 @@ impl ReplHelper {
"stop", "stop",
"tick", "tick",
]; ];
let start = line[..position] let (start, reference_value) = Self::completion_start(line, position);
.rfind(char::is_whitespace) if reference_value {
.map_or(0, |index| index + 1); return self
.actor_candidates()
.into_iter()
.filter(|option| option.starts_with(&line[start..position]))
.collect();
}
let previous: Vec<_> = line[..start].split_whitespace().collect(); let previous: Vec<_> = line[..start].split_whitespace().collect();
let options: Vec<String> = match previous.as_slice() { let options: Vec<String> = match previous.as_slice() {
[] => COMMANDS [] => COMMANDS
@ -114,9 +120,7 @@ impl ReplHelper {
| ["resume"] | ["resume"]
| ["cancel"] | ["cancel"]
| ["install"] => self.actor_candidates(), | ["install"] => self.actor_candidates(),
["send", _, _, _] => vec!["--to".to_owned()], ["install", _] | ["history"] | ["rollback"] | ["publish"] => self.behaviors.clone(),
["send", _, _, _, "--to"] => self.actor_candidates(),
["install", _] => self.behaviors.clone(),
_ => Vec::new(), _ => Vec::new(),
}; };
let prefix = &line[start..position]; let prefix = &line[start..position];
@ -126,6 +130,28 @@ impl ReplHelper {
.collect() .collect()
} }
fn completion_start(line: &str, position: usize) -> (usize, bool) {
let before_cursor = &line[..position];
if let Some(marker) = before_cursor.rfind("\"$actor\"") {
let suffix = &before_cursor[marker + "\"$actor\"".len()..];
if let Some(value) = suffix.trim_start().strip_prefix(':') {
let value = value.trim_start();
if let Some(content) = value.strip_prefix('"')
&& !content.contains('"')
{
return (position - content.len(), true);
}
}
}
(
before_cursor
.char_indices()
.rfind(|(_, character)| character.is_whitespace())
.map_or(0, |(index, character)| index + character.len_utf8()),
false,
)
}
fn actor_candidates(&self) -> Vec<String> { fn actor_candidates(&self) -> Vec<String> {
self.bindings self.bindings
.iter() .iter()
@ -143,9 +169,7 @@ impl Completer for ReplHelper {
position: usize, position: usize,
_context: &Context<'_>, _context: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> { ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
let start = line[..position] let (start, _) = ReplHelper::completion_start(line, position);
.rfind(char::is_whitespace)
.map_or(0, |index| index + 1);
let candidates = self let candidates = self
.candidates(line, position) .candidates(line, position)
.into_iter() .into_iter()
@ -260,7 +284,9 @@ fn execute_line<W: Write>(
if words.is_empty() { if words.is_empty() {
return Ok(true); return Ok(true);
} }
if let ["let", name, "=", "spawn", behavior_name, initial_state] = words.as_slice() { if let ["let", name, "=", "spawn", behavior_name, initial_state @ ..] = words.as_slice()
&& !initial_state.is_empty()
{
if !is_binding_name(name) { if !is_binding_name(name) {
writeln!(output, "error: invalid actor binding name `{name}`")?; writeln!(output, "error: invalid actor binding name `{name}`")?;
return Ok(true); return Ok(true);
@ -269,7 +295,7 @@ fn execute_line<W: Write>(
writeln!(output, "error: actor binding `{name}` already exists")?; writeln!(output, "error: actor binding `{name}` already exists")?;
return Ok(true); return Ok(true);
} }
match spawn_actor(behavior_name, initial_state, server) { match spawn_actor(behavior_name, after_words(line, 5), server, state) {
Ok(actor_ref) => { Ok(actor_ref) => {
writeln!(output, "{name} = @{actor_ref}")?; writeln!(output, "{name} = @{actor_ref}")?;
state.bindings.insert((*name).to_owned(), actor_ref); state.bindings.insert((*name).to_owned(), actor_ref);
@ -282,7 +308,7 @@ fn execute_line<W: Write>(
let args = &words[1..]; let args = &words[1..];
let result = match command { let result = match command {
"help" => print_help(output), "help" => print_help(output),
"spawn" => spawn_command(output, args, server), "spawn" => spawn_command(output, line, server, state),
"behaviors" if args.is_empty() => match dispatch(server, Command::GetBehaviors) { "behaviors" if args.is_empty() => match dispatch(server, Command::GetBehaviors) {
Ok(Reply::Behaviors(behaviors)) => { Ok(Reply::Behaviors(behaviors)) => {
for behavior in behaviors { for behavior in behaviors {
@ -313,19 +339,9 @@ fn execute_line<W: Write>(
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
}, },
"actors" => writeln!(output, "error: usage: actors"), "actors" => writeln!(output, "error: usage: actors"),
"send" => match parse_send_args(args, state) { "send" => match parse_send_args(line, state) {
Ok((actor_ref, kind, value, target_ref)) => { Ok((actor_ref, message)) => {
match dispatch( match dispatch(server, Command::Send { actor_ref, message }) {
server,
Command::Send {
actor_ref,
message: MessageDto {
kind,
value,
target_ref,
},
},
) {
Ok(Reply::Acknowledged) => writeln!(output, "message queued"), Ok(Reply::Acknowledged) => writeln!(output, "message queued"),
Ok(other) => unexpected_reply(output, other), Ok(other) => unexpected_reply(output, other),
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
@ -412,16 +428,24 @@ fn lifecycle_command<W: Write>(
fn spawn_command<W: Write>( fn spawn_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], line: &str,
server: &mut dyn Connection, server: &mut dyn Connection,
state: &ReplState,
) -> io::Result<()> { ) -> io::Result<()> {
let [behavior_name, initial_state] = args else { let args: Vec<_> = line.split_whitespace().collect();
let [_, behavior_name, initial_state @ ..] = args.as_slice() else {
return writeln!( return writeln!(
output, output,
"error: usage: spawn <behavior-name> <initial-state>" "error: usage: spawn <behavior-name> <initial-state>"
); );
}; };
match spawn_actor(behavior_name, initial_state, server) { if initial_state.is_empty() {
return writeln!(
output,
"error: usage: spawn <behavior-name> <initial-state>"
);
}
match spawn_actor(behavior_name, after_words(line, 2), server, state) {
Ok(actor_ref) => writeln!(output, "spawned @{actor_ref} from `{behavior_name}`"), Ok(actor_ref) => writeln!(output, "spawned @{actor_ref} from `{behavior_name}`"),
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
} }
@ -431,10 +455,10 @@ fn spawn_actor(
behavior_name: &str, behavior_name: &str,
initial_state: &str, initial_state: &str,
server: &mut dyn Connection, server: &mut dyn Connection,
repl: &ReplState,
) -> Result<String, String> { ) -> Result<String, String> {
let state = initial_state let state = parse_value(initial_state, repl)
.parse() .map_err(|error| format!("invalid initial state: {error}"))?;
.map_err(|_| format!("invalid initial state: `{initial_state}`"))?;
match dispatch( match dispatch(
server, server,
Command::Spawn { Command::Spawn {
@ -663,26 +687,86 @@ fn parse_tick_args(args: &[&str]) -> Result<(usize, NonZeroUsize), String> {
} }
} }
fn parse_send_args( fn parse_send_args(line: &str, state: &ReplState) -> Result<(String, Value), String> {
args: &[&str], let actor_arg = line
state: &ReplState, .split_whitespace()
) -> Result<(String, i64, i64, Option<String>), String> { .nth(1)
let (actor_arg, kind_arg, value_arg, target_arg) = match args { .ok_or("usage: send <actor> <message-json>")?;
[actor, kind, value] => (*actor, *kind, *value, None),
[actor, kind, value, "--to", target] => (*actor, *kind, *value, Some(*target)),
_ => return Err("usage: send <actor> <kind> <value> [--to <target>]".to_owned()),
};
let actor_ref = parse_actor_argument(&[actor_arg], state)?; let actor_ref = parse_actor_argument(&[actor_arg], state)?;
let kind = kind_arg Ok((actor_ref, parse_value(after_words(line, 2), state)?))
.parse() }
.map_err(|_| format!("invalid message kind: `{kind_arg}`"))?;
let value = value_arg fn after_words(mut line: &str, count: usize) -> &str {
.parse() for _ in 0..count {
.map_err(|_| format!("invalid message value: `{value_arg}`"))?; line = line.trim_start();
let target_ref = target_arg line = line
.map(|target| parse_actor_argument(&[target], state)) .find(char::is_whitespace)
.transpose()?; .map_or("", |index| &line[index..]);
Ok((actor_ref, kind, value, target_ref)) }
line.trim()
}
fn parse_value(input: &str, state: &ReplState) -> Result<Value, String> {
if input.len() > runa_core::MAX_VALUE_BYTES {
return Err("JSON value exceeds input byte limit".to_owned());
}
fn record(
values: serde_json::Map<String, serde_json::Value>,
state: &ReplState,
) -> Result<Value, String> {
values
.into_iter()
.map(|(key, value)| Ok((key, convert(value, state)?)))
.collect::<Result<BTreeMap<_, _>, String>>()
.map(Value::Record)
}
fn convert(value: serde_json::Value, state: &ReplState) -> Result<Value, String> {
Ok(match value {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(value) => Value::Bool(value),
serde_json::Value::Number(value) => Value::Integer(
value
.as_i64()
.ok_or("numbers must be signed 64-bit integers")?,
),
serde_json::Value::String(value) => Value::String(value),
serde_json::Value::Array(values) => Value::List(
values
.into_iter()
.map(|value| convert(value, state))
.collect::<Result<_, _>>()?,
),
serde_json::Value::Object(mut values)
if values.len() == 1 && values.contains_key("$actor") =>
{
let serde_json::Value::String(reference) =
values.remove("$actor").expect("key checked")
else {
return Err(
"$actor must contain a binding name or actor reference string".to_owned(),
);
};
let reference = parse_actor_argument(&[&reference], state)?;
Value::ActorRef(ActorRef::parse(&reference).expect("reference validated"))
}
serde_json::Value::Object(mut values)
if values.len() == 1 && values.contains_key("$record") =>
{
let serde_json::Value::Object(values) =
values.remove("$record").expect("key checked")
else {
return Err("$record must contain a JSON object".to_owned());
};
record(values, state)?
}
serde_json::Value::Object(values) => record(values, state)?,
})
}
let value =
serde_json::from_str(input).map_err(|error| format!("invalid JSON value: {error}"))?;
let value = convert(value, state)?;
value.validate()?;
Ok(value)
} }
fn print_help<W: Write>(output: &mut W) -> io::Result<()> { fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
@ -696,7 +780,8 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
history <behavior-name>\n\ history <behavior-name>\n\
rollback <behavior-name> <expected-revision> <target-revision>\n\ rollback <behavior-name> <expected-revision> <target-revision>\n\
install <actor-ref-or-name> <behavior-name>\n\ install <actor-ref-or-name> <behavior-name>\n\
send <actor-ref-or-name> <kind> <value> [--to <actor-ref-or-name>]\n\ send <actor-ref-or-name> <message-json>\n\
State/messages: JSON values; {{\"$actor\":\"name-or-@ref\"}} embeds an actor reference.\n\
tick <count> [instruction-budget]\n\ tick <count> [instruction-budget]\n\
inspect <actor-ref-or-name>\n\ inspect <actor-ref-or-name>\n\
actors\n\ actors\n\
@ -715,6 +800,101 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn value_parser_preserves_json_shapes_and_distinguishes_references() {
let reference = ActorRef::new();
let state = ReplState {
bindings: BTreeMap::from([("receiver".to_owned(), reference.to_string())]),
};
let input =
r#"{"text": "hello world", "items": [null, true, -42, {"$actor": "receiver"}]}"#;
assert_eq!(
parse_value(input, &state).unwrap(),
Value::Record(BTreeMap::from([
("text".to_owned(), Value::String("hello world".to_owned())),
(
"items".to_owned(),
Value::List(vec![
Value::Null,
Value::Bool(true),
Value::Integer(-42),
Value::ActorRef(reference)
])
),
]))
);
assert_eq!(
parse_value(r#"{"$record": {"$actor": "literal"}}"#, &state).unwrap(),
Value::Record(BTreeMap::from([(
"$actor".to_owned(),
Value::String("literal".to_owned())
)]))
);
for input in [
"1.5",
"9223372036854775808",
r#"{"$actor": "missing"}"#,
r#"{"$actor": 1}"#,
"null trailing",
] {
assert!(parse_value(input, &state).is_err(), "{input}");
}
}
#[test]
fn repl_spawns_and_sends_nested_values_without_losing_whitespace()
-> Result<(), Box<dyn std::error::Error>> {
let mut server = Server::new();
dispatch(
&mut server,
Command::PublishBehavior {
name: "echo".to_owned(),
expected_revision: None,
source: "pub fn handle(state, message) { (message, []) }".to_owned(),
},
)?;
let mut state = ReplState::default();
let mut output = Vec::new();
execute_line(
r#"let echo = spawn echo {"text": "hello world", "items": [true, null]}"#,
&mut output,
&mut server,
&mut state,
)?;
let actor_ref = state.bindings.get("echo").ok_or("spawn failed")?.clone();
let Reply::Actor(snapshot) = dispatch(
&mut server,
Command::InspectActor {
actor_ref: actor_ref.clone(),
},
)?
else {
return Err("unexpected inspect reply".into());
};
assert_eq!(
snapshot.state,
parse_value(r#"{"text":"hello world","items":[true,null]}"#, &state)?
);
execute_line(
r#"send echo {"nested": [{"$actor": "echo"}, "two spaces"]}"#,
&mut output,
&mut server,
&mut state,
)?;
execute_line("tick 100", &mut output, &mut server, &mut state)?;
let Reply::Actor(snapshot) = dispatch(&mut server, Command::InspectActor { actor_ref })?
else {
return Err("unexpected inspect reply".into());
};
assert_eq!(
snapshot.state,
parse_value(r#"{"nested": [{"$actor":"echo"}, "two spaces"]}"#, &state)?
);
assert_eq!(snapshot.status, runa_protocol::ActorStatusDto::Idle);
assert!(!String::from_utf8(output)?.contains("error:"));
Ok(())
}
#[test] #[test]
fn connection_arguments_require_an_explicit_socket_address() { fn connection_arguments_require_an_explicit_socket_address() {
assert_eq!( assert_eq!(
@ -749,7 +929,9 @@ mod tests {
let mut client = TcpClient::connect(address)?; let mut client = TcpClient::connect(address)?;
let mut output = Vec::new(); let mut output = Vec::new();
run_repl( run_repl(
io::Cursor::new("let actor = spawn counter 5\nsend actor 0 7\ntick 100\nquit\n"), io::Cursor::new(
"let actor = spawn counter 5\nsend actor {\"op\":\"add\",\"value\":7}\ntick 100\nquit\n",
),
&mut output, &mut output,
&mut client, &mut client,
)?; )?;
@ -770,8 +952,18 @@ mod tests {
)?; )?;
let output = String::from_utf8(output)?; let output = String::from_utf8(output)?;
assert!(output.contains(actor_ref)); assert!(output.contains(actor_ref));
assert!(output.contains("state: 12"));
assert!(!output.contains("error:")); assert!(!output.contains("error:"));
let Reply::Actor(snapshot) = dispatch(
&mut client,
Command::InspectActor {
actor_ref: actor_ref.to_owned(),
},
)?
else {
return Err("unexpected inspect reply".into());
};
assert_eq!(snapshot.state, Value::Integer(12));
assert_eq!(snapshot.status, runa_protocol::ActorStatusDto::Idle);
drop(client); drop(client);
worker.join().expect("TCP image worker completed"); worker.join().expect("TCP image worker completed");
Ok(()) Ok(())
@ -795,16 +987,27 @@ mod tests {
fn repl_drives_actor_messages_and_inspection() -> Result<(), Box<dyn std::error::Error>> { fn repl_drives_actor_messages_and_inspection() -> Result<(), Box<dyn std::error::Error>> {
let mut server = server(); let mut server = server();
let input = io::Cursor::new( let input = io::Cursor::new(
"let sender = spawn counter 0\nlet receiver = spawn counter 10\nsend sender 1 5 --to receiver\ntick 100\ninspect sender\ninspect receiver\nevents\nquit\n", "let sender = spawn counter 0\nlet receiver = spawn counter 10\nsend sender {\"op\": \"send\", \"value\": 5, \"target\": {\"$actor\": \"receiver\"}}\ntick 100\ninspect sender\ninspect receiver\nevents\nquit\n",
); );
let mut output = Vec::new(); let mut output = Vec::new();
run_repl(input, &mut output, &mut server)?; run_repl(input, &mut output, &mut server)?;
let output = String::from_utf8(output)?; let output = String::from_utf8(output)?;
assert!(output.contains("sender = @")); assert!(output.contains("sender = @"));
assert!(output.contains("receiver = @")); assert!(output.contains("receiver = @"));
assert!(output.contains("state: 15")); assert!(!output.contains("error:"), "{output}");
assert!(output.contains("Committed {")); assert!(output.contains("Committed {"));
assert!(output.contains("actor_ref: \"")); assert!(output.contains("actor_ref: \""));
let Reply::Actors(actors) = dispatch(&mut server, Command::ListActors)? else {
return Err("unexpected actors reply".into());
};
assert_eq!(actors.len(), 2);
assert!(
actors
.iter()
.all(|actor| actor.status == runa_protocol::ActorStatusDto::Idle)
);
assert!(actors.iter().any(|actor| actor.state == Value::Integer(1)));
assert!(actors.iter().any(|actor| actor.state == Value::Integer(15)));
Ok(()) Ok(())
} }
@ -812,7 +1015,7 @@ mod tests {
fn repl_reports_bad_input_and_continues() -> Result<(), Box<dyn std::error::Error>> { fn repl_reports_bad_input_and_continues() -> Result<(), Box<dyn std::error::Error>> {
let mut server = server(); let mut server = server();
let input = io::Cursor::new( let input = io::Cursor::new(
"spawn 0\nspawn counter nope\nsend 000000000000000000000 0 1\ntick 1 0\nunknown\nquit\n", "spawn 0\nspawn counter nope\nsend 000000000000000000000 {\"op\":\"add\",\"value\":1}\ntick 1 0\nunknown\nquit\n",
); );
let mut output = Vec::new(); let mut output = Vec::new();
run_repl(input, &mut output, &mut server)?; run_repl(input, &mut output, &mut server)?;
@ -834,7 +1037,7 @@ mod tests {
"/../../runa-std/actors/counter.rn" "/../../runa-std/actors/counter.rn"
); );
let input = io::Cursor::new(format!( let input = io::Cursor::new(format!(
"behaviors\npublish counter 1 {source_path}\nhistory counter\nrollback counter 2 1\nlet actor = spawn counter 0\ninstall actor counter\nsend actor 0 1\ntick 1 1\ncancel actor\npause actor\nresume actor\ninspect actor\nevents\nquit\n" "behaviors\npublish counter 1 {source_path}\nhistory counter\nrollback counter 2 1\nlet actor = spawn counter 0\ninstall actor counter\nsend actor {{\"op\":\"add\",\"value\":1}}\ntick 1 1\ncancel actor\npause actor\nresume actor\ninspect actor\nevents\nquit\n"
)); ));
let mut output = Vec::new(); let mut output = Vec::new();
run_repl(input, &mut output, &mut server)?; run_repl(input, &mut output, &mut server)?;
@ -880,5 +1083,16 @@ mod tests {
assert_eq!(helper.candidates("sp", 2), vec!["spawn"]); assert_eq!(helper.candidates("sp", 2), vec!["spawn"]);
assert_eq!(helper.candidates("spawn co", 8), vec!["counter"]); assert_eq!(helper.candidates("spawn co", 8), vec!["counter"]);
assert_eq!(helper.candidates("inspect se", 10), vec!["sender"]); assert_eq!(helper.candidates("inspect se", 10), vec!["sender"]);
let unicode_whitespace = "inspect\u{2003}se";
assert_eq!(
helper.candidates(unicode_whitespace, unicode_whitespace.len()),
vec!["sender"]
);
let line = r#"send sender {"target": {"$actor": "se"#;
assert_eq!(helper.candidates(line, line.len()), vec!["sender"]);
assert_eq!(
ReplHelper::completion_start(line, line.len()),
(line.len() - 2, true)
);
} }
} }

View File

@ -310,7 +310,7 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen
| Crate | Owns | May depend on | | Crate | Owns | May depend on |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `runa-core` | Small, stable value types shared across independent layers: source and implementation IDs, canonical message value types, common error identifiers | Minimal external libraries only | | `runa-core` | Small, stable value types shared across independent layers: actor references, source and implementation IDs, portable actor values, common error identifiers | Minimal external libraries only |
| `runa-code` | Original source retention, versioned source/implementation hashing, compile-input fingerprints, in-memory compiled artifact cache | `runa-core`, `runa-rune`, BLAKE3 | | `runa-code` | Original source retention, versioned source/implementation hashing, compile-input fingerprints, in-memory compiled artifact cache | `runa-core`, `runa-rune`, BLAKE3 |
| `runa-actor` | Mailbox and state-transition semantics, message reservation, acknowledgment, effects, actor lifecycle definitions | `runa-core` | | `runa-actor` | Mailbox and state-transition semantics, message reservation, acknowledgment, effects, actor lifecycle definitions | `runa-core` |
| `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 |
@ -439,11 +439,14 @@ This is a **target layout**, not a request to generate empty crates. For Phase 0
The current runtime prototype supports implementation installation only at an The current runtime prototype supports implementation installation only at an
idle handler boundary, cooperative pause/resume between scheduler ticks, and an idle handler boundary, cooperative pause/resume between scheduler ticks, and an
explicit host-provided migration closure for its temporary `i64` actor state. explicit host-provided migration closure for structured actor state.
Actor snapshots expose active VM instruction progress, instruction pointer, Actor snapshots expose active VM instruction progress, instruction pointer,
and call-frame depth. Structured state schemas, version compatibility checks, and call-frame depth. State and messages are owned portable trees of null,
Rune-authored migration functions, source mapping, and local-variable booleans, integers, strings, lists, records, and distinct actor references.
inspection remain future work. Rune handlers receive `(state, message)` and return state plus explicit sends;
Rune heap objects never become shared committed actor data. State schemas,
version compatibility checks, Rune-authored migration functions, source
mapping, and local-variable inspection remain future work.
### Phase 5 — Image API ### Phase 5 — Image API

View File

@ -7,9 +7,9 @@ when started with `--listen`, over TCP bound to an IPv4 or IPv6 loopback
address. The TCP listener is unauthenticated and is intended only for trusted address. The TCP listener is unauthenticated and is intended only for trusted
local development. local development.
## Version 2 ## Version 3
Every request and response carries `version: 2`. Requests also carry a Every request and response carries `version: 3`. Requests also carry a
non-empty `request_id` used to correlate the response. A server must reject non-empty `request_id` used to correlate the response. A server must reject
unsupported versions with `unsupported_version` rather than interpreting a unsupported versions with `unsupported_version` rather than interpreting a
request using a different contract. Events carry the protocol version and do request using a different contract. Events carry the protocol version and do
@ -19,23 +19,26 @@ Requests have a tagged command object:
```json ```json
{ {
"version": 2, "version": 3,
"request_id": "req-1", "request_id": "req-1",
"command": { "command": {
"operation": "send", "operation": "send",
"parameters": { "parameters": {
"actor_ref": "xK8_pQ2-Lm5_012345678", "actor_ref": "xK8_pQ2-Lm5_012345678",
"message": { "message": {
"kind": 2, "type": "record",
"value": 9, "data": {
"target_ref": "Hj2_9sM0-pQ4_7aBcD3eF" "op": { "type": "string", "data": "send" },
"value": { "type": "integer", "data": 9 },
"target": { "type": "actor_ref", "data": "Hj2_9sM0-pQ4_7aBcD3eF" }
}
} }
} }
} }
} }
``` ```
The version 2 operations are behavior publication, rollback, lookup, source The version 3 operations are behavior publication, rollback, lookup, source
retrieval, actor spawn, send, tick, inspect, actor listing, event retrieval, retrieval, actor spawn, send, tick, inspect, actor listing, event retrieval,
stop, restart, pause, resume, cancellation, and behavior installation. stop, restart, pause, resume, cancellation, and behavior installation.
Migration closures are intentionally excluded: they are host callbacks, not Migration closures are intentionally excluded: they are host callbacks, not
@ -45,11 +48,40 @@ 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 error. Events use a separate versioned envelope. Actor and behavior snapshots
are wire DTOs, not serialized runtime objects; implementation identities are are wire DTOs, not serialized runtime objects; implementation identities are
represented by their stable display strings. Public actor identity is a represented by their stable display strings. Public actor identity is a
21-character URL-safe NanoID string carried in `actor_ref`; message `target_ref` 21-character URL-safe NanoID string carried in `actor_ref`. The server maps known service
is optional and omitted when absent. The server maps known service
failures to the defined error codes and otherwise uses `runtime_failure` failures to the defined error codes and otherwise uses `runtime_failure`
rather than claiming a more specific category. rather than claiming a more specific category.
### Portable values
Spawn state, send messages, snapshot state/mailbox/reservations, and migration
event states use the same tagged value shape:
| Value | JSON |
| --- | --- |
| Null | `{"type":"null"}` |
| Boolean | `{"type":"bool","data":true}` |
| Signed 64-bit integer | `{"type":"integer","data":42}` |
| String | `{"type":"string","data":"hello"}` |
| List | `{"type":"list","data":[<value>, ...]}` |
| Record | `{"type":"record","data":{"key":<value>, ...}}` |
| Actor reference | `{"type":"actor_ref","data":"<21-character-NanoID>"}` |
References are distinct from strings. Their shape is validated even when
nested; existence is checked when a reference is used as a send destination,
not merely stored as data. The old message envelope and protocol v2 are not
accepted. The REPL's natural JSON and `$actor`/`$record` conveniences are
client syntax, not wire encodings.
Each state/message permits a maximum nesting depth of 32 (root depth 0),
4,096 nodes, and 1 MiB of UTF-8 string/key bytes. Requests are also subject to
the transport's 1 MiB total line limit, including tags and envelope.
Unsupported value tags, malformed references, floats, and out-of-range integers
fail decoding; well-formed requests exceeding value limits receive
`invalid_request`. Invalid Rune transition output fails the actor without
acknowledging the reserved message, changing committed state, or enqueueing
any of its sends.
`get_events` drains and returns the events currently accumulated by this `get_events` drains and returns the events currently accumulated by this
single-worker image. It is not a durable subscription or replayable event log. single-worker image. It is not a durable subscription or replayable event log.
Both transports limit each request line to 1 MiB before parsing. Malformed Both transports limit each request line to 1 MiB before parsing. Malformed
@ -76,7 +108,7 @@ publishes the bundled `counter` behavior on image startup, not on connection.
The stdio executable continues to start with an empty image. The stdio executable continues to start with an empty image.
The current validation checks the protocol version, request ID, actor-reference The current validation checks the protocol version, request ID, actor-reference
shape, positive revisions where required, non-empty behavior names, mailbox shape, structured-value limits, positive revisions where required, non-empty behavior names, mailbox
capacity, and instruction budget. Behavior-name grammar, actor existence, capacity, and instruction budget. Behavior-name grammar, actor existence,
current behavior revisions, mailbox capacity at send time, and all runtime current behavior revisions, mailbox capacity at send time, and all runtime
invariants remain authoritative at the application-service boundary. invariants remain authoritative at the application-service boundary.

View File

@ -32,7 +32,7 @@ runa> let sender = spawn counter 0
sender = @<actor-reference> sender = @<actor-reference>
runa> let receiver = spawn counter 10 runa> let receiver = spawn counter 10
receiver = @<actor-reference> receiver = @<actor-reference>
runa> send sender 1 5 --to receiver runa> send sender {"op": "send", "value": 5, "target": {"$actor": "receiver"}}
message queued message queued
runa> tick 100 runa> tick 100
runa> inspect sender runa> inspect sender
@ -46,11 +46,27 @@ Up/Down navigate command history. `tick <count> [instruction-budget]`
explicitly advances the scheduler. The default per-actor slice budget is 32 explicitly advances the scheduler. The default per-actor slice budget is 32
instructions. instructions.
State and messages use JSON literals in the REPL, preserving whitespace inside
strings. `spawn <behavior> <json>` and `let <name> = spawn <behavior> <json>`
require explicit initial state; `send <actor> <json>` takes one message value.
For example, `send receiver {"op": "add", "value": 7}` adds to the bundled
counter. Its supported operations are `add` and `send`; unknown operations
fail explicitly.
The sole-key object `{"$actor": "receiver"}` resolves a local binding to a
distinct actor-reference value. A literal `@<NanoID>` or raw NanoID string
can be used in place of the binding. Ordinary strings are never implicitly
converted to references. To encode a literal record that would otherwise
match a marker, wrap it in `{"$record": {...}}`. The wrapper escapes only that
record; nested values still use the normal conversion rules. Tab completion
also offers references inside a `"$actor"` string.
## Current contracts ## Current contracts
- `runa-actor` owns actor references, mailbox and implementation IDs, structured - `runa-actor` owns actor references, mailbox and implementation IDs, structured
messages, snapshots, statuses and lifecycle events. messages, snapshots, statuses and lifecycle events.
- `runa-core` owns stable source and implementation identity types. - `runa-core` owns stable source/implementation identities, actor references,
and the portable owned value model shared by the runtime and protocol.
- `runa-code` preserves original source, derives identities, caches compiled - `runa-code` preserves original source, derives identities, caches compiled
artifacts, and provides an in-memory named behavior registry with revisions. artifacts, and provides an in-memory named behavior registry with revisions.
- `runa-rune` owns compilation and typed bounded invocation execution. - `runa-rune` owns compilation and typed bounded invocation execution.
@ -94,7 +110,7 @@ when it is idle, has no active invocation, and has no reserved input. Queued
messages may remain; they will use the newly installed implementation. Active messages may remain; they will use the newly installed implementation. Active
or awaiting handlers keep their original implementation, and installation or awaiting handlers keep their original implementation, and installation
attempts during execution fail explicitly so the caller can retry at the next attempts during execution fail explicitly so the caller can retry at the next
handler boundary. `install` preserves the current `i64` state unchanged. handler boundary. `install` preserves the current structured state unchanged.
Runtime callers that need a state transformation can use Runtime callers that need a state transformation can use
`install_with_migration`, which receives the committed state and computes its `install_with_migration`, which receives the committed state and computes its
@ -103,8 +119,10 @@ requires the same idle safe point; if the migration returns an error, neither
value changes and no installation event is emitted. A successful changed value changes and no installation event is emitted. A successful changed
state emits `StateMigrated` as well as `BehaviorInstalled`. This prototype state emits `StateMigrated` as well as `BehaviorInstalled`. This prototype
accepts a host-provided migration closure only: it does not infer schema accepts a host-provided migration closure only: it does not infer schema
compatibility or execute migration code from Rune source. Its state contract compatibility or execute migration code from Rune source. The closure receives
is still `i64`, so structured schema/version migration remains future work. an owned copy of the structured state; an invalid result also leaves the
original state and implementation unchanged. Schema/version policy remains
future work.
`pause` prevents an actor from being scheduled on subsequent worker ticks and `pause` prevents an actor from being scheduled on subsequent worker ticks and
retains its active invocation, reserved message, and prior `Running`, `Awaiting`, retains its active invocation, reserved message, and prior `Running`, `Awaiting`,
@ -140,24 +158,48 @@ The cache is in-memory only. Invalid source is retained in the current
identity is not a claim that Rune bytecode is portable or safe to persist. identity is not a claim that Rune bytecode is portable or safe to persist.
`CodeStore::compile_with_module` requires an explicit stable module identity; `CodeStore::compile_with_module` requires an explicit stable module identity;
callers must change it when registered native functions or their contracts callers must change it when registered native functions or their contracts
change. The CLI currently creates a fresh `CodeStore` when it starts. change. The server creates a fresh `CodeStore` when its image starts.
Prototype state is an `i64`; messages contain integer `kind` and `value` State and messages are owned `Value` trees: null, boolean, signed 64-bit
fields, plus an optional target actor reference for a handler-produced send. integer, UTF-8 string, list, string-keyed record, or actor reference. Public
Public actor references are 21-character URL-safe NanoIDs. They are generated actor references are 21-character URL-safe NanoIDs. They are generated when
when actors are spawned and remain stable across stop/restart; mailbox IDs are actors are spawned and remain stable across stop/restart; mailbox IDs are
separate internal counters. Rune handlers have this deliberately restricted separate internal counters. Rune handlers have this interface:
interface:
```text ```text
handle(state, kind, value, target_ref) handle(state, message) -> (new_state, [(target_actor_ref, message), ...])
-> (new_state, [(target_ref, kind, value), ...])
``` ```
`target_ref` is an empty string when the incoming message has no routed target. The former `kind`, `value`, and `target_ref` envelope is removed. Actors choose
The returned effect vector describes sends; handlers cannot access the runtime their own message records. For example:
or directly mutate committed state. General schema/versioned values and
capability-bearing contexts are future work. ```rune
pub fn handle(state, message) {
state.items.push(message.text);
(state, [(message.reply_to, #{accepted: true})])
}
```
The runtime allocates a fresh Rune object graph for each state and message
input, and copies portable data back out before committing. A Rune handler can
mutate its invocation-local draft but cannot mutate committed state, the
reserved input, or another actor's data. Copies made for separate effect
messages do not share mutable Rune objects.
Null maps to Rune unit `()`, lists to Rune vectors, records to anonymous
objects (`#{...}`), and references to an opaque native `runa::ActorReference`.
Effect targets must be references, not lookalike strings. References support
equality and cloning in Rune and can be retained and forwarded through
state/messages; this is not authentication.
Floats, nonempty tuples, options, closures, futures, custom Rune structs, and
other native objects are not portable actor data and fail conversion. Cyclic
or excessively deep Rune output also fails without committing.
Each state/message is limited to depth 32 (root depth 0), 4,096 value nodes
(including the root), and 1 MiB of UTF-8 string/key bytes. A transition permits
at most 4,096 send effects. Transport line-size limits apply separately.
These are conversion limits, not a Rune heap quota or hostile-code sandbox.
Schema/version policy and capability-bearing contexts remain future work.
## Scheduling and lifecycle ## Scheduling and lifecycle

View File

@ -1,7 +1,9 @@
pub fn handle(state, kind, value, target_ref) { pub fn handle(state, message) {
if kind == 1 { if message.op == "send" {
(state + 1, [(target_ref, 0, 5)]) (state + 1, [(message.target, #{op: "add", value: message.value})])
} else if message.op == "add" {
(state + message.value, [])
} else { } else {
(state + value, []) panic("unknown counter operation")
} }
} }