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:
parent
350ddba0ce
commit
ad63e286d9
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,5 @@ version = "0.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
nanoid = "0.4"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue