feat: add actor behavior editor to world

Add actor-local temporary behavior editing, reload-from-source, and confirmed updates to the shared source while guarding unsaved drafts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-04 06:38:47 +01:00
parent 2f9d500ca3
commit c697010765
4 changed files with 259 additions and 12 deletions

View File

@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file.
source commits, and explicit image-event collection. source commits, and explicit image-event collection.
- Add committed source history browsing, revision preview, and confirmed - Add committed source history browsing, revision preview, and confirmed
rollback to the graphical source editor. rollback to the graphical source editor.
- Add actor-local temporary behavior editing and guarded shared-source updates
to the graphical actor inspector.
### Changed ### Changed

View File

@ -90,6 +90,9 @@ execution-stack inspection, and retained logs. Its source view supports
editing image sources with optimistic conflict checks, compilation diagnostics, editing image sources with optimistic conflict checks, compilation diagnostics,
explicit history commits, and revision preview/rollback. Rollback requires explicit history commits, and revision preview/rollback. Rollback requires
confirmation and creates a new current source version without deleting history. confirmation and creates a new current source version without deleting history.
The actor inspector can also load and edit an actor-local temporary behavior,
save it with source-identity checks, reload the behavior from the image source,
or explicitly confirm updating the shared source from the temporary version.
The events view has a manual collection action: The events view has a manual collection action:
collecting events consumes the image's shared event queue, so other clients do collecting events consumes the image's shared event queue, so other clients do
not receive events collected by the world client. Rune frame locals and not receive events collected by the world client. Rune frame locals and

View File

@ -51,6 +51,13 @@ enum WorkerUpdate {
submitted_source: String, submitted_source: String,
}, },
SourceDiagnostics(Vec<SourceDiagnostic>), SourceDiagnostics(Vec<SourceDiagnostic>),
ActorBehavior {
actor_ref: String,
path: String,
source_id: String,
source: String,
submitted_source: Option<String>,
},
SourceHistory { SourceHistory {
path: String, path: String,
history: Vec<SourceCommitDto>, history: Vec<SourceCommitDto>,
@ -74,6 +81,7 @@ enum WorldView {
struct WorkerSnapshot { struct WorkerSnapshot {
actors: Vec<ActorSnapshotDto>, actors: Vec<ActorSnapshotDto>,
sources: Vec<String>, sources: Vec<String>,
source_ids: BTreeMap<String, String>,
actor: Option<ActorSnapshotDto>, actor: Option<ActorSnapshotDto>,
execution: Option<ExecutionInspectionDto>, execution: Option<ExecutionInspectionDto>,
logs: Vec<LogEntry>, logs: Vec<LogEntry>,
@ -87,6 +95,7 @@ struct WorldApp {
notice: String, notice: String,
actors: Vec<ActorSnapshotDto>, actors: Vec<ActorSnapshotDto>,
sources: Vec<String>, sources: Vec<String>,
source_ids: BTreeMap<String, String>,
selected_actor: Option<String>, selected_actor: Option<String>,
actor: Option<ActorSnapshotDto>, actor: Option<ActorSnapshotDto>,
execution: Option<ExecutionInspectionDto>, execution: Option<ExecutionInspectionDto>,
@ -110,6 +119,10 @@ struct WorldApp {
revision_preview: Option<(String, String)>, revision_preview: Option<(String, String)>,
rollback_confirmation: Option<(String, String)>, rollback_confirmation: Option<(String, String)>,
events: Vec<EventDto>, events: Vec<EventDto>,
actor_behavior: Option<SourceFileDto>,
actor_behavior_draft: String,
actor_behavior_dirty: bool,
update_behavior_confirmation: bool,
} }
impl WorldApp { impl WorldApp {
@ -129,6 +142,7 @@ impl WorldApp {
notice: String::new(), notice: String::new(),
actors: Vec::new(), actors: Vec::new(),
sources: Vec::new(), sources: Vec::new(),
source_ids: BTreeMap::new(),
selected_actor: None, selected_actor: None,
actor: None, actor: None,
execution: None, execution: None,
@ -152,6 +166,10 @@ impl WorldApp {
revision_preview: None, revision_preview: None,
rollback_confirmation: None, rollback_confirmation: None,
events: Vec::new(), events: Vec::new(),
actor_behavior: None,
actor_behavior_draft: String::new(),
actor_behavior_dirty: false,
update_behavior_confirmation: false,
} }
} }
@ -163,11 +181,20 @@ impl WorldApp {
fn choose_actor(&mut self, actor_ref: Option<String>) { fn choose_actor(&mut self, actor_ref: Option<String>) {
if self.selected_actor != actor_ref { if self.selected_actor != actor_ref {
if self.actor_behavior_dirty {
self.notice =
"Save or discard the current actor behavior draft before changing actors"
.to_owned();
return;
}
self.selected_actor = actor_ref.clone(); self.selected_actor = actor_ref.clone();
self.actor = None; self.actor = None;
self.execution = None; self.execution = None;
self.logs.clear(); self.logs.clear();
self.breakpoints.clear(); self.breakpoints.clear();
self.actor_behavior = None;
self.actor_behavior_draft.clear();
self.actor_behavior_dirty = false;
let _ = self.requests.send(WorkerRequest::Select(actor_ref)); let _ = self.requests.send(WorkerRequest::Select(actor_ref));
} }
} }
@ -202,6 +229,7 @@ impl WorldApp {
self.choose_actor(None); self.choose_actor(None);
self.actors.clear(); self.actors.clear();
self.sources.clear(); self.sources.clear();
self.source_ids.clear();
self.actor = None; self.actor = None;
self.execution = None; self.execution = None;
self.logs.clear(); self.logs.clear();
@ -213,6 +241,9 @@ impl WorldApp {
self.selected_revision = None; self.selected_revision = None;
self.revision_preview = None; self.revision_preview = None;
self.events.clear(); self.events.clear();
self.actor_behavior = None;
self.actor_behavior_draft.clear();
self.actor_behavior_dirty = false;
} }
Ok(WorkerUpdate::SourceLoaded(source)) => { Ok(WorkerUpdate::SourceLoaded(source)) => {
if self.loading_source.as_deref() == Some(source.path.as_str()) { if self.loading_source.as_deref() == Some(source.path.as_str()) {
@ -239,6 +270,28 @@ impl WorldApp {
Ok(WorkerUpdate::SourceDiagnostics(diagnostics)) => { Ok(WorkerUpdate::SourceDiagnostics(diagnostics)) => {
self.source_diagnostics = diagnostics; self.source_diagnostics = diagnostics;
} }
Ok(WorkerUpdate::ActorBehavior {
actor_ref,
path,
source_id,
source,
submitted_source,
}) => {
if self.selected_actor.as_deref() == Some(&actor_ref) {
if submitted_source
.as_ref()
.is_none_or(|submitted| self.actor_behavior_draft == *submitted)
{
self.actor_behavior_draft.clone_from(&source);
}
self.actor_behavior_dirty = self.actor_behavior_draft != source;
self.actor_behavior = Some(SourceFileDto {
path,
source_id,
source,
});
}
}
Ok(WorkerUpdate::SourceHistory { path, history }) => { Ok(WorkerUpdate::SourceHistory { path, history }) => {
if self.source_path() != Some(path.as_str()) { if self.source_path() != Some(path.as_str()) {
continue; continue;
@ -272,8 +325,20 @@ impl WorldApp {
{ {
self.choose_actor(Some(first.actor_ref.clone())); self.choose_actor(Some(first.actor_ref.clone()));
} }
if let (Some(previous), Some(current)) = (&self.actor, &snapshot.actor)
&& previous.actor_ref == current.actor_ref
&& previous.temporary_behavior
&& !current.temporary_behavior
{
let _ =
self.requests
.send(WorkerRequest::Command(Command::ActorBehavior {
actor_ref: current.actor_ref.clone(),
}));
}
self.actors = snapshot.actors; self.actors = snapshot.actors;
self.sources = snapshot.sources; self.sources = snapshot.sources;
self.source_ids = snapshot.source_ids;
self.actor = snapshot.actor; self.actor = snapshot.actor;
self.execution = snapshot.execution; self.execution = snapshot.execution;
self.logs = snapshot.logs; self.logs = snapshot.logs;
@ -306,9 +371,10 @@ impl WorldApp {
ui.label("Image"); ui.label("Image");
ui.text_edit_singleline(&mut self.endpoint); ui.text_edit_singleline(&mut self.endpoint);
if ui.button("Connect").clicked() { if ui.button("Connect").clicked() {
if self.source_dirty { if self.source_dirty || self.actor_behavior_dirty {
self.notice = self.notice =
"Save or discard the current source draft before reconnecting".to_owned(); "Save or discard the current source and behavior drafts before reconnecting"
.to_owned();
} else { } else {
match self.endpoint.parse() { match self.endpoint.parse() {
Ok(address) => { Ok(address) => {
@ -479,11 +545,136 @@ impl WorldApp {
} }
}); });
self.draw_message_sender(ui, &actor.actor_ref); self.draw_message_sender(ui, &actor.actor_ref);
self.draw_actor_behavior_editor(ui, &actor);
self.draw_debugger(ui, &actor); self.draw_debugger(ui, &actor);
self.draw_logs(ui, &actor.actor_ref); self.draw_logs(ui, &actor.actor_ref);
}); });
} }
fn draw_actor_behavior_editor(&mut self, ui: &mut egui::Ui, actor: &ActorSnapshotDto) {
ui.collapsing("Actor behavior", |ui| {
ui.horizontal(|ui| {
ui.label(actor.behavior_path.as_deref().unwrap_or("No source path"));
if ui.button("Load active behavior").clicked() {
self.submit(Command::ActorBehavior {
actor_ref: actor.actor_ref.clone(),
});
}
if ui
.add_enabled(
!self.actor_behavior_dirty,
egui::Button::new("Reload from source"),
)
.clicked()
{
self.submit(Command::System {
actor_ref: actor.actor_ref.clone(),
message: SystemMessageDto::Reload,
});
}
});
if self.actor_behavior_dirty {
ui.colored_label(egui::Color32::YELLOW, "Unsaved temporary draft");
if ui.button("Discard draft").clicked()
&& let Some(source) = &self.actor_behavior
{
self.actor_behavior_draft.clone_from(&source.source);
self.actor_behavior_dirty = false;
}
}
ui.horizontal(|ui| {
if ui
.add_enabled(
self.actor_behavior.is_some() && self.actor_behavior_dirty,
egui::Button::new("Save as temporary behavior"),
)
.clicked()
&& let Some(source) = &self.actor_behavior
{
self.submit(Command::WriteActorBehavior {
actor_ref: actor.actor_ref.clone(),
expected_source_id: source.source_id.clone(),
source: self.actor_behavior_draft.clone(),
});
}
if ui
.add_enabled(
actor.temporary_behavior && !self.actor_behavior_dirty,
egui::Button::new("Update shared source"),
)
.clicked()
{
self.update_behavior_confirmation = true;
}
});
if self.actor_behavior.is_none() {
ui.label(
"Load the actor's current behavior to edit an actor-local temporary copy.",
);
return;
}
let mut layouter = |ui: &egui::Ui, source: &str, wrap_width: f32| {
let mut job = rune_layout_job(source);
job.wrap.max_width = wrap_width;
ui.fonts(|fonts| fonts.layout_job(job))
};
let response = ui.add(
egui::TextEdit::multiline(&mut self.actor_behavior_draft)
.code_editor()
.layouter(&mut layouter)
.desired_width(f32::INFINITY)
.desired_rows(12),
);
if response.changed() {
self.actor_behavior_dirty = self
.actor_behavior
.as_ref()
.is_some_and(|source| self.actor_behavior_draft != source.source);
}
});
if self.update_behavior_confirmation {
egui::Window::new("Confirm shared behavior update")
.collapsible(false)
.resizable(false)
.show(ui.ctx(), |ui| {
ui.label(format!(
"Overwrite `{}` in the image source tree with this actor's temporary behavior?",
self.actor_behavior
.as_ref()
.map_or("unknown source", |source| source.path.as_str())
));
ui.horizontal(|ui| {
if ui.button("Update source").clicked() {
let Some(temporary) = &self.actor_behavior else {
self.update_behavior_confirmation = false;
return;
};
let expected_source_id = self.source_ids.get(&temporary.path).cloned();
match expected_source_id {
Some(expected_source_id) => self.submit(
Command::UpdateBehaviorSource {
actor_ref: actor.actor_ref.clone(),
path: temporary.path.clone(),
expected_source_id,
temporary_source_id: temporary.source_id.clone(),
},
),
None => {
self.notice =
"Could not determine the current source identity; refresh and try again"
.to_owned()
}
}
self.update_behavior_confirmation = false;
}
if ui.button("Cancel").clicked() {
self.update_behavior_confirmation = false;
}
});
});
}
}
fn draw_message_sender(&mut self, ui: &mut egui::Ui, actor_ref: &str) { fn draw_message_sender(&mut self, ui: &mut egui::Ui, actor_ref: &str) {
ui.collapsing("Send message", |ui| { ui.collapsing("Send message", |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -864,6 +1055,7 @@ fn request_reply(client: &mut WebSocketClient, command: Command) -> Result<Reply
fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate>) { fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate>) {
let mut client = None; let mut client = None;
let mut selected_actor = None; let mut selected_actor = None;
let mut selected_behavior_path = None;
let mut log_sequence = 0; let mut log_sequence = 0;
let mut retained_logs = Vec::new(); let mut retained_logs = Vec::new();
let mut next_poll = Instant::now(); let mut next_poll = Instant::now();
@ -873,6 +1065,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
Ok(connected) => { Ok(connected) => {
client = Some(connected); client = Some(connected);
selected_actor = None; selected_actor = None;
selected_behavior_path = None;
log_sequence = 0; log_sequence = 0;
retained_logs.clear(); retained_logs.clear();
next_poll = Instant::now(); next_poll = Instant::now();
@ -887,6 +1080,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
}, },
Ok(WorkerRequest::Select(actor_ref)) => { Ok(WorkerRequest::Select(actor_ref)) => {
selected_actor = actor_ref; selected_actor = actor_ref;
selected_behavior_path = None;
log_sequence = 0; log_sequence = 0;
retained_logs.clear(); retained_logs.clear();
next_poll = Instant::now(); next_poll = Instant::now();
@ -907,6 +1101,9 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
let operation = match &command { let operation = match &command {
Command::ReadSource { .. } => "read_source", Command::ReadSource { .. } => "read_source",
Command::WriteSource { .. } => "write_source", Command::WriteSource { .. } => "write_source",
Command::ActorBehavior { .. } => "actor_behavior",
Command::WriteActorBehavior { .. } => "write_actor_behavior",
Command::UpdateBehaviorSource { .. } => "update_behavior_source",
Command::CheckSource { .. } => "check_source", Command::CheckSource { .. } => "check_source",
Command::GetEvents => "get_events", Command::GetEvents => "get_events",
Command::CommitSource { .. } => "commit_source", Command::CommitSource { .. } => "commit_source",
@ -916,7 +1113,8 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
_ => "command", _ => "command",
}; };
let submitted_source = match &command { let submitted_source = match &command {
Command::WriteSource { source, .. } => Some(source.clone()), Command::WriteSource { source, .. }
| Command::WriteActorBehavior { source, .. } => Some(source.clone()),
_ => None, _ => None,
}; };
let revision = match &command { let revision = match &command {
@ -924,13 +1122,19 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
| Command::RollbackSource { revision, .. } => Some(revision.clone()), | Command::RollbackSource { revision, .. } => Some(revision.clone()),
_ => None, _ => None,
}; };
let requested_actor_ref = match &command {
Command::ActorBehavior { actor_ref }
| Command::WriteActorBehavior { actor_ref, .. } => Some(actor_ref.clone()),
_ => None,
};
let source_path = match &command { let source_path = match &command {
Command::ReadSource { path } Command::ReadSource { path }
| Command::WriteSource { path, .. } | Command::WriteSource { path, .. }
| Command::CommitSource { path } | Command::CommitSource { path }
| Command::SourceHistory { path } | Command::SourceHistory { path }
| Command::RevisionSource { path, .. } | Command::RevisionSource { path, .. }
| Command::RollbackSource { path, .. } => Some(path.clone()), | Command::RollbackSource { path, .. }
| Command::UpdateBehaviorSource { path, .. } => Some(path.clone()),
_ => None, _ => None,
}; };
match request_reply(connected, command) { match request_reply(connected, command) {
@ -946,6 +1150,25 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
.expect("write operation includes its submitted source"), .expect("write operation includes its submitted source"),
}) })
} }
("actor_behavior", Reply::ActorBehavior { source_id, source }) => {
Some(WorkerUpdate::ActorBehavior {
actor_ref: requested_actor_ref.unwrap_or_default(),
path: selected_behavior_path.clone().unwrap_or_default(),
source_id,
source,
submitted_source: None,
})
}
(
"write_actor_behavior",
Reply::ActorBehavior { source_id, source },
) => Some(WorkerUpdate::ActorBehavior {
actor_ref: requested_actor_ref.unwrap_or_default(),
path: selected_behavior_path.clone().unwrap_or_default(),
source_id,
source,
submitted_source,
}),
("check_source", Reply::SourceChecked { diagnostics }) => { ("check_source", Reply::SourceChecked { diagnostics }) => {
Some(WorkerUpdate::SourceDiagnostics(diagnostics)) Some(WorkerUpdate::SourceDiagnostics(diagnostics))
} }
@ -979,6 +1202,12 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
submitted_source, submitted_source,
}) })
} }
("update_behavior_source", Reply::Source(source)) => {
Some(WorkerUpdate::SourceSaved {
source: source.clone(),
submitted_source: source.source,
})
}
(_, reply) => Some(WorkerUpdate::Notice(format!( (_, reply) => Some(WorkerUpdate::Notice(format!(
"Completed with unexpected response: {reply:?}" "Completed with unexpected response: {reply:?}"
))), ))),
@ -1027,9 +1256,18 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
continue; continue;
} }
}; };
let sources = match request_reply(connected, Command::ListSources) { let (sources, source_ids) = match request_reply(connected, Command::ListSources) {
Ok(Reply::Sources(sources)) => sources.into_iter().map(|source| source.path).collect(), Ok(Reply::Sources(sources)) => {
Ok(_) => Vec::new(), let source_ids = sources
.iter()
.map(|source| (source.path.clone(), source.source_id.clone()))
.collect();
(
sources.into_iter().map(|source| source.path).collect(),
source_ids,
)
}
Ok(_) => (Vec::new(), BTreeMap::new()),
Err(error) => { Err(error) => {
report_call_error(error, &updates, &mut client); report_call_error(error, &updates, &mut client);
continue; continue;
@ -1041,6 +1279,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
.find(|actor| &actor.actor_ref == selected) .find(|actor| &actor.actor_ref == selected)
.cloned() .cloned()
}); });
selected_behavior_path = actor.as_ref().and_then(|actor| actor.behavior_path.clone());
let (execution, logs) = if let Some(actor) = &actor { let (execution, logs) = if let Some(actor) = &actor {
let execution = match request_reply( let execution = match request_reply(
connected, connected,
@ -1088,6 +1327,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
let _ = updates.send(WorkerUpdate::Snapshot(Box::new(WorkerSnapshot { let _ = updates.send(WorkerUpdate::Snapshot(Box::new(WorkerSnapshot {
actors, actors,
sources, sources,
source_ids,
actor, actor,
execution, execution,
logs, logs,

View File

@ -224,11 +224,13 @@ loopback image over the shared WebSocket protocol. It currently provides an
actor browser and inspector with message, lifecycle, log, breakpoint, bounded actor browser and inspector with message, lifecycle, log, breakpoint, bounded
step, and execution-stack controls, plus a source editor with compilation step, and execution-stack controls, plus a source editor with compilation
diagnostics, explicit source commits, committed-revision previews, and diagnostics, explicit source commits, committed-revision previews, and
confirmed rollback. Image events can be collected manually; this operation confirmed rollback. The actor inspector can edit an actor-local temporary
drains the shared queue and therefore consumes events for other clients too. behavior and explicitly update its shared source or reload from that source.
Network requests run on a worker thread, not the UI frame loop. This is a Image events can be collected manually; this operation drains the shared queue
client foundation, not yet a Rune-composed graphical world; frame locals and and therefore consumes events for other clients too. Network requests run on a
expression evaluation remain unavailable. 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