feat: add graphical world client

Add an egui actor browser, inspector, and debugger controls over the shared WebSocket image.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-04 04:49:07 +01:00
parent d63580cc02
commit 97585a6f10
8 changed files with 3577 additions and 17 deletions

View File

@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### Added
- Add an egui/eframe graphical client for browsing and inspecting actors in a
running image, with message, lifecycle, breakpoint, stepping, stack, and log
views.
### Changed
- Pretty-print actor state and mailbox JSON with two-space indentation while

2704
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ members = [
"crates/runa-protocol",
"crates/runa-rune",
"crates/runa-runtime",
"crates/runa-world",
]
[workspace.dependencies]

View File

@ -13,8 +13,10 @@ experiment using Rune 0.14.2: script execution, native calls, async suspension,
instruction budgets, stepping, and compiled-code sharing. It also runs a minimal
single-worker actor demonstration with bounded mailboxes and explicit message
effects. Source and compiled implementation identities are BLAKE3 content
addresses with an in-memory compile cache. Automatic crash recovery, canonicalization,
remote network access, and the graphical world are not implemented yet.
addresses with an in-memory compile cache. Automatic crash recovery,
canonicalization, and remote network access are not implemented yet. The
initial native graphical world client can browse and inspect actors in a
running local image.
A minimal VS Code extension can connect to the local image, list actors, and
inspect state, queued/reserved messages, and execution telemetry. Inspection
JSON is editable for inactive paused/failed actors; saving restores
@ -74,6 +76,20 @@ In another terminal, connect the REPL:
mise run repl
```
To open the initial graphical world client against the same running image:
```sh
mise run world
```
The world client connects to `127.0.0.1:3737` by default. Set `RUNA_CONNECT` or
pass `--connect 127.0.0.1:4000` to select another loopback address. It provides
actor browsing and inspection, message sending, lifecycle controls, explicit
scheduler ticks, bounded debugger stepping, source breakpoints,
execution-stack inspection, and retained logs. Rune frame locals and expression
evaluation are not available. The client does not create or embed a separate
image.
The REPL defaults to `127.0.0.1:3737`. To select another loopback endpoint,
use `cargo run -p runa-cli -- --connect 127.0.0.1:4000 repl` and start the server with
the corresponding `--listen` address. Quitting the REPL disconnects only that

View File

@ -0,0 +1,16 @@
[package]
name = "runa-world"
version = "0.0.0"
edition = "2024"
[dependencies]
clap.workspace = true
eframe = {
version = "0.31.1",
default-features = false,
features = ["default_fonts", "glow", "wayland", "x11"]
}
runa-client = { path = "../runa-client" }
runa-core = { path = "../runa-core" }
runa-protocol = { path = "../runa-protocol" }
serde_json = "1"

View File

@ -0,0 +1,836 @@
use std::{
collections::BTreeMap,
net::SocketAddr,
sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError},
thread,
time::{Duration, Instant},
};
use clap::Parser;
use eframe::egui;
use runa_client::WebSocketClient;
use runa_core::{ActorRef, StepMode, Value};
use runa_protocol::{
ActorSnapshotDto, ActorStatusDto, Command, ExecutionInspectionDto, LogEntry, Reply,
SystemMessageDto,
};
#[derive(Parser)]
#[command(
name = "runa-world",
about = "Graphical client for a running Runa image"
)]
struct Args {
#[arg(long, env = "RUNA_CONNECT", default_value = "127.0.0.1:3737")]
connect: SocketAddr,
}
fn main() -> eframe::Result<()> {
let args = Args::parse();
eframe::run_native(
"Runa World",
eframe::NativeOptions::default(),
Box::new(move |creation| Ok(Box::new(WorldApp::new(creation, args.connect)))),
)
}
enum WorkerRequest {
Connect(SocketAddr),
Select(Option<String>),
Command(Command),
Disconnect,
}
enum WorkerUpdate {
Status(String),
Notice(String),
Reset,
Snapshot(Box<WorkerSnapshot>),
}
struct WorkerSnapshot {
actors: Vec<ActorSnapshotDto>,
sources: Vec<String>,
actor: Option<ActorSnapshotDto>,
execution: Option<ExecutionInspectionDto>,
logs: Vec<LogEntry>,
}
struct WorldApp {
requests: Sender<WorkerRequest>,
updates: Receiver<WorkerUpdate>,
endpoint: String,
status: String,
notice: String,
actors: Vec<ActorSnapshotDto>,
sources: Vec<String>,
selected_actor: Option<String>,
actor: Option<ActorSnapshotDto>,
execution: Option<ExecutionInspectionDto>,
logs: Vec<LogEntry>,
breakpoints: String,
step_budget: String,
step_mode: StepMode,
spawn_behavior: String,
spawn_state: String,
message_json: String,
scheduler_ticks: String,
scheduler_budget: String,
}
impl WorldApp {
fn new(_creation: &eframe::CreationContext<'_>, connect: SocketAddr) -> Self {
let (request_tx, request_rx) = mpsc::channel();
let (update_tx, update_rx) = mpsc::channel();
thread::Builder::new()
.name("runa-world-client".to_owned())
.spawn(move || client_worker(request_rx, update_tx))
.expect("failed to start Runa client worker");
let _ = request_tx.send(WorkerRequest::Connect(connect));
Self {
requests: request_tx,
updates: update_rx,
endpoint: connect.to_string(),
status: "Connecting".to_owned(),
notice: String::new(),
actors: Vec::new(),
sources: Vec::new(),
selected_actor: None,
actor: None,
execution: None,
logs: Vec::new(),
breakpoints: String::new(),
step_budget: "1000".to_owned(),
step_mode: StepMode::Into,
spawn_behavior: String::new(),
spawn_state: "null".to_owned(),
message_json: "{\"op\":\"inc\"}".to_owned(),
scheduler_ticks: "1".to_owned(),
scheduler_budget: "1000".to_owned(),
}
}
fn submit(&mut self, command: Command) {
if self.requests.send(WorkerRequest::Command(command)).is_err() {
self.status = "Client worker stopped".to_owned();
}
}
fn choose_actor(&mut self, actor_ref: Option<String>) {
if self.selected_actor != actor_ref {
self.selected_actor = actor_ref.clone();
self.actor = None;
self.execution = None;
self.logs.clear();
self.breakpoints.clear();
let _ = self.requests.send(WorkerRequest::Select(actor_ref));
}
}
fn read_worker_updates(&mut self) {
loop {
match self.updates.try_recv() {
Ok(WorkerUpdate::Status(status)) => self.status = status,
Ok(WorkerUpdate::Notice(notice)) => self.notice = notice,
Ok(WorkerUpdate::Reset) => {
self.choose_actor(None);
self.actors.clear();
self.sources.clear();
self.actor = None;
self.execution = None;
self.logs.clear();
}
Ok(WorkerUpdate::Snapshot(snapshot)) => {
if self.selected_actor.is_none()
&& let Some(first) = snapshot.actors.first()
{
self.choose_actor(Some(first.actor_ref.clone()));
}
self.actors = snapshot.actors;
self.sources = snapshot.sources;
self.actor = snapshot.actor;
self.execution = snapshot.execution;
self.logs = snapshot.logs;
}
Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
}
}
}
fn draw_connection(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label("Image");
ui.text_edit_singleline(&mut self.endpoint);
if ui.button("Connect").clicked() {
match self.endpoint.parse() {
Ok(address) => {
self.status = format!("Connecting to {address}");
let _ = self.requests.send(WorkerRequest::Connect(address));
}
Err(error) => self.notice = format!("Invalid loopback address: {error}"),
}
}
ui.separator();
ui.label(&self.status);
});
if !self.notice.is_empty() {
ui.colored_label(egui::Color32::LIGHT_RED, &self.notice);
}
}
fn draw_actor_list(&mut self, ui: &mut egui::Ui) {
ui.heading("Actors");
ui.horizontal(|ui| {
ui.label("Sources");
egui::ComboBox::from_id_salt("spawn_behavior")
.selected_text(if self.spawn_behavior.is_empty() {
"Choose behavior"
} else {
&self.spawn_behavior
})
.show_ui(ui, |ui| {
for path in &self.sources {
ui.selectable_value(&mut self.spawn_behavior, path.clone(), path);
}
});
});
ui.horizontal(|ui| {
if ui.button("Spawn").clicked() {
match parse_value(&self.spawn_state) {
Ok(state) if !self.spawn_behavior.trim().is_empty() => {
self.submit(Command::Spawn {
behavior_name: self.spawn_behavior.trim().to_owned(),
state,
mailbox_capacity: 64,
});
}
Ok(_) => self.notice = "Enter a behavior path to spawn".to_owned(),
Err(error) => self.notice = format!("Invalid state JSON: {error}"),
}
}
});
ui.horizontal(|ui| {
ui.label("Initial state");
ui.text_edit_singleline(&mut self.spawn_state);
});
ui.horizontal(|ui| {
ui.label("Scheduler ticks");
ui.text_edit_singleline(&mut self.scheduler_ticks);
ui.label("Instruction budget");
ui.text_edit_singleline(&mut self.scheduler_budget);
if ui.button("Advance").clicked() {
match (
self.scheduler_ticks.parse::<usize>(),
self.scheduler_budget.parse::<usize>(),
) {
(Ok(ticks), Ok(instruction_budget)) if ticks > 0 && instruction_budget > 0 => {
self.submit(Command::Tick {
ticks,
instruction_budget,
});
}
_ => {
self.notice =
"Tick count and instruction budget must be positive integers".to_owned()
}
}
}
});
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
let actors = self.actors.clone();
for actor in actors {
let label = format!("{} · {}", actor.actor_ref, status_label(&actor.status));
if ui
.selectable_label(
self.selected_actor.as_deref() == Some(&actor.actor_ref),
label,
)
.clicked()
{
self.choose_actor(Some(actor.actor_ref));
}
}
});
}
fn draw_inspector(&mut self, ui: &mut egui::Ui) {
let Some(actor) = self.actor.clone() else {
ui.heading("Actor inspector");
ui.label("Select an actor to inspect its state, mailbox, logs, and execution.");
return;
};
ui.heading(format!("Actor {}", actor.actor_ref));
ui.horizontal_wrapped(|ui| {
ui.label(format!("Status: {}", status_label(&actor.status)));
ui.label(format!("Incarnation: {}", actor.incarnation));
ui.label(format!(
"Behavior: {}",
actor.behavior_path.as_deref().unwrap_or("—")
));
ui.label(format!("Implementation: {}", actor.implementation_id));
});
ui.horizontal(|ui| {
for (label, command) in [
(
"Pause",
Command::PauseActor {
actor_ref: actor.actor_ref.clone(),
},
),
(
"Resume",
Command::ResumeActor {
actor_ref: actor.actor_ref.clone(),
},
),
(
"Restart",
Command::RestartActor {
actor_ref: actor.actor_ref.clone(),
},
),
(
"Stop",
Command::StopActor {
actor_ref: actor.actor_ref.clone(),
},
),
(
"Cancel",
Command::CancelActor {
actor_ref: actor.actor_ref.clone(),
},
),
] {
if ui.button(label).clicked() {
self.submit(command);
}
}
if ui.button("Kill").clicked() {
self.submit(Command::System {
actor_ref: actor.actor_ref.clone(),
message: SystemMessageDto::Kill,
});
}
});
egui::ScrollArea::vertical().show(ui, |ui| {
ui.collapsing("State", |ui| {
show_json(ui, &actor.state);
});
ui.collapsing(format!("Mailbox ({})", actor.queued.len()), |ui| {
if let Some(reserved) = &actor.reserved {
ui.label("Reserved");
show_json(ui, reserved);
}
for (index, message) in actor.queued.iter().enumerate() {
ui.label(format!("Queued {index}"));
show_json(ui, message);
}
});
self.draw_message_sender(ui, &actor.actor_ref);
self.draw_debugger(ui, &actor);
self.draw_logs(ui, &actor.actor_ref);
});
}
fn draw_message_sender(&mut self, ui: &mut egui::Ui, actor_ref: &str) {
ui.collapsing("Send message", |ui| {
ui.horizontal(|ui| {
ui.text_edit_singleline(&mut self.message_json);
if ui.button("Send").clicked() {
match parse_value(&self.message_json) {
Ok(message) => self.submit(Command::Send {
actor_ref: actor_ref.to_owned(),
message,
}),
Err(error) => self.notice = format!("Invalid message JSON: {error}"),
}
}
});
});
}
fn draw_debugger(&mut self, ui: &mut egui::Ui, actor: &ActorSnapshotDto) {
ui.collapsing("Debugger", |ui| {
ui.horizontal(|ui| {
ui.label("Breakpoint lines (1-based, comma-separated)");
ui.text_edit_singleline(&mut self.breakpoints);
if ui.button("Set").clicked() {
match parse_breakpoints(&self.breakpoints) {
Ok(lines) => self.submit(Command::SetBreakpoints {
actor_ref: actor.actor_ref.clone(),
implementation_id: actor.implementation_id.clone(),
lines,
}),
Err(error) => self.notice = error,
}
}
});
ui.horizontal(|ui| {
egui::ComboBox::from_label("Step")
.selected_text(format!("{:?}", self.step_mode))
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.step_mode, StepMode::Into, "Into");
ui.selectable_value(&mut self.step_mode, StepMode::Over, "Over");
ui.selectable_value(&mut self.step_mode, StepMode::Out, "Out");
});
ui.label("Instruction budget");
ui.text_edit_singleline(&mut self.step_budget);
if ui.button("Step").clicked() {
match self.step_budget.parse::<usize>() {
Ok(instruction_budget) if instruction_budget > 0 => {
self.submit(Command::StepSource {
actor_ref: actor.actor_ref.clone(),
implementation_id: actor.implementation_id.clone(),
instruction_budget,
mode: self.step_mode,
});
}
_ => {
self.notice = "Instruction budget must be a positive integer".to_owned()
}
}
}
});
if let Some(execution) = &self.execution {
ui.label(format!(
"VM steps: {} · call depth: {} · stop reason: {:?}",
execution
.execution
.as_ref()
.map_or(0, |details| details.vm_steps),
execution
.execution
.as_ref()
.map_or(0, |details| details.call_depth),
execution.debugger.stop_reason
));
if let Some(details) = &execution.execution {
for (index, frame) in details.frames.iter().enumerate() {
let location = frame.range.map_or_else(
|| "location unavailable".to_owned(),
|range| {
format!("{}:{}", range.start.line + 1, range.start.character + 1)
},
);
ui.label(format!(
"#{index} {:?} {} ({location})",
frame.kind,
frame.function.as_deref().unwrap_or("<anonymous>")
));
}
if details.frames_truncated {
ui.label("Stack display is truncated.");
}
} else {
ui.label("No active execution stack.");
}
if !execution.source.is_empty() {
ui.collapsing("Installed source", |ui| {
egui::ScrollArea::vertical()
.max_height(240.0)
.show(ui, |ui| {
for (line, contents) in execution.source.lines().enumerate() {
ui.monospace(format!("{:>4} {contents}", line + 1));
}
});
});
}
}
ui.small("Rune frame locals and expression evaluation are not available.");
});
}
fn draw_logs(&mut self, ui: &mut egui::Ui, actor_ref: &str) {
ui.collapsing("Logs", |ui| {
let entries = self
.logs
.iter()
.filter(|entry| entry.actor_ref.as_str() == actor_ref)
.collect::<Vec<_>>();
if entries.is_empty() {
ui.label("No retained log entries.");
}
for entry in entries {
ui.monospace(format!(
"{} [{}] {}",
entry.sequence,
format!("{:?}", entry.level).to_uppercase(),
entry.message
));
}
});
}
}
impl eframe::App for WorldApp {
fn update(&mut self, context: &egui::Context, _frame: &mut eframe::Frame) {
self.read_worker_updates();
egui::TopBottomPanel::top("connection").show(context, |ui| self.draw_connection(ui));
egui::SidePanel::left("actor_list")
.resizable(true)
.default_width(340.0)
.show(context, |ui| self.draw_actor_list(ui));
egui::CentralPanel::default().show(context, |ui| self.draw_inspector(ui));
context.request_repaint_after(Duration::from_millis(100));
}
}
impl Drop for WorldApp {
fn drop(&mut self) {
let _ = self.requests.send(WorkerRequest::Disconnect);
}
}
enum CallError {
Transport(String),
Protocol(String),
}
fn request_reply(client: &mut WebSocketClient, command: Command) -> Result<Reply, CallError> {
let response = client
.request(command)
.map_err(|error| CallError::Transport(error.to_string()))?;
response
.result
.map_err(|error| CallError::Protocol(format!("{:?}: {}", error.code, error.message)))
}
fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate>) {
let mut client = None;
let mut selected_actor = None;
let mut log_sequence = 0;
let mut retained_logs = Vec::new();
let mut next_poll = Instant::now();
loop {
match requests.recv_timeout(Duration::from_millis(50)) {
Ok(WorkerRequest::Connect(address)) => match WebSocketClient::connect(address) {
Ok(connected) => {
client = Some(connected);
selected_actor = None;
log_sequence = 0;
retained_logs.clear();
next_poll = Instant::now();
let _ = updates.send(WorkerUpdate::Reset);
let _ = updates.send(WorkerUpdate::Status(format!("Connected to {address}")));
}
Err(error) => {
client = None;
let _ =
updates.send(WorkerUpdate::Status(format!("Connection failed: {error}")));
}
},
Ok(WorkerRequest::Select(actor_ref)) => {
selected_actor = actor_ref;
log_sequence = 0;
retained_logs.clear();
next_poll = Instant::now();
}
Ok(WorkerRequest::Disconnect) => {
if let Some(mut connected) = client {
let _ = connected.close();
}
return;
}
Ok(WorkerRequest::Command(command)) => {
let Some(connected) = client.as_mut() else {
let _ = updates.send(WorkerUpdate::Notice(
"Connect to a running image before issuing commands".to_owned(),
));
continue;
};
match request_reply(connected, command) {
Ok(reply) => {
let _ = updates.send(WorkerUpdate::Notice(format!("Completed: {reply:?}")));
next_poll = Instant::now();
}
Err(CallError::Protocol(error)) => {
let _ = updates.send(WorkerUpdate::Notice(error));
}
Err(CallError::Transport(error)) => {
client = None;
let _ =
updates.send(WorkerUpdate::Status(format!("Disconnected: {error}")));
}
}
}
Err(RecvTimeoutError::Disconnected) => {
if let Some(mut connected) = client {
let _ = connected.close();
}
return;
}
Err(RecvTimeoutError::Timeout) => {}
}
if Instant::now() < next_poll {
continue;
}
next_poll = Instant::now() + Duration::from_millis(400);
let Some(connected) = client.as_mut() else {
continue;
};
let actors = match request_reply(connected, Command::ListActors) {
Ok(Reply::Actors(actors)) => actors,
Ok(reply) => {
let _ = updates.send(WorkerUpdate::Notice(format!(
"Unexpected actor-list response: {reply:?}"
)));
continue;
}
Err(error) => {
report_call_error(error, &updates, &mut client);
continue;
}
};
let sources = match request_reply(connected, Command::ListSources) {
Ok(Reply::Sources(sources)) => sources.into_iter().map(|source| source.path).collect(),
Ok(_) => Vec::new(),
Err(error) => {
report_call_error(error, &updates, &mut client);
continue;
}
};
let actor = selected_actor.as_ref().and_then(|selected| {
actors
.iter()
.find(|actor| &actor.actor_ref == selected)
.cloned()
});
let (execution, logs) = if let Some(actor) = &actor {
let execution = match request_reply(
connected,
Command::InspectExecution {
actor_ref: actor.actor_ref.clone(),
},
) {
Ok(Reply::ExecutionInspected(execution)) => Some(execution),
Ok(_) => None,
Err(error) => {
report_call_error(error, &updates, &mut client);
continue;
}
};
let logs = match request_reply(
connected,
Command::Logs {
actors: vec![actor.actor_ref.clone()],
follow: false,
after: log_sequence,
},
) {
Ok(Reply::Logs {
entries,
next_sequence,
..
}) => {
log_sequence = next_sequence;
retained_logs.extend(entries);
if retained_logs.len() > 200 {
retained_logs.drain(..retained_logs.len() - 200);
}
retained_logs.clone()
}
Ok(_) => Vec::new(),
Err(error) => {
report_call_error(error, &updates, &mut client);
continue;
}
};
(execution, logs)
} else {
(None, Vec::new())
};
let _ = updates.send(WorkerUpdate::Snapshot(Box::new(WorkerSnapshot {
actors,
sources,
actor,
execution,
logs,
})));
}
}
fn report_call_error(
error: CallError,
updates: &Sender<WorkerUpdate>,
client: &mut Option<WebSocketClient>,
) {
match error {
CallError::Protocol(message) => {
let _ = updates.send(WorkerUpdate::Notice(message));
}
CallError::Transport(message) => {
*client = None;
let _ = updates.send(WorkerUpdate::Status(format!("Disconnected: {message}")));
}
}
}
fn status_label(status: &ActorStatusDto) -> String {
match status {
ActorStatusDto::Failed(message) => format!("Failed: {message}"),
other => format!("{other:?}"),
}
}
fn parse_breakpoints(input: &str) -> Result<Vec<usize>, String> {
input
.split(',')
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
line.parse::<usize>()
.ok()
.filter(|line| *line > 0)
.map(|line| line - 1)
.ok_or_else(|| {
format!("Invalid breakpoint line `{line}`; use positive line numbers")
})
})
.collect()
}
fn parse_value(input: &str) -> Result<Value, String> {
if input.len() > runa_client::MAX_MESSAGE_BYTES {
return Err("JSON input exceeds 1 MiB".to_owned());
}
let value: serde_json::Value =
serde_json::from_str(input).map_err(|error| error.to_string())?;
let value = from_json_value(value)?;
value.validate()?;
Ok(value)
}
fn from_json_value(value: serde_json::Value) -> Result<Value, String> {
match value {
serde_json::Value::Null => Ok(Value::Null),
serde_json::Value::Bool(value) => Ok(Value::Bool(value)),
serde_json::Value::Number(value) => value
.as_i64()
.map(Value::Integer)
.ok_or_else(|| "actor values support only signed 64-bit integers".to_owned()),
serde_json::Value::String(value) => Ok(Value::String(value)),
serde_json::Value::Array(values) => values
.into_iter()
.map(from_json_value)
.collect::<Result<Vec<_>, _>>()
.map(Value::List),
serde_json::Value::Object(mut object) => {
if object.len() == 1
&& let Some(reference) = object.remove("$actor")
{
let serde_json::Value::String(actor_ref) = reference else {
return Err("$actor requires an actor reference string".to_owned());
};
let actor_ref = actor_ref.strip_prefix('@').unwrap_or(&actor_ref);
return ActorRef::parse(actor_ref)
.map(Value::ActorRef)
.ok_or_else(|| "$actor requires a valid actor reference".to_owned());
}
if object.len() == 1
&& let Some(escaped) = object.remove("$record")
{
let serde_json::Value::Object(escaped) = escaped else {
return Err("$record requires a JSON object".to_owned());
};
return record_from_json_object(escaped);
}
record_from_json_object(object)
}
}
}
fn record_from_json_object(
object: serde_json::Map<String, serde_json::Value>,
) -> Result<Value, String> {
object
.into_iter()
.map(|(key, value)| Ok((key, from_json_value(value)?)))
.collect::<Result<BTreeMap<_, _>, String>>()
.map(Value::Record)
}
fn show_json(ui: &mut egui::Ui, value: &Value) {
let display = serde_json::to_string_pretty(&to_json_value(value));
match display {
Ok(display) => {
ui.monospace(display);
}
Err(error) => {
ui.colored_label(
egui::Color32::LIGHT_RED,
format!("Could not format value: {error}"),
);
}
}
}
fn to_json_value(value: &Value) -> serde_json::Value {
match value {
Value::Null => serde_json::Value::Null,
Value::Bool(value) => serde_json::Value::Bool(*value),
Value::Integer(value) => serde_json::Value::Number((*value).into()),
Value::String(value) => serde_json::Value::String(value.clone()),
Value::List(values) => serde_json::Value::Array(values.iter().map(to_json_value).collect()),
Value::Record(values) => {
let object = serde_json::Value::Object(
values
.iter()
.map(|(key, value)| (key.clone(), to_json_value(value)))
.collect(),
);
if values.len() == 1
&& (values.contains_key("$actor") || values.contains_key("$record"))
{
serde_json::json!({ "$record": object })
} else {
object
}
}
Value::ActorRef(actor_ref) => serde_json::json!({ "$actor": actor_ref.as_str() }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_nested_json_and_actor_reference_markers() {
let actor_ref = ActorRef::new();
let parsed = parse_value(&format!(
r#"{{"count":2,"items":[true,{{"$actor":"{actor_ref}"}}]}}"#
))
.unwrap();
assert_eq!(
parsed,
Value::Record(BTreeMap::from([
("count".to_owned(), Value::Integer(2)),
(
"items".to_owned(),
Value::List(vec![Value::Bool(true), Value::ActorRef(actor_ref),]),
),
]))
);
}
#[test]
fn rejects_floating_point_values_and_invalid_breakpoint_lines() {
assert!(parse_value("1.5").is_err());
assert!(parse_breakpoints("1, 0").is_err());
assert_eq!(parse_breakpoints("1, 4").unwrap(), vec![0, 3]);
}
#[test]
fn round_trips_reserved_record_keys_without_confusing_them_for_markers() {
let value = Value::Record(BTreeMap::from([(
"$actor".to_owned(),
Value::String("literal".to_owned()),
)]));
let json = serde_json::to_string(&to_json_value(&value)).unwrap();
assert_eq!(parse_value(&json).unwrap(), value);
}
}

View File

@ -219,6 +219,13 @@ Not every graphical node must be an actor. Use actors for independently schedule
The graphical inspector should support selecting a component, locating its owning actor, inspecting its state and behavior, editing its implementation, and observing live changes. The development environment must be able to inspect itself.
The first native client milestone uses egui/eframe and connects to the existing
loopback image over the shared WebSocket protocol. It currently provides an
actor browser and inspector with message, lifecycle, log, breakpoint, bounded
step, and execution-stack controls. Network requests run on a worker thread,
not the UI frame loop. This is a client foundation, not yet a Rune-composed
graphical world; frame locals and expression evaluation remain unavailable.
## 8. Unified Programmatic Image API
The running image exposes a versioned semantic API, used by graphical tools, CLI tools, tests, automation, coding agents, and the VS Code extension. External clients use WebSockets to reach the same running image; there is no stdio transport. Client location does not change actor placement or message-passing semantics.

View File

@ -25,6 +25,10 @@ run = "cargo run -p runa"
description = "Start the Runa REPL connected to the image server"
run = "cargo run -p runa-cli -- repl"
[tasks.world]
description = "Start the Runa graphical world client"
run = "cargo run -p runa-world"
[tasks.lint]
description = "Check Rust targets and the TypeScript extension"
run = ["cargo clippy --workspace --all-targets -- -D warnings", "npm --prefix vscode run lint"]