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>
2026-11-02 15:56:03 +00:00
|
|
|
use runa_protocol::Value;
|
2026-11-03 14:36:21 +00:00
|
|
|
use std::io::{BufRead, BufReader};
|
|
|
|
|
use std::net::SocketAddr;
|
2026-11-02 13:50:30 +00:00
|
|
|
use std::process::{Child, Command as ProcessCommand, Stdio};
|
|
|
|
|
|
2026-11-03 14:36:21 +00:00
|
|
|
use runa_client::{LocalClient, WebSocketClient};
|
|
|
|
|
use runa_protocol::{Command, Reply};
|
|
|
|
|
use tokio_tungstenite::tungstenite::{self, Message, client::IntoClientRequest};
|
|
|
|
|
|
|
|
|
|
fn start_server() -> (ServerProcess, SocketAddr) {
|
|
|
|
|
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa-server"))
|
|
|
|
|
.args(["--listen", "127.0.0.1:0"])
|
|
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::piped())
|
|
|
|
|
.spawn()
|
|
|
|
|
.unwrap();
|
|
|
|
|
let mut server = ServerProcess(child);
|
|
|
|
|
let mut stderr = BufReader::new(server.0.stderr.take().unwrap());
|
|
|
|
|
let mut startup = String::new();
|
|
|
|
|
stderr.read_line(&mut startup).unwrap();
|
|
|
|
|
let address = startup
|
|
|
|
|
.strip_prefix("runa-server: listening on ")
|
|
|
|
|
.expect("server should report its bound address")
|
|
|
|
|
.trim()
|
|
|
|
|
.parse()
|
|
|
|
|
.unwrap();
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
std::io::copy(&mut stderr, &mut std::io::sink()).unwrap();
|
|
|
|
|
});
|
|
|
|
|
(server, address)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn watcher(address: SocketAddr) -> tungstenite::WebSocket<std::net::TcpStream> {
|
|
|
|
|
let mut request = format!("ws://{address}/").into_client_request().unwrap();
|
|
|
|
|
request
|
|
|
|
|
.headers_mut()
|
|
|
|
|
.insert("sec-websocket-protocol", "runa-debug".parse().unwrap());
|
|
|
|
|
let stream = std::net::TcpStream::connect(address).unwrap();
|
|
|
|
|
stream
|
|
|
|
|
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
|
|
|
|
.unwrap();
|
|
|
|
|
let (socket, response) = tungstenite::client(request, stream).unwrap();
|
|
|
|
|
assert_eq!(response.headers()["sec-websocket-protocol"], "runa-debug");
|
|
|
|
|
socket
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn revision(socket: &mut tungstenite::WebSocket<std::net::TcpStream>) -> u64 {
|
|
|
|
|
let message = socket.read().unwrap();
|
|
|
|
|
let event: runa_protocol::Notification =
|
|
|
|
|
serde_json::from_str(message.to_text().unwrap()).unwrap();
|
|
|
|
|
assert_eq!(event.version, runa_protocol::PROTOCOL_VERSION);
|
|
|
|
|
let runa_protocol::ImageEvent::ImageChanged { revision } = event.event;
|
|
|
|
|
revision
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn websocket_broadcasts_non_destructive_invalidations_for_mailbox_state_and_lifecycle() {
|
|
|
|
|
let (_server, address) = start_server();
|
|
|
|
|
let mut first = watcher(address);
|
|
|
|
|
let mut second = watcher(address);
|
|
|
|
|
let mut client = WebSocketClient::connect(address).unwrap();
|
|
|
|
|
let actor_ref = match client
|
|
|
|
|
.request(Command::Spawn {
|
|
|
|
|
behavior_name: "counter".to_owned(),
|
|
|
|
|
state: Value::Integer(1),
|
|
|
|
|
mailbox_capacity: 4,
|
|
|
|
|
})
|
|
|
|
|
.unwrap()
|
|
|
|
|
.result
|
|
|
|
|
.unwrap()
|
|
|
|
|
{
|
|
|
|
|
Reply::ActorSpawned { actor_ref } => actor_ref,
|
|
|
|
|
other => panic!("unexpected spawn: {other:?}"),
|
|
|
|
|
};
|
|
|
|
|
let mut previous = 0;
|
|
|
|
|
for command in [
|
|
|
|
|
Command::ListActors,
|
|
|
|
|
Command::Send {
|
|
|
|
|
actor_ref: actor_ref.clone(),
|
|
|
|
|
message: Value::Record(std::collections::BTreeMap::from([
|
|
|
|
|
("op".to_owned(), Value::String("add".to_owned())),
|
|
|
|
|
("value".to_owned(), Value::Integer(3)),
|
|
|
|
|
])),
|
|
|
|
|
},
|
|
|
|
|
Command::Tick {
|
|
|
|
|
ticks: 100,
|
|
|
|
|
instruction_budget: 10,
|
|
|
|
|
},
|
|
|
|
|
Command::PauseActor {
|
|
|
|
|
actor_ref: actor_ref.clone(),
|
|
|
|
|
},
|
|
|
|
|
Command::ResumeActor {
|
|
|
|
|
actor_ref: actor_ref.clone(),
|
|
|
|
|
},
|
|
|
|
|
Command::StopActor {
|
|
|
|
|
actor_ref: actor_ref.clone(),
|
|
|
|
|
},
|
|
|
|
|
Command::RestartActor {
|
|
|
|
|
actor_ref: actor_ref.clone(),
|
|
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
if !matches!(command, Command::ListActors) {
|
|
|
|
|
client.request(command).unwrap().result.unwrap();
|
|
|
|
|
}
|
|
|
|
|
let current = revision(&mut first);
|
|
|
|
|
assert!(current > previous);
|
|
|
|
|
assert_eq!(revision(&mut second), current);
|
|
|
|
|
previous = current;
|
|
|
|
|
}
|
|
|
|
|
// Both subscribers have seen notifications, but neither consumed runtime events.
|
|
|
|
|
let Reply::Events(events) = client.request(Command::GetEvents).unwrap().result.unwrap() else {
|
|
|
|
|
panic!("expected runtime events")
|
|
|
|
|
};
|
|
|
|
|
assert!(
|
|
|
|
|
events
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|event| matches!(event, runa_protocol::EventDto::Spawned { .. }))
|
|
|
|
|
);
|
|
|
|
|
let Reply::Actor(snapshot) = client
|
|
|
|
|
.request(Command::InspectActor { actor_ref })
|
|
|
|
|
.unwrap()
|
|
|
|
|
.result
|
|
|
|
|
.unwrap()
|
|
|
|
|
else {
|
|
|
|
|
panic!("expected actor")
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(snapshot.state, Value::Integer(4));
|
|
|
|
|
|
|
|
|
|
// A read after a failed mutation is a fence: no invalidation should precede its reply.
|
|
|
|
|
assert!(
|
|
|
|
|
client
|
|
|
|
|
.request(Command::Spawn {
|
|
|
|
|
behavior_name: "missing".to_owned(),
|
|
|
|
|
state: Value::Null,
|
|
|
|
|
mailbox_capacity: 1,
|
|
|
|
|
})
|
|
|
|
|
.unwrap()
|
|
|
|
|
.result
|
|
|
|
|
.is_err()
|
|
|
|
|
);
|
|
|
|
|
first
|
|
|
|
|
.send(Message::Text(
|
|
|
|
|
serde_json::to_string(&runa_protocol::Request {
|
|
|
|
|
version: 3,
|
|
|
|
|
request_id: "read-fence".to_owned(),
|
|
|
|
|
command: Command::ListActors,
|
|
|
|
|
})
|
|
|
|
|
.unwrap()
|
|
|
|
|
.into(),
|
|
|
|
|
))
|
|
|
|
|
.unwrap();
|
|
|
|
|
let message = first.read().unwrap();
|
|
|
|
|
let response: runa_protocol::Response =
|
|
|
|
|
serde_json::from_str(message.to_text().unwrap()).unwrap();
|
|
|
|
|
assert_eq!(response.request_id, "read-fence");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn websocket_requires_subprotocol_and_rejects_browser_origins_without_blocking_other_clients() {
|
|
|
|
|
let (_server, address) = start_server();
|
|
|
|
|
// An idle HTTP handshake cannot stall the image or another client's upgrade.
|
|
|
|
|
let _idle = std::net::TcpStream::connect(address).unwrap();
|
|
|
|
|
for (protocol, origin) in [
|
|
|
|
|
(None, false),
|
|
|
|
|
(Some("debug"), false),
|
|
|
|
|
(Some("runa-debug"), true),
|
|
|
|
|
] {
|
|
|
|
|
let mut request = format!("ws://{address}/").into_client_request().unwrap();
|
|
|
|
|
if let Some(protocol) = protocol {
|
|
|
|
|
request
|
|
|
|
|
.headers_mut()
|
|
|
|
|
.insert("sec-websocket-protocol", protocol.parse().unwrap());
|
|
|
|
|
}
|
|
|
|
|
if origin {
|
|
|
|
|
request
|
|
|
|
|
.headers_mut()
|
|
|
|
|
.insert("origin", "https://example.test".parse().unwrap());
|
|
|
|
|
}
|
|
|
|
|
let stream = std::net::TcpStream::connect(address).unwrap();
|
|
|
|
|
stream
|
|
|
|
|
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(tungstenite::client(request, stream).is_err());
|
|
|
|
|
}
|
|
|
|
|
let mut client = WebSocketClient::connect(address).unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
client.request(Command::ListActors).unwrap().result.unwrap(),
|
|
|
|
|
Reply::Actors(Vec::new())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn websocket_rejects_binary_malformed_and_oversized_requests() {
|
|
|
|
|
let (_server, address) = start_server();
|
|
|
|
|
for message in [
|
|
|
|
|
Message::Binary(b"{}".to_vec().into()),
|
|
|
|
|
Message::Text("{not-json}".into()),
|
|
|
|
|
Message::Text(" ".repeat(runa_server::MAX_REQUEST_LINE_BYTES + 1).into()),
|
|
|
|
|
] {
|
|
|
|
|
let mut socket = watcher(address);
|
|
|
|
|
// An oversized frame may be rejected as soon as its header arrives,
|
|
|
|
|
// before the sender finishes writing its payload.
|
|
|
|
|
let _ = socket.send(message);
|
|
|
|
|
assert!(socket.read().is_err());
|
|
|
|
|
}
|
|
|
|
|
let mut client = WebSocketClient::connect(address).unwrap();
|
|
|
|
|
assert!(client.request(Command::ListActors).unwrap().result.is_ok());
|
|
|
|
|
}
|
2026-11-02 13:50:30 +00:00
|
|
|
|
|
|
|
|
struct ServerProcess(Child);
|
|
|
|
|
|
|
|
|
|
impl Drop for ServerProcess {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
let _ = self.0.kill();
|
|
|
|
|
let _ = self.0.wait();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-11-02 13:40:56 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_client_launches_server_and_operates_the_same_live_image() {
|
|
|
|
|
let mut client = LocalClient::spawn(env!("CARGO_BIN_EXE_runa-server"), &[] as &[&str]).unwrap();
|
|
|
|
|
|
|
|
|
|
let published = client
|
|
|
|
|
.request(Command::PublishBehavior {
|
|
|
|
|
name: "counter".to_owned(),
|
|
|
|
|
expected_revision: None,
|
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>
2026-11-02 15:56:03 +00:00
|
|
|
source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned(),
|
2026-11-02 13:40:56 +00:00
|
|
|
})
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(published.request_id, "runa-client-1");
|
|
|
|
|
assert!(matches!(published.result, Ok(Reply::Behavior(_))));
|
|
|
|
|
|
|
|
|
|
let spawned = client
|
|
|
|
|
.request(Command::Spawn {
|
|
|
|
|
behavior_name: "counter".to_owned(),
|
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>
2026-11-02 15:56:03 +00:00
|
|
|
state: Value::Integer(5),
|
2026-11-02 13:40:56 +00:00
|
|
|
mailbox_capacity: 2,
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(spawned.request_id, "runa-client-2");
|
2026-11-02 14:19:25 +00:00
|
|
|
let actor_ref = match spawned.result {
|
|
|
|
|
Ok(Reply::ActorSpawned { actor_ref }) => actor_ref,
|
|
|
|
|
other => panic!("unexpected spawn response: {other:?}"),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(actor_ref.len(), 21);
|
2026-11-02 13:40:56 +00:00
|
|
|
|
|
|
|
|
client
|
|
|
|
|
.request(Command::Send {
|
2026-11-02 14:19:25 +00:00
|
|
|
actor_ref: actor_ref.clone(),
|
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>
2026-11-02 15:56:03 +00:00
|
|
|
message: test_message(0, 7, None),
|
2026-11-02 13:40:56 +00:00
|
|
|
})
|
|
|
|
|
.unwrap();
|
|
|
|
|
client
|
|
|
|
|
.request(Command::Tick {
|
|
|
|
|
ticks: 100,
|
|
|
|
|
instruction_budget: 10,
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
2026-11-02 14:19:25 +00:00
|
|
|
let inspected = client.request(Command::InspectActor { actor_ref }).unwrap();
|
2026-11-02 13:40:56 +00:00
|
|
|
assert!(matches!(
|
|
|
|
|
inspected.result,
|
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>
2026-11-02 15:56:03 +00:00
|
|
|
Ok(Reply::Actor(actor)) if actor.state == Value::Integer(12)
|
2026-11-02 13:40:56 +00:00
|
|
|
));
|
|
|
|
|
assert!(client.shutdown().unwrap().success());
|
|
|
|
|
}
|
2026-11-02 13:50:30 +00:00
|
|
|
|
|
|
|
|
#[test]
|
2026-11-03 14:36:21 +00:00
|
|
|
fn websocket_server_accepts_local_protocol_requests() {
|
2026-11-02 13:50:30 +00:00
|
|
|
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa-server"))
|
|
|
|
|
.args(["--listen", "127.0.0.1:0"])
|
|
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::piped())
|
|
|
|
|
.spawn()
|
|
|
|
|
.unwrap();
|
|
|
|
|
let mut server = ServerProcess(child);
|
|
|
|
|
let stderr = server.0.stderr.take().unwrap();
|
|
|
|
|
let mut stderr = BufReader::new(stderr);
|
|
|
|
|
let mut startup = String::new();
|
|
|
|
|
stderr.read_line(&mut startup).unwrap();
|
|
|
|
|
let address = startup
|
|
|
|
|
.strip_prefix("runa-server: listening on ")
|
|
|
|
|
.expect("server should report its bound address")
|
|
|
|
|
.trim()
|
|
|
|
|
.parse::<SocketAddr>()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
2026-11-03 14:36:21 +00:00
|
|
|
let mut client = WebSocketClient::connect(address).unwrap();
|
|
|
|
|
let response = client.request(Command::ListActors).unwrap();
|
2026-11-02 13:50:30 +00:00
|
|
|
assert_eq!(response.result, Ok(Reply::Actors(Vec::new())));
|
2026-11-02 14:57:26 +00:00
|
|
|
|
2026-11-03 14:36:21 +00:00
|
|
|
let mut first = WebSocketClient::connect(address).unwrap();
|
|
|
|
|
let mut second = WebSocketClient::connect(address).unwrap();
|
2026-11-02 14:57:26 +00:00
|
|
|
let actor_ref = match first
|
|
|
|
|
.request(Command::Spawn {
|
|
|
|
|
behavior_name: "counter".to_owned(),
|
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>
2026-11-02 15:56:03 +00:00
|
|
|
state: Value::Integer(5),
|
2026-11-02 14:57:26 +00:00
|
|
|
mailbox_capacity: 2,
|
|
|
|
|
})
|
|
|
|
|
.unwrap()
|
|
|
|
|
.result
|
|
|
|
|
.unwrap()
|
|
|
|
|
{
|
|
|
|
|
Reply::ActorSpawned { actor_ref } => actor_ref,
|
|
|
|
|
other => panic!("unexpected spawn response: {other:?}"),
|
|
|
|
|
};
|
|
|
|
|
assert!(matches!(second.request(Command::InspectActor {
|
|
|
|
|
actor_ref: actor_ref.clone(),
|
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>
2026-11-02 15:56:03 +00:00
|
|
|
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == Value::Integer(5)));
|
2026-11-02 14:57:26 +00:00
|
|
|
assert!(matches!(
|
|
|
|
|
first
|
|
|
|
|
.request(Command::RollbackBehavior {
|
|
|
|
|
name: "counter".to_owned(),
|
|
|
|
|
expected_revision: 1,
|
|
|
|
|
target_revision: 1,
|
|
|
|
|
})
|
|
|
|
|
.unwrap()
|
|
|
|
|
.result,
|
|
|
|
|
Ok(Reply::Behavior(behavior)) if behavior.revision == 2
|
|
|
|
|
));
|
|
|
|
|
drop(first);
|
|
|
|
|
drop(second);
|
|
|
|
|
|
2026-11-03 14:36:21 +00:00
|
|
|
let mut reconnected = WebSocketClient::connect(address).unwrap();
|
2026-11-02 14:57:26 +00:00
|
|
|
assert!(matches!(
|
|
|
|
|
reconnected
|
|
|
|
|
.request(Command::GetBehavior {
|
|
|
|
|
name: "counter".to_owned(),
|
|
|
|
|
})
|
|
|
|
|
.unwrap()
|
|
|
|
|
.result,
|
|
|
|
|
Ok(Reply::Behavior(behavior)) if behavior.revision == 2
|
|
|
|
|
));
|
|
|
|
|
reconnected
|
|
|
|
|
.request(Command::Send {
|
|
|
|
|
actor_ref: actor_ref.clone(),
|
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>
2026-11-02 15:56:03 +00:00
|
|
|
message: Value::Record(std::collections::BTreeMap::from([
|
|
|
|
|
("op".to_owned(), Value::String("add".to_owned())),
|
|
|
|
|
("value".to_owned(), Value::Integer(7)),
|
|
|
|
|
])),
|
2026-11-02 14:57:26 +00:00
|
|
|
})
|
|
|
|
|
.unwrap()
|
|
|
|
|
.result
|
|
|
|
|
.unwrap();
|
|
|
|
|
reconnected
|
|
|
|
|
.request(Command::Tick {
|
|
|
|
|
ticks: 100,
|
|
|
|
|
instruction_budget: 10,
|
|
|
|
|
})
|
|
|
|
|
.unwrap()
|
|
|
|
|
.result
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(matches!(reconnected.request(Command::InspectActor {
|
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>
2026-11-02 15:56:03 +00:00
|
|
|
actor_ref: actor_ref.clone(),
|
|
|
|
|
}).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),
|
|
|
|
|
),
|
|
|
|
|
]))
|
2026-11-02 13:50:30 +00:00
|
|
|
}
|