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:
parent
d63580cc02
commit
97585a6f10
|
|
@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Changed
|
||||||
|
|
||||||
- Pretty-print actor state and mailbox JSON with two-space indentation while
|
- Pretty-print actor state and mailbox JSON with two-space indentation while
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,6 +11,7 @@ members = [
|
||||||
"crates/runa-protocol",
|
"crates/runa-protocol",
|
||||||
"crates/runa-rune",
|
"crates/runa-rune",
|
||||||
"crates/runa-runtime",
|
"crates/runa-runtime",
|
||||||
|
"crates/runa-world",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -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
|
instruction budgets, stepping, and compiled-code sharing. It also runs a minimal
|
||||||
single-worker actor demonstration with bounded mailboxes and explicit message
|
single-worker actor demonstration with bounded mailboxes and explicit message
|
||||||
effects. Source and compiled implementation identities are BLAKE3 content
|
effects. Source and compiled implementation identities are BLAKE3 content
|
||||||
addresses with an in-memory compile cache. Automatic crash recovery, canonicalization,
|
addresses with an in-memory compile cache. Automatic crash recovery,
|
||||||
remote network access, and the graphical world are not implemented yet.
|
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
|
A minimal VS Code extension can connect to the local image, list actors, and
|
||||||
inspect state, queued/reserved messages, and execution telemetry. Inspection
|
inspect state, queued/reserved messages, and execution telemetry. Inspection
|
||||||
JSON is editable for inactive paused/failed actors; saving restores
|
JSON is editable for inactive paused/failed actors; saving restores
|
||||||
|
|
@ -74,6 +76,20 @@ In another terminal, connect the REPL:
|
||||||
mise run 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,
|
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
|
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
|
the corresponding `--listen` address. Quitting the REPL disconnects only that
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 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
|
## 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ run = "cargo run -p runa"
|
||||||
description = "Start the Runa REPL connected to the image server"
|
description = "Start the Runa REPL connected to the image server"
|
||||||
run = "cargo run -p runa-cli -- repl"
|
run = "cargo run -p runa-cli -- repl"
|
||||||
|
|
||||||
|
[tasks.world]
|
||||||
|
description = "Start the Runa graphical world client"
|
||||||
|
run = "cargo run -p runa-world"
|
||||||
|
|
||||||
[tasks.lint]
|
[tasks.lint]
|
||||||
description = "Check Rust targets and the TypeScript extension"
|
description = "Check Rust targets and the TypeScript extension"
|
||||||
run = ["cargo clippy --workspace --all-targets -- -D warnings", "npm --prefix vscode run lint"]
|
run = ["cargo clippy --workspace --all-targets -- -D warnings", "npm --prefix vscode run lint"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue