diff --git a/CHANGELOG.md b/CHANGELOG.md index a67d4ec..46a4f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to this project will be documented in this file. ### Changed +- Expand VS Code actors into directories with read-only `meta.json`/`log`, + live-editable `config.json`, and guarded natural-JSON `state.json`/`mailbox.json`. +- Allow reload/become to repair failed actors' implementations without clearing + failure, state, or reserved messages; restart remains explicit. + - **Breaking:** Successful graceful stop and immediate kill now remove actors, discarding their state, mailbox, and retained logs. Failed actors remain until killed. Restart remains an independent, identity-preserving operation. @@ -46,6 +51,13 @@ All notable changes to this project will be documented in this file. ### Added +- Source file creation, rename/move with confirmed actor path updates and + history preservation, and deletion guarded against running actor users. +- Server-owned actor-specific temporary behaviors, safe-boundary compile-on-save, + and VS Code Edit Current Behavior, Go to Behavior, and confirmed Update Behavior + actions. Temporary scripts appear as an actor's `behavior` file and are cleared + by reload without implicitly committing shared source. + - Editable VS Code inspection JSON for inactive actors, restoring protected fields with a warning; actor-reference copy actions and a scheduler toolbar button. - Read-only `ctx.this` actor reference for transactional self-messaging. diff --git a/README.md b/README.md index 7255e0c..040b770 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,12 @@ open this repository and launch **Run Runa Extension** from Run and Debug (F5). Start `mise run start`, then use **Runa: Connect to Local Image** in the Extension Development Host. Open the **Runa** Activity Bar sidebar. Its **Actors** view lists -actors created through the REPL or another client. Click an actor to inspect -it. Server notifications automatically refresh the tree and open snapshots; +actors created through the REPL or another client as expandable directories. +Open `meta.json` for read-only identity/status/behavior-path metadata, +`config.json` for live log-level configuration, `state.json` for natural JSON +state, `mailbox.json` for reserved/queued messages, or `log` for retained logs. +State and mailbox files are writable only for inactive paused/failed actors. +Server notifications automatically refresh the tree and open files; **Runa: Refresh Image** is also available on demand. The **Image Sources** view in the Runa sidebar automatically shows the image's virtual @@ -189,6 +193,17 @@ workspace folder. Open a file and save edits to update the image without committing. Right-click a source file to **Spawn** (null state) or **Spawn with State** (which prompts for JSON), or to commit, browse history, and roll back. Historical revisions remain read-only. +The source toolbar and context menus support creating, renaming/moving, and +deleting files. Renaming an in-use behavior asks to update every actor using its +path; declining cancels the rename. Deleting a source used by a running actor +is rejected with a stop-first error. + +**Edit Current Behavior** on an actor opens its loaded script (or existing +temporary script). Saving compiles an actor-specific override and installs it +at the next handler boundary without changing shared source, state, or mailbox. +A `behavior` file then appears inside the actor directory. **Reload Actor Source** +clears the override, **Go to Behavior** opens the shared source, and **Update Behavior** +asks before copying the temporary text into shared working source without committing. Use **Runa: Spawn Actor** to choose a source path and supply JSON initial state. Actor context menus offer structured messages and system lifecycle commands. @@ -221,8 +236,10 @@ actors. `logs` prints retained history; `logs -f` additionally subscribes to future entries until Ctrl+C, which returns to the REPL or closes a one-shot CLI. In VS Code, actor context menus offer **Edit Actor State...**, **Edit Reserved and Queued Messages...**, and **Set Actor Log Level...**. -You can also edit those fields directly in inspection JSON and save. Protected -fields are restored with a warning. The Actors toolbar advances the scheduler, +The actor directory provides editable state/mailbox files and a `config.json` +that remains writable while running. The **Inspect Actor** command also retains +the combined JSON editor; protected fields there are restored with a warning. +The Actors toolbar advances the scheduler, and **Copy Actor Reference** copies a reference from the actor context menu. Rune handlers can call `ctx.log("info", "message")`, or `ctx.trace`, `ctx.debug`, diff --git a/crates/runa-code/src/lib.rs b/crates/runa-code/src/lib.rs index 96af56d..229dbfa 100644 --- a/crates/runa-code/src/lib.rs +++ b/crates/runa-code/src/lib.rs @@ -210,6 +210,48 @@ impl CodeStore { self.working_sources.get(path) } + pub fn rename_source( + &mut self, + path: &str, + target: &str, + expected: SourceId, + ) -> ExperimentResult { + validate_source_path(target)?; + let file = self + .working_sources + .get(path) + .ok_or("unknown source path")?; + if file.source_id != expected { + return Err("source identity conflict: source changed".into()); + } + if self.working_sources.contains_key(target) { + return Err("source identity conflict: target exists".into()); + } + let mut file = self.working_sources.remove(path).expect("checked source"); + file.path = target.to_owned(); + self.working_sources.insert(target.to_owned(), file.clone()); + if let Some(mut history) = self.source_commits.remove(path) { + for commit in &mut history { + commit.path = target.to_owned(); + } + self.source_commits.insert(target.to_owned(), history); + } + Ok(file) + } + + pub fn delete_source(&mut self, path: &str, expected: SourceId) -> ExperimentResult<()> { + let file = self + .working_sources + .get(path) + .ok_or("unknown source path")?; + if file.source_id != expected { + return Err("source identity conflict: source changed".into()); + } + self.working_sources.remove(path); + self.source_commits.remove(path); + Ok(()) + } + /// Capture the current working source as an immutable commit without /// compiling it. pub fn commit_source(&mut self, path: &str) -> ExperimentResult { diff --git a/crates/runa-image-api/src/lib.rs b/crates/runa-image-api/src/lib.rs index 42c5d8d..db3c7f3 100644 --- a/crates/runa-image-api/src/lib.rs +++ b/crates/runa-image-api/src/lib.rs @@ -14,6 +14,7 @@ pub struct ImageApi { runtime: Runtime, actor_paths: BTreeMap, pending_paths: BTreeMap, + temporary: BTreeMap, events: Vec, } @@ -30,6 +31,7 @@ impl ImageApi { runtime: Runtime::default(), actor_paths: BTreeMap::new(), pending_paths: BTreeMap::new(), + temporary: BTreeMap::new(), events: Vec::new(), } } @@ -120,6 +122,145 @@ impl ImageApi { self.code.rollback_source(path, revision) } + pub fn source_users(&self, path: &str) -> Vec { + self.runtime + .actors() + .into_iter() + .filter(|actor| { + self.actor_paths + .get(&actor.actor_ref) + .is_some_and(|p| p == path) + || self + .pending_paths + .get(&actor.actor_ref) + .is_some_and(|(p, _)| p == path) + }) + .map(|actor| actor.actor_ref) + .collect() + } + + pub fn rename_source( + &mut self, + path: &str, + target: &str, + expected: runa_core::SourceId, + update_actors: bool, + ) -> RuntimeResult { + if !update_actors && !self.source_users(path).is_empty() { + return Err("behavior is in use; confirm updating actors before renaming".into()); + } + let file = self.code.rename_source(path, target, expected)?; + for value in self.actor_paths.values_mut() { + if value == path { + *value = target.to_owned(); + } + } + for (value, _) in self.pending_paths.values_mut() { + if value == path { + *value = target.to_owned(); + } + } + for file in self.temporary.values_mut() { + if file.path == path { + file.path = target.to_owned(); + } + } + Ok(file) + } + + pub fn delete_source( + &mut self, + path: &str, + expected: runa_core::SourceId, + ) -> RuntimeResult<()> { + if self.source_users(path).iter().any(|id| { + self.runtime + .inspect(*id) + .is_ok_and(|actor| !matches!(actor.status, runa_actor::Status::Failed(_))) + }) { + return Err("behavior is used by running actors; stop the actors first".into()); + } + self.code.delete_source(path, expected) + } + + pub fn actor_path(&self, actor: ActorRef) -> Option<&str> { + self.actor_paths.get(&actor).map(String::as_str) + } + pub fn pending_actor_path(&self, actor: ActorRef) -> Option<&str> { + self.pending_paths + .get(&actor) + .map(|(path, _)| path.as_str()) + } + + pub fn has_temporary_behavior(&self, actor: ActorRef) -> bool { + self.temporary.contains_key(&actor) + } + + pub fn actor_behavior(&self, actor: ActorRef) -> RuntimeResult { + let snapshot = self.runtime.inspect(actor)?; + if let Some(file) = self.temporary.get(&actor) { + return Ok(file.clone()); + } + let artifact = self + .code + .artifact(snapshot.implementation) + .ok_or("behavior artifact is unavailable")?; + Ok(SourceFile { + path: self + .actor_path(actor) + .ok_or("actor has no source path")? + .to_owned(), + source_id: artifact.source_id, + source: artifact.original_source().to_owned(), + }) + } + + pub fn write_actor_behavior( + &mut self, + actor: ActorRef, + expected: String, + source: &str, + ) -> RuntimeResult { + let previous = self.actor_behavior(actor)?; + if previous.source_id.to_string() != expected { + return Err("source identity conflict: actor behavior changed".into()); + } + let artifact = self.code.compile(source)?; + self.runtime.publish(Arc::clone(&artifact))?; + self.runtime + .set_actor_behavior(actor, artifact.implementation_id)?; + self.pending_paths.remove(&actor); + let file = SourceFile { + path: previous.path, + source_id: artifact.source_id, + source: source.to_owned(), + }; + self.temporary.insert(actor, file.clone()); + self.collect_runtime_events(); + Ok(file) + } + + pub fn update_behavior_source( + &mut self, + actor: ActorRef, + expected: runa_core::SourceId, + temporary_id: &str, + ) -> RuntimeResult { + self.runtime.inspect(actor)?; + let file = self + .temporary + .get(&actor) + .ok_or("actor has no temporary behavior")?; + if file.source_id.to_string() != temporary_id { + return Err("source identity conflict: temporary behavior changed".into()); + } + let path = self + .actor_paths + .get(&actor) + .ok_or("actor has no source path")?; + self.code.write_source(path, Some(expected), &file.source) + } + pub fn spawn( &mut self, path: &str, @@ -147,7 +288,9 @@ impl ImageApi { .get(&actor) .cloned() .ok_or("actor has no source path")?; - self.schedule_source_install(actor, &path) + let result = self.schedule_source_install(actor, &path)?; + self.temporary.remove(&actor); + Ok(result) } pub fn r#become(&mut self, actor: ActorRef, path: &str) -> RuntimeResult { @@ -168,9 +311,11 @@ impl ImageApi { let artifact = self.code.compile(&source)?; self.runtime.publish(Arc::clone(&artifact))?; self.runtime - .schedule_install(actor, artifact.implementation_id)?; + .set_actor_behavior(actor, artifact.implementation_id)?; self.pending_paths .insert(actor, (path.to_owned(), artifact.implementation_id)); + self.temporary.remove(&actor); + self.collect_runtime_events(); Ok(artifact.implementation_id) } @@ -232,6 +377,7 @@ impl ImageApi { if let Event::Stopped(actor) = &event { self.actor_paths.remove(actor); self.pending_paths.remove(actor); + self.temporary.remove(actor); } if let Event::BehaviorInstalled(actor, implementation) = &event && self @@ -255,6 +401,13 @@ impl ImageApi { if matches!(message, SystemMessage::Kill | SystemMessage::Restart) { self.pending_paths.remove(&actor); } + if message == SystemMessage::Restart + && let Some(file) = self.temporary.get(&actor) + { + let artifact = self.code.compile(&file.source)?; + self.runtime + .set_actor_behavior(actor, artifact.implementation_id)?; + } self.collect_runtime_events(); Ok(()) } @@ -414,6 +567,196 @@ mod tests { Ok(()) } + #[test] + fn source_rename_moves_history_and_guards_installed_and_pending_users() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + let first = image.write_source( + "actors/first", + None, + "pub fn handle(ctx, state, sender, message) { state + 1 }", + )?; + let second = image.write_source( + "actors/second", + None, + "pub fn handle(ctx, state, sender, message) { state + 2 }", + )?; + let commit = image.commit_source("actors/second")?; + let actor = image.spawn("actors/first", Value::Integer(0), capacity(2))?; + image.r#become(actor, "actors/second")?; + assert!( + image + .delete_source("actors/first", first.source_id) + .unwrap_err() + .to_string() + .contains("stop the actors first") + ); + assert!( + image + .delete_source("actors/second", second.source_id) + .is_err() + ); + assert!( + image + .rename_source("actors/second", "nested/renamed", second.source_id, false) + .is_err() + ); + assert!( + image + .rename_source("actors/second", "actors/first", second.source_id, true) + .is_err() + ); + assert!( + image + .rename_source("actors/second", "nested/renamed", first.source_id, true) + .is_err() + ); + assert_eq!(image.pending_actor_path(actor), Some("actors/second")); + image.rename_source("actors/second", "nested/renamed", second.source_id, true)?; + assert_eq!(image.pending_actor_path(actor), Some("nested/renamed")); + assert!(image.source("actors/second").is_none()); + assert_eq!( + image + .source_commit("nested/renamed", &commit.revision) + .unwrap() + .path, + "nested/renamed" + ); + image.tick(1, capacity(100)); + assert_eq!(image.actor_path(actor), Some("nested/renamed")); + image.rename_source("nested/renamed", "renamed", second.source_id, true)?; + assert_eq!(image.actor_path(actor), Some("renamed")); + image.system(actor, SystemMessage::Kill)?; + image.delete_source("renamed", second.source_id)?; + assert!(image.source_history("renamed").is_none()); + Ok(()) + } + + #[test] + fn temporary_behavior_is_actor_local_cas_guarded_and_reloadable() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + let first = image.write_source( + "actors/counter", + None, + "pub fn handle(ctx, state, sender, message) { state + 1 }", + )?; + let actor = image.spawn("actors/counter", Value::Integer(0), capacity(2))?; + let other = image.spawn("actors/counter", Value::Integer(0), capacity(2))?; + let working = image.write_source( + "actors/counter", + Some(first.source_id), + "pub fn handle(ctx, state, sender, message) { state + 10 }", + )?; + assert_eq!(image.actor_behavior(actor)?.source, first.source); + assert!( + image + .write_actor_behavior(actor, first.source_id.to_string(), "pub fn handle( {") + .is_err() + ); + assert!(!image.has_temporary_behavior(actor)); + let temporary = image.write_actor_behavior( + actor, + first.source_id.to_string(), + "pub fn handle(ctx, state, sender, message) { state + 100 }", + )?; + assert!(image.has_temporary_behavior(actor)); + assert!(!image.has_temporary_behavior(other)); + assert_eq!( + image.source("actors/counter").unwrap().source, + working.source + ); + assert!( + image + .write_actor_behavior(actor, first.source_id.to_string(), &first.source) + .is_err() + ); + assert!( + image + .update_behavior_source(actor, first.source_id, &temporary.source_id.to_string()) + .is_err() + ); + assert!( + image + .update_behavior_source(actor, working.source_id, &first.source_id.to_string()) + .is_err() + ); + for id in [actor, other] { + image.send(id, Value::Null)?; + } + image.tick(10, capacity(100)); + assert_eq!(image.inspect(actor)?.state, Value::Integer(100)); + assert_eq!(image.inspect(other)?.state, Value::Integer(1)); + let updated = image.update_behavior_source( + actor, + working.source_id, + &temporary.source_id.to_string(), + )?; + assert_eq!(updated.source, temporary.source); + assert!(image.source_history("actors/counter").unwrap().is_empty()); + image.write_source("actors/counter", Some(updated.source_id), &working.source)?; + image.reload(actor)?; + assert!(!image.has_temporary_behavior(actor)); + image.send(actor, Value::Null)?; + image.tick(10, capacity(100)); + assert_eq!(image.inspect(actor)?.state, Value::Integer(110)); + assert_eq!(image.actor_behavior(actor)?.source, working.source); + Ok(()) + } + + #[test] + fn temporary_install_waits_for_active_handler_and_survives_restart() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + let file = image.write_source("actors/slow", None, "pub fn handle(ctx, state, sender, message) { let n = 0; while n < 100 { n += 1; } state + 1 }")?; + let actor = image.spawn("actors/slow", Value::Integer(0), capacity(2))?; + image.send(actor, Value::Null)?; + image.tick(1, capacity(1)); + let active = image.inspect(actor)?; + assert!(active.execution.is_some()); + image.write_actor_behavior( + actor, + file.source_id.to_string(), + "pub fn handle(ctx, state, sender, message) { state + 10 }", + )?; + assert_eq!(image.inspect(actor)?.implementation, active.implementation); + image.tick(10, capacity(1000)); + assert_eq!(image.inspect(actor)?.state, Value::Integer(1)); + image.send(actor, Value::Null)?; + image.restart(actor)?; + image.tick(10, capacity(1000)); + assert_eq!(image.inspect(actor)?.state, Value::Integer(11)); + image.system(actor, SystemMessage::Kill)?; + assert!(!image.has_temporary_behavior(actor)); + Ok(()) + } + + #[test] + fn failed_actors_can_repair_behavior_without_losing_reserved_messages() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + let file = image.write_source( + "actors/broken", + None, + "pub fn handle(ctx, state, sender, message) { panic(\"broken\") }", + )?; + let actor = image.spawn("actors/broken", Value::Integer(0), capacity(2))?; + image.send(actor, Value::Integer(7))?; + image.tick(10, capacity(100)); + let before = image.inspect(actor)?; + assert!(matches!(before.status, runa_actor::Status::Failed(_))); + image.write_actor_behavior( + actor, + file.source_id.to_string(), + "pub fn handle(ctx, state, sender, message) { state + message }", + )?; + assert!(matches!( + image.inspect(actor)?.status, + runa_actor::Status::Failed(_) + )); + assert_eq!(image.inspect(actor)?.reserved, before.reserved); + image.restart(actor)?; + image.tick(10, capacity(100)); + assert_eq!(image.inspect(actor)?.state, Value::Integer(7)); + Ok(()) + } + #[test] fn invalid_uncommitted_source_fails_spawn_without_creating_actor() -> RuntimeResult<()> { let mut image = ImageApi::new(); diff --git a/crates/runa-protocol/src/lib.rs b/crates/runa-protocol/src/lib.rs index 90564ab..d99f6e4 100644 --- a/crates/runa-protocol/src/lib.rs +++ b/crates/runa-protocol/src/lib.rs @@ -46,6 +46,19 @@ impl Request { } match &self.command { + Command::RenameSource { path, target, .. } => { + validate_name(path)?; + validate_name(target)?; + } + Command::DeleteSource { path, .. } => validate_name(path)?, + Command::ActorBehavior { actor_ref } + | Command::WriteActorBehavior { actor_ref, .. } => validate_actor_ref(actor_ref)?, + Command::UpdateBehaviorSource { + actor_ref, path, .. + } => { + validate_actor_ref(actor_ref)?; + validate_name(path)?; + } Command::EditActor { actor_ref, state, @@ -158,6 +171,30 @@ impl Request { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "operation", content = "parameters", rename_all = "snake_case")] pub enum Command { + RenameSource { + path: String, + target: String, + expected_source_id: String, + update_actors: bool, + }, + DeleteSource { + path: String, + expected_source_id: String, + }, + ActorBehavior { + actor_ref: String, + }, + WriteActorBehavior { + actor_ref: String, + expected_source_id: String, + source: String, + }, + UpdateBehaviorSource { + actor_ref: String, + path: String, + expected_source_id: String, + temporary_source_id: String, + }, EditActor { actor_ref: String, state: Option, @@ -281,6 +318,10 @@ pub struct Response { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", content = "data", rename_all = "snake_case")] pub enum Reply { + ActorBehavior { + source_id: String, + source: String, + }, Logs { entries: Vec, next_sequence: u64, @@ -404,6 +445,12 @@ pub type MessageDto = Value; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ActorSnapshotDto { + #[serde(default)] + pub pending_behavior_path: Option, + #[serde(default)] + pub behavior_path: Option, + #[serde(default)] + pub temporary_behavior: bool, pub log_level: LogLevel, pub actor_ref: String, pub mailbox_id: u64, diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index 71c672f..5a46ab6 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -310,6 +310,30 @@ impl Runtime { Ok(()) } + /// Schedule a boundary install, or replace a failed actor's inactive implementation. + pub fn set_actor_behavior( + &mut self, + id: ActorRef, + implementation: ImplementationId, + ) -> ExperimentResult<()> { + if !self.implementations.contains_key(&implementation) { + return Err("unknown implementation".into()); + } + let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; + if actor.stopping { + return Err("actor is stopping".into()); + } + if !matches!(actor.snapshot.status, Status::Failed(_)) { + actor.pending_install = Some(implementation); + } else { + actor.pending_install = None; + actor.snapshot.implementation = implementation; + self.events + .push(Event::BehaviorInstalled(id, implementation)); + } + Ok(()) + } + /// Pause scheduling between worker ticks without discarding an invocation. pub fn pause(&mut self, id: ActorRef) -> ExperimentResult<()> { let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; diff --git a/crates/runa/src/lib.rs b/crates/runa/src/lib.rs index db75d37..a40abac 100644 --- a/crates/runa/src/lib.rs +++ b/crates/runa/src/lib.rs @@ -65,6 +65,73 @@ impl Server { fn dispatch_command(&mut self, command: Command) -> Result { match command { + Command::RenameSource { + path, + target, + expected_source_id, + update_actors, + } => { + let expected = self.expected_source(&path, &expected_source_id)?.source_id; + self.image + .rename_source(&path, &target, expected, update_actors) + .map(source_dto) + .map(Reply::Source) + .map_err(api_error) + } + Command::DeleteSource { + path, + expected_source_id, + } => { + let expected = self.expected_source(&path, &expected_source_id)?.source_id; + self.image + .delete_source(&path, expected) + .map(|()| Reply::Acknowledged) + .map_err(api_error) + } + Command::ActorBehavior { actor_ref } => self + .image + .actor_behavior(parse_actor_ref(&actor_ref)?) + .map(|file| Reply::ActorBehavior { + source_id: file.source_id.to_string(), + source: file.source, + }) + .map_err(api_error), + Command::WriteActorBehavior { + actor_ref, + expected_source_id, + source, + } => self + .image + .write_actor_behavior(parse_actor_ref(&actor_ref)?, expected_source_id, &source) + .map(|file| Reply::ActorBehavior { + source_id: file.source_id.to_string(), + source: file.source, + }) + .map_err(api_error), + Command::UpdateBehaviorSource { + actor_ref, + path, + expected_source_id, + temporary_source_id, + } => { + let actor = parse_actor_ref(&actor_ref)?; + let current_path = self + .image + .actor_path(actor) + .ok_or_else(|| api_error("actor has no source path"))? + .to_owned(); + if current_path != path { + return Err(api_error( + "source identity conflict: actor source path changed", + )); + } + let expected = self.expected_source(&path, &expected_source_id)?.source_id; + self.image + .update_behavior_source(actor, expected, &temporary_source_id) + .map(source_dto) + .map(Reply::Source) + .map_err(api_error) + } Command::EditActor { actor_ref, state, @@ -247,12 +314,16 @@ impl Server { let actor_ref = parse_actor_ref(&actor_ref)?; self.image .inspect(actor_ref) - .map(|snapshot| actor_snapshot_dto(&snapshot)) + .map(|snapshot| actor_snapshot_dto(&snapshot, &self.image)) .map(Reply::Actor) .map_err(api_error) } Command::ListActors => Ok(Reply::Actors( - self.image.actors().iter().map(actor_snapshot_dto).collect(), + self.image + .actors() + .iter() + .map(|snapshot| actor_snapshot_dto(snapshot, &self.image)) + .collect(), )), Command::GetEvents => Ok(Reply::Events( self.image.events().iter().map(event_dto).collect(), @@ -308,6 +379,23 @@ impl Server { } } +impl Server { + fn expected_source( + &self, + path: &str, + expected: &str, + ) -> Result { + let file = self + .image + .source(path) + .ok_or_else(|| api_error("unknown source path"))?; + if file.source_id.to_string() != expected { + return Err(api_error("source identity conflict: source changed")); + } + Ok(file.clone()) + } +} + fn source_dto(file: runa_code::SourceFile) -> SourceFileDto { SourceFileDto { path: file.path, @@ -339,8 +427,13 @@ fn behavior_dto(behavior: runa_code::BehaviorSnapshot) -> BehaviorDto { } } -fn actor_snapshot_dto(snapshot: &runa_actor::ActorSnapshot) -> ActorSnapshotDto { +fn actor_snapshot_dto(snapshot: &runa_actor::ActorSnapshot, image: &ImageApi) -> ActorSnapshotDto { ActorSnapshotDto { + behavior_path: image.actor_path(snapshot.actor_ref).map(str::to_owned), + temporary_behavior: image.has_temporary_behavior(snapshot.actor_ref), + pending_behavior_path: image + .pending_actor_path(snapshot.actor_ref) + .map(str::to_owned), log_level: snapshot.log_level, actor_ref: snapshot.actor_ref.as_str().to_owned(), mailbox_id: snapshot.mailbox.0, diff --git a/crates/runa/src/network.rs b/crates/runa/src/network.rs index bdaa28b..be3159d 100644 --- a/crates/runa/src/network.rs +++ b/crates/runa/src/network.rs @@ -76,7 +76,7 @@ async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> { Command::GetBehaviorSource { .. } | Command::InspectActor { .. } | Command::ListActors | Command::GetEvents | Command::ListSources | Command::ReadSource { .. } | Command::SourceHistory { .. } | - Command::RevisionSource { .. } | Command::Logs { .. }); + Command::RevisionSource { .. } | Command::ActorBehavior { .. } | Command::Logs { .. }); let response = server.dispatch(dispatch.request); let changed = mutates && response.result.is_ok(); // Dispatch may commit even if the initiating client has disconnected. diff --git a/docs/architecture.md b/docs/architecture.md index ef7e35a..1673ee5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -519,10 +519,16 @@ variables. Mise's `start` and `cli` tasks run the server and REPL respectively. - Connection, actor explorer, state/mailbox inspection, spawn/send controls. - Virtual behavior documents, compilation diagnostics, hot upgrades, runtime events. -Connection management, the actor explorer, and read-only snapshot inspection +Connection management, the actor explorer, and snapshot inspection are implemented in `vscode/`, along with spawn/send, lifecycle controls, and explicit scheduler ticks, editable image source files, commit history, and immutable revision documents. Reload/become install scripts at safe boundaries. +Actors expand into virtual metadata/config/state/mailbox/log files; inactive +state/mailbox edits and live log configuration are implemented. Sources support +creation, guarded deletion, and confirmed rename/move with actor path updates. +Actor-specific temporary behavior documents compile on save without changing +shared source; reload clears them and a confirmed action can copy them back to +working source. These behaviors remain in-memory, not durable. Structured compilation diagnostics and detailed runtime event subscriptions remain planned. Non-destructive image-change notifications and automatic snapshot refresh are implemented over the shared `runa-debug` WebSocket transport. diff --git a/docs/protocol.md b/docs/protocol.md index 8719f58..8117308 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -9,7 +9,7 @@ Clap parses `--listen` / `RUNA_LISTEN` on the server and `--connect` / The listener is unauthenticated and intended only for trusted local development. Both the REPL and VS Code use WebSockets. -## Version 4 +## Version 5 Every request and response carries `version: 5`. Requests also carry a non-empty `request_id` used to correlate the response. A server must reject @@ -54,6 +54,11 @@ behavior aliases. They exclude the `source/` root and `.rn` extension. | `list_sources` | none | `sources`: file array | | `read_source` | `path` | `source`: file | | `write_source` | `path`, `expected_source_id` (null to create), `source` | `source`: file | +| `rename_source` | `path`, `target`, `expected_source_id`, `update_actors` | `source`: renamed file | +| `delete_source` | `path`, `expected_source_id` | `acknowledged` | +| `actor_behavior` | `actor_ref` | `actor_behavior`: `{source_id, source}` | +| `write_actor_behavior` | `actor_ref`, `expected_source_id`, `source` | `actor_behavior`: `{source_id, source}` | +| `update_behavior_source` | `actor_ref`, `path`, `expected_source_id`, `temporary_source_id` | `source`: updated original file | | `commit_source` | `path` | `source_committed`: commit | | `source_history` | `path` | `source_history`: commit array | | `revision_source` | `path`, `revision` | `behavior_source`: `{source}` | @@ -67,6 +72,27 @@ capture working text without compilation. Rollback restores working source without appending history. Spawn compiles current working source even when uncommitted; compilation failures do not spawn an actor. +Rename preserves history and atomically updates installed/pending actor path +bindings when `update_actors` is true. In-use paths require that authorization; +existing destination paths are never overwritten. Delete discards working text +and history and rejects installed/pending users unless all are failed, with an +explicit stop-first error. Compiled artifacts are retained. + +`actor_behavior` reads the existing temporary text, or the actor's loaded +artifact rather than its current working file. Writing compiles first and +schedules an actor-local safe-boundary install, retaining state/mailbox and +leaving shared source/history untouched. A failed actor's implementation may be +replaced without restarting or acknowledging its failed input. Reload/become +clear temporary text after successful scheduling; restart retains it; removal +cleans it up. `update_behavior_source` copies temporary text into shared working +source without committing. Its original path and both source identities are +checked before writing. Stale source identities/path bindings return `conflict`. + +Actor snapshots add `behavior_path` (nullable installed source path), +`pending_behavior_path` (nullable scheduled source path), and +`temporary_behavior` (boolean). These are additive v5 metadata fields; older +v5 snapshots may omit them and clients default to null/false. + System message shape is `{kind: "stop"|"kill"|"restart"|"pause"|"resume"|"reload"}` or `{kind: "become", path: "actors/other"}`. This is a trusted host request; actor `ctx.send` and `ctx.send_from` accept only ordinary data. Stop runs optional diff --git a/docs/runtime.md b/docs/runtime.md index 4359940..9e6156b 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -163,6 +163,24 @@ next handler boundary, keeping active frames immutable. Reload compiles the latest working source at the actor's current path; become chooses another path. Compilation failure preserves its current implementation and source binding. +The image API also owns actor-specific temporary source. Reading current +behavior returns that temporary text when present, otherwise the immutable +loaded artifact (not newer working source). Saving compiles and schedules a +safe-boundary install, preserving state and reserved/queued messages. Failed +actors can replace their implementation but remain failed until an explicit +restart. Temporary text survives restart, is cleared on successful reload/become +scheduling, and is removed with the actor. Copying it back to shared working +source requires matching original and temporary source identities; it neither +commits nor reloads other actors. + +Source rename preserves commit history and, with explicit authorization, +atomically rewrites every installed and pending actor path binding without +changing loaded implementations. Delete rejects any nonfailed actor using +either binding, including paused actors, and tells the caller to stop actors +first. Failed actors may keep loaded artifacts after deleting their source; +reload requires recreating that path. Source mutations and temporary text are +in-memory only. + Runtime callers that need a state transformation can use `install_with_migration`, which receives the committed state and computes its replacement before changing either state or implementation. The operation diff --git a/vscode/README.md b/vscode/README.md index 7886f4e..8a2f171 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -31,7 +31,8 @@ In that host: `actors/counter`). **Runa: Spawn Actor** in **Actors** prompts for a source path and state. The command reports and returns the new actor reference. Mailbox capacity is 64. -3. Click an actor or run **Runa: Inspect Actor** to open its JSON snapshot. +3. Expand an actor directory to open its metadata, configuration, state, + mailbox, or log files. **Runa: Inspect Actor** also opens a combined JSON snapshot. 4. Right-click an actor to send a JSON message or a system message: pause, resume, stop, kill, restart, reload, or become another source path. These actions are also available in the Command Palette with an actor picker. @@ -60,8 +61,23 @@ machine running the extension host, not necessarily the desktop. ## Snapshot semantics -- The tree shows actor references, lifecycle status, and queued/reserved counts. +- The tree shows actor directories with references, lifecycle status, and queued/reserved counts. Tooltips include implementation ID, incarnation, and failure text. +- Actor directories contain: + - `meta.json`: read-only identity, lifecycle, execution telemetry, source path + (`behavior_path`), pending source path, and temporary-behavior flag. + - `config.json`: `{"log_level":"info"}`, editable even while running. + - `state.json`: natural JSON state, using `$actor`/`$record` markers as needed. + - `mailbox.json`: `{"reserved":[],"queued":[]}`, with at most one reserved + message. State and mailbox are writable only when paused or failed with no + active invocation. Saving never resumes the actor. + - `log`: read-only retained entries, automatically refreshed on image changes. + Entries show their global sequence, level, and message. Only the server's + latest 1,000 accepted entries per actor are available. + - `behavior`: present after an actor-specific temporary behavior is saved. + These files use connection-scoped `runa-actor:` URIs. Removed/disconnected + actors' open JSON files retain their last values and become read-only; already + opened log/behavior files retain cached text. - Inspect fetches a fresh snapshot through `inspect_actor`. Documents show committed state, FIFO queued messages, the reserved message, status, and active-execution telemetry. Tagged values distinguish actor references from @@ -125,7 +141,27 @@ execution. Message input uses `{"reserved":[],"queued":[]}` with zero or one reserved message. Clearing the reservation removes the failed input that would otherwise retry on restart. Edits do not resume actors. **Set Actor Log Level...** selects trace/debug/info/warn/error/fatal (default info); snapshots show the level. -Use CLI `logs [-f] [actor...]` for retained/live log output. +The actor's `log` file shows retained output; CLI `logs [-f] [actor...]` also +supports a live event subscription. + +### Actor-specific behaviors + +**Edit Current Behavior** opens the actor's loaded script, not the potentially +newer working source. Saving compiles and schedules a temporary actor-specific +implementation for the next safe handler boundary; it never mutates active +frames, state, mailbox, shared source, or commit history. Compilation errors +reject the save and keep the previous behavior. Repeating the action reuses the +same document and temporary text. Other clients can read/edit the same temporary +behavior through the server; stale saves fail with a source-identity conflict. +Temporary behaviors survive actor restart, but not actor removal or server exit. +For failed actors, the new implementation is installed without clearing failure +or the reserved message; restart explicitly to retry. + +**Reload Actor Source** clears the override and schedules current shared source. +**Go to Behavior** opens the original image source. **Update Behavior...** asks +before copying the temporary script into shared working source, checking both +source identities and the actor's source path for concurrent changes. It does +not commit or reload other actors. Unsaved temporary editor text is not copied. The dedicated **Runa** Activity Bar sidebar contains **Image Sources** first, followed by **Actors**. Both views are visible by default and do not require @@ -146,8 +182,17 @@ paths are canonical and exclude the `source/` prefix and `.rn` suffix. - **Save** writes the image's working source with compare-and-swap against the last observed `source_id`. If another client changed the file, the save fails instead of overwriting; reopen or revert to load the image's text. Saving - never commits. New `.rn` files are created in the image on first save. - Deleting and renaming are not supported. + never commits. **New Source File...** in the toolbar or a directory context + menu creates a file at a relative path (including nested directories). +- **Rename or Move Source...** changes a file's path and moves its commit history. + If installed or pending actor behaviors use it, a warning asks to update their + paths atomically. Declining cancels the operation. Loaded code does not change; + future reloads follow the new path. Existing destinations are not overwritten. +- **Delete Source** confirms deletion of a file and its history. The server + rejects deletion if any nonfailed actor still uses its installed or pending + path, including paused actors: stop them first. Failed actors may retain loaded + code after deletion, but reload/go-to-source then fail until the path is recreated. + Directory rename/delete and cross-image moves are not supported. - **Spawn** uses the latest saved working source, committed or not. If the editor has unsaved changes, spawn and commit ask to save first; cancelling does nothing. Unsaved editor text is never sent implicitly. diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 925e476..361ff8f 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import * as vscode from "vscode"; import { control, spawnCounter, startImage } from "./server"; import { ImageSourceTree, RevisionDocuments } from "../src/sourceProvider"; +import { actorFileUri } from "../src/actorFiles"; +import type { activate } from "../src/extension"; export async function run(): Promise { const image = await startImage(); @@ -36,6 +38,7 @@ export async function run(): Promise { folders, ); await editSources(image.address); + await editActorFiles(image.address); const spawned = await vscode.commands.executeCommand("runa.spawn", { path: "actors/counter", state: "5", @@ -444,6 +447,209 @@ async function replaceText(document: vscode.TextDocument, text: string): Promise assert.ok(await vscode.workspace.applyEdit(edit)); } +async function editActorFiles(address: string): Promise { + const extension = vscode.extensions.all.find( + (candidate) => candidate.packageJSON.name === "runa", + ); + assert.ok(extension); + const { actorTree, sourceTree } = extension.exports as ReturnType; + const inputWindow = vscode.window as { showInputBox: (...args: unknown[]) => unknown }; + const input = inputWindow.showInputBox; + const [root] = sourceTree.getChildren(); + assert.ok(root); + inputWindow.showInputBox = async () => "tests/files.rn"; + try { + await vscode.commands.executeCommand("runa.newSource", root, [root]); + } finally { + inputWindow.showInputBox = input; + } + const sourceDocument = vscode.window.activeTextEditor?.document; + assert.ok(sourceDocument); + const original = + 'pub fn handle(ctx, state, sender, message) { ctx.info("original"); state + message }\n'; + await replaceText(sourceDocument, original); + assert.ok(await sourceDocument.save()); + const ref = await vscode.commands.executeCommand("runa.spawn", { + path: "tests/files", + state: "1", + }); + assert.ok(ref); + const selected = { actor_ref: ref }; + await vscode.commands.executeCommand("runa.refresh"); + const parent = actorTree.getChildren().find((node) => node.actor_ref === ref); + assert.ok(parent); + assert.equal( + actorTree.getTreeItem(parent).collapsibleState, + vscode.TreeItemCollapsibleState.Collapsed, + ); + assert.deepEqual( + actorTree.getChildren(parent).map((node) => ("file" in node ? node.file : "")), + ["meta.json", "config.json", "state.json", "mailbox.json", "log"], + ); + const session = actorTree.session; + const uri = (name: string) => actorFileUri(session, ref, name); + const meta = await vscode.workspace.openTextDocument(uri("meta.json")); + await vscode.window.showTextDocument(meta, { preview: false }); + assert.equal(JSON.parse(meta.getText()).behavior_path, "tests/files"); + assert.equal(JSON.parse(meta.getText()).state, undefined); + assert.equal( + (await vscode.workspace.fs.stat(meta.uri)).permissions, + vscode.FilePermission.Readonly, + ); + assert.equal( + (await vscode.workspace.fs.stat(uri("state.json"))).permissions, + vscode.FilePermission.Readonly, + ); + await assert.rejects( + async () => vscode.workspace.fs.writeFile(meta.uri, Buffer.from("{}")), + /read-only/, + ); + const config = await vscode.workspace.openTextDocument(uri("config.json")); + await vscode.window.showTextDocument(config); + await replaceText(config, '{"log_level":"trace"}'); + assert.ok(await config.save(), "configuration is writable while running"); + await vscode.commands.executeCommand("runa.pause", selected); + await vscode.commands.executeCommand("runa.refresh"); + assert.equal((await vscode.workspace.fs.stat(uri("state.json"))).permissions, undefined); + const state = await vscode.workspace.openTextDocument(uri("state.json")); + assert.equal(state.getText().trim(), "1"); + await vscode.window.showTextDocument(state, { preview: false }); + await replaceText(state, "40"); + assert.ok(await state.save()); + const mailbox = await vscode.workspace.openTextDocument(uri("mailbox.json")); + await vscode.window.showTextDocument(mailbox); + await replaceText(mailbox, '{"reserved":[2],"queued":[3]}'); + assert.ok(await mailbox.save()); + await assert.rejects( + async () => + vscode.workspace.fs.writeFile(mailbox.uri, Buffer.from('{"reserved":[1,2],"queued":[]}')), + /zero or one/, + ); + const executed = documentChanged(state, (text) => text.trim() === "45"); + await vscode.commands.executeCommand("runa.restart", selected); + await vscode.commands.executeCommand("runa.tick", 20); + await executed; + const log = await vscode.workspace.openTextDocument(uri("log")); + await vscode.window.showTextDocument(log, { preview: false }); + assert.match(log.getText(), /INFO: original/); + assert.equal( + (await vscode.workspace.fs.stat(log.uri)).permissions, + vscode.FilePermission.Readonly, + ); + + const working = original.replace("state + message", "state + message * 10"); + await vscode.workspace.fs.writeFile(sourceDocument.uri, Buffer.from(working)); + await vscode.commands.executeCommand("runa.editBehavior", selected); + const behavior = vscode.window.activeTextEditor?.document; + assert.ok(behavior); + assert.equal(behavior.uri.toString(), uri("behavior").toString()); + assert.equal( + behavior.getText(), + original, + "edit starts from loaded behavior, not current working source", + ); + const temporary = original + .replace('"original"', '"temporary"') + .replace("state + message", "state + message * 100"); + await replaceText(behavior, temporary); + assert.ok(await behavior.save()); + assert.equal( + Buffer.from(await vscode.workspace.fs.readFile(sourceDocument.uri)).toString(), + working, + ); + await vscode.commands.executeCommand("runa.editBehavior", selected); + assert.equal(vscode.window.activeTextEditor?.document.uri.toString(), behavior.uri.toString()); + await vscode.commands.executeCommand("runa.refresh"); + const withTemporary = actorTree.getChildren().find((node) => node.actor_ref === ref); + assert.ok(withTemporary); + assert.ok( + actorTree.getChildren(withTemporary).some((node) => "file" in node && node.file === "behavior"), + ); + const tempExecuted = documentChanged(state, (text) => text.trim() === "145"); + const logged = documentChanged(log, (text) => text.includes("INFO: temporary")); + await vscode.commands.executeCommand("runa.send", selected, "1"); + await vscode.commands.executeCommand("runa.tick", 20); + await tempExecuted; + await logged; + + const window = vscode.window as { showWarningMessage: (...args: unknown[]) => unknown }; + const warning = window.showWarningMessage; + let answer: string | undefined; + window.showWarningMessage = async () => answer; + const renamed = vscode.Uri.from({ + scheme: "runa-image", + authority: address, + path: "/source/moved/files.rn", + }); + try { + await vscode.commands.executeCommand("runa.updateBehavior", selected); + assert.equal( + Buffer.from(await vscode.workspace.fs.readFile(sourceDocument.uri)).toString(), + working, + ); + answer = "Update Behavior"; + await vscode.commands.executeCommand("runa.updateBehavior", selected); + assert.equal( + Buffer.from(await vscode.workspace.fs.readFile(sourceDocument.uri)).toString(), + temporary, + ); + answer = undefined; + await assert.rejects( + async () => vscode.workspace.fs.rename(sourceDocument.uri, renamed), + /[Cc]ancel/, + ); + assert.equal( + Buffer.from(await vscode.workspace.fs.readFile(sourceDocument.uri)).toString(), + temporary, + ); + answer = "Rename and Update Actors"; + const renamedMeta = documentChanged( + meta, + (text) => JSON.parse(text).behavior_path === "moved/files", + ); + inputWindow.showInputBox = async () => "moved/files.rn"; + try { + await vscode.commands.executeCommand("runa.renameSource", { uri: sourceDocument.uri }, [ + { uri: sourceDocument.uri }, + ]); + } finally { + inputWindow.showInputBox = input; + } + await renamedMeta; + await vscode.commands.executeCommand("runa.goToBehavior", selected); + assert.equal(vscode.window.activeTextEditor?.document.uri.toString(), renamed.toString()); + await assert.rejects(async () => vscode.workspace.fs.delete(renamed), /stop the actors first/); + await vscode.commands.executeCommand("runa.reload", selected); + await vscode.commands.executeCommand("runa.tick", 20); + await vscode.commands.executeCommand("runa.refresh"); + const reloaded = actorTree.getChildren().find((node) => node.actor_ref === ref); + assert.ok(reloaded); + assert.ok( + !actorTree.getChildren(reloaded).some((node) => "file" in node && node.file === "behavior"), + ); + await vscode.commands.executeCommand("runa.kill", selected); + await vscode.commands.executeCommand("runa.refresh"); + assert.match( + Buffer.from(await vscode.workspace.fs.readFile(log.uri)).toString(), + /INFO: temporary/, + ); + assert.equal( + JSON.parse(Buffer.from(await vscode.workspace.fs.readFile(meta.uri)).toString()).removed, + true, + ); + await vscode.workspace.fs.delete(renamed); + assert.equal( + (await vscode.workspace.fs.stat(state.uri)).permissions, + vscode.FilePermission.Readonly, + ); + } finally { + window.showWarningMessage = warning; + } + console.log( + "Runa actor directories, live config, natural state/mailbox edits, logs, temporary behaviors and source operations passed", + ); +} + function documentChanged( document: vscode.TextDocument, expected: (text: string) => boolean, @@ -461,7 +667,11 @@ function documentChanged( }); const timeout = setTimeout(() => { subscription.dispose(); - reject(new Error("Actor inspector did not refresh")); + reject( + new Error( + `Document did not refresh: ${document.uri.toString()} (closed=${document.isClosed}): ${document.getText()}`, + ), + ); }, 5000); }); } diff --git a/vscode/integration/logs.test.ts b/vscode/integration/logs.test.ts index f9a364a..100935f 100644 --- a/vscode/integration/logs.test.ts +++ b/vscode/integration/logs.test.ts @@ -232,5 +232,9 @@ test( sequences, Array.from({ length: 300 }, (_, index) => index + 1), ); + assert.deepEqual( + (await client.logs(actor)).map((entry) => entry.sequence), + Array.from({ length: 300 }, (_, index) => BigInt(index + 1)), + ); }, ); diff --git a/vscode/integration/sources.test.ts b/vscode/integration/sources.test.ts index 672302c..05d9bcf 100644 --- a/vscode/integration/sources.test.ts +++ b/vscode/integration/sources.test.ts @@ -7,6 +7,96 @@ import { startImage } from "./server"; const conflict = (error: unknown) => error instanceof ProtocolError && error.code === "conflict"; +test( + "actor-local behaviors, source rename guards and log files use the image protocol", + { timeout: 10000 }, + async (t) => { + const image = await startImage(); + const client = await ImageClient.connect(image.address, (error) => + assert.equal(error, undefined), + ); + t.after(async () => { + await client.close(); + await image.stop(); + }); + const source = await client.writeSource( + "tests/behavior", + null, + 'pub fn handle(ctx, state, sender, message) { ctx.info("old"); state + 1 }', + ); + const actor = await client.spawn(source.path, parseValue("0")); + const other = await client.spawn(source.path, parseValue("0")); + const commit = await client.commitSource(source.path); + const saved = await client.writeSource( + source.path, + source.source_id, + source.source.replace("state + 1", "state + 2"), + ); + assert.equal((await client.actorBehavior(actor)).source, source.source); + const temporary = await client.writeActorBehavior( + actor, + source.source_id, + source.source.replace('"old"', '"temporary"').replace("state + 1", "state + 10"), + ); + assert.equal((await client.inspectActor(actor)).temporary_behavior, true); + assert.equal((await client.inspectActor(other)).temporary_behavior, false); + await assert.rejects( + client.writeActorBehavior(actor, source.source_id, source.source), + conflict, + ); + await assert.rejects( + client.updateBehaviorSource(actor, source.source_id, temporary.source_id, source.path), + conflict, + ); + const updated = await client.updateBehaviorSource( + actor, + saved.source_id, + temporary.source_id, + source.path, + ); + assert.equal(updated.source, temporary.source); + assert.deepEqual(await client.sourceHistory(source.path), [commit]); + await client.send(actor, parseValue("null")); + await client.send(other, parseValue("null")); + await client.tick(20); + assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10")); + assert.deepEqual((await client.inspectActor(other)).state, parseValue("1")); + const logs = await client.logs(actor); + assert.equal(logs.length, 1); + assert.equal(logs[0]?.message, "temporary"); + assert.equal(logs[0]?.level, "info"); + assert.equal(logs[0]?.actor_ref, actor); + await assert.rejects( + client.renameSource(source.path, "moved/behavior", updated.source_id, false), + /in use/, + ); + await assert.rejects( + client.deleteSource(source.path, updated.source_id), + /stop the actors first/, + ); + const renamed = await client.renameSource( + source.path, + "moved/behavior", + updated.source_id, + true, + ); + assert.equal((await client.inspectActor(actor)).behavior_path, renamed.path); + assert.equal((await client.inspectActor(other)).behavior_path, renamed.path); + await assert.rejects( + client.updateBehaviorSource(actor, updated.source_id, temporary.source_id, source.path), + conflict, + ); + assert.deepEqual(await client.sourceHistory(renamed.path), [commit]); + await assert.rejects(client.readSource(source.path), ProtocolError); + await client.system(actor, { kind: "reload" }); + assert.equal((await client.inspectActor(actor)).temporary_behavior, false); + await client.system(actor, { kind: "kill" }); + await client.system(other, { kind: "kill" }); + await client.deleteSource(renamed.path, renamed.source_id); + await assert.rejects(client.readSource(renamed.path), ProtocolError); + }, +); + test( "image sources are saved with compare-and-swap, spawned uncommitted, committed, and rolled back", { timeout: 10000 }, diff --git a/vscode/package.json b/vscode/package.json index 8f09ca5..60a0061 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -13,6 +13,7 @@ ], "main": "./out/src/extension.js", "activationEvents": [ + "onFileSystem:runa-actor", "onView:runa.actors", "onView:runa.sources", "onFileSystem:runa-image" @@ -38,7 +39,55 @@ "typescript": "^5.9.3" }, "contributes": { + "languages": [ + { + "id": "rune", + "aliases": [ + "Rune" + ], + "extensions": [ + ".rn" + ] + } + ], "commands": [ + { + "command": "runa.newSource", + "title": "New Source File...", + "category": "Runa", + "icon": "$(new-file)", + "enablement": "runa.connected" + }, + { + "command": "runa.renameSource", + "title": "Rename or Move Source...", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.deleteSource", + "title": "Delete Source", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.editBehavior", + "title": "Edit Current Behavior", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.goToBehavior", + "title": "Go to Behavior", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.updateBehavior", + "title": "Update Behavior...", + "category": "Runa", + "enablement": "runa.connected" + }, { "command": "runa.editState", "title": "Edit Actor State...", @@ -215,6 +264,11 @@ ], "menus": { "view/title": [ + { + "command": "runa.newSource", + "when": "view == runa.sources && runa.connected", + "group": "navigation" + }, { "command": "runa.tick", "when": "view == runa.actors && runa.connected", @@ -251,6 +305,36 @@ } ], "view/item/context": [ + { + "command": "runa.newSource", + "when": "view == runa.sources && viewItem == runa.sourceDirectory && runa.connected", + "group": "runa_0_files@1" + }, + { + "command": "runa.renameSource", + "when": "view == runa.sources && viewItem == runa.sourceFile && runa.connected", + "group": "runa_0_files@2" + }, + { + "command": "runa.deleteSource", + "when": "view == runa.sources && viewItem == runa.sourceFile && runa.connected", + "group": "runa_0_files@3" + }, + { + "command": "runa.editBehavior", + "when": "view == runa.actors && viewItem == runa.actor && runa.connected", + "group": "behavior@1" + }, + { + "command": "runa.goToBehavior", + "when": "view == runa.actors && viewItem == runa.actor && runa.connected", + "group": "behavior@2" + }, + { + "command": "runa.updateBehavior", + "when": "view == runa.actors && viewItem == runa.actor && runa.connected", + "group": "behavior@3" + }, { "command": "runa.copyActorReference", "when": "view == runa.actors && viewItem == runa.actor", diff --git a/vscode/src/actorFiles.ts b/vscode/src/actorFiles.ts new file mode 100644 index 0000000..6f14bdc --- /dev/null +++ b/vscode/src/actorFiles.ts @@ -0,0 +1,278 @@ +import * as vscode from "vscode"; +import { stringify } from "lossless-json"; +import { TextDecoder } from "node:util"; +import { ImageClient } from "./client"; +import { ActorSnapshot, LOG_LEVELS, ProtocolError } from "./protocol"; +import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits"; +import { formatValue, parseValue } from "./values"; + +export const ACTOR_SCHEME = "runa-actor"; +export const ACTOR_FILES = [ + "meta.json", + "config.json", + "state.json", + "mailbox.json", + "log", +] as const; +export function actorFileUri(session: number, actor: string, file?: string): vscode.Uri { + return vscode.Uri.from({ + scheme: ACTOR_SCHEME, + authority: String(session), + path: `/${actor}${file ? `/${file}` : ""}`, + }); +} +interface Connection { + session: number; + client: ImageClient; + check(): void; +} +interface Entry { + actor: ActorSnapshot; + session: number; + connected: boolean; + removed: boolean; + mtime: number; +} + +export class ActorFiles implements vscode.FileSystemProvider { + private readonly changes = new vscode.EventEmitter(); + readonly onDidChangeFile = this.changes.event; + private readonly actors = new Map(); + private readonly behaviorIds = new Map(); + private readonly textCache = new Map(); + constructor( + private readonly connection: () => Connection, + private readonly refresh: () => Promise, + ) {} + + refreshActors(session: number, actors: ActorSnapshot[]): void { + const seen = new Set(actors.map((actor) => actor.actor_ref)); + for (const actor of actors) { + const key = actorFileUri(session, actor.actor_ref).toString(); + const old = this.actors.get(key); + this.actors.set(key, { + actor, + session, + connected: true, + removed: false, + mtime: Math.max(Date.now(), (old?.mtime ?? 0) + 1), + }); + this.fire(session, actor.actor_ref); + } + for (const entry of this.actors.values()) { + if (entry.session === session && !seen.has(entry.actor.actor_ref) && !entry.removed) { + entry.removed = true; + entry.mtime++; + this.fire(session, entry.actor.actor_ref); + } + } + } + disconnect(session: number): void { + for (const entry of this.actors.values()) + if (entry.session === session) { + entry.connected = false; + entry.mtime++; + this.fire(session, entry.actor.actor_ref); + } + } + private fire(session: number, actor: string): void { + this.changes.fire( + [...ACTOR_FILES, "behavior"].map((file) => ({ + type: vscode.FileChangeType.Changed, + uri: actorFileUri(session, actor, file), + })), + ); + } + private entry(uri: vscode.Uri): { entry: Entry; file?: string } { + const parts = uri.path.split("/").filter(Boolean); + if (parts.length < 1 || parts.length > 2) throw vscode.FileSystemError.FileNotFound(uri); + const entry = this.actors.get(actorFileUri(Number(uri.authority), parts[0]!).toString()); + if (!entry || (parts[1] !== undefined && ![...ACTOR_FILES, "behavior"].includes(parts[1]))) + throw vscode.FileSystemError.FileNotFound(uri); + return { entry, file: parts[1] }; + } + private writable(entry: Entry, file?: string): boolean { + return ( + entry.connected && + !entry.removed && + (file === "config.json" || + file === "behavior" || + ((file === "state.json" || file === "mailbox.json") && canEditActor(entry.actor))) + ); + } + stat(uri: vscode.Uri): vscode.FileStat { + const { entry, file } = this.entry(uri); + return { + type: file ? vscode.FileType.File : vscode.FileType.Directory, + ctime: 0, + mtime: entry.mtime, + size: 0, + permissions: this.writable(entry, file) ? undefined : vscode.FilePermission.Readonly, + }; + } + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + const { entry, file } = this.entry(uri); + if (file) throw vscode.FileSystemError.FileNotADirectory(uri); + return [...ACTOR_FILES, ...(entry.actor.temporary_behavior ? ["behavior"] : [])].map((name) => [ + name, + vscode.FileType.File, + ]); + } + async readFile(uri: vscode.Uri): Promise { + const { entry, file } = this.entry(uri); + const actor = entry.actor; + let text: string; + switch (file) { + case "state.json": + text = formatValue(actor.state); + break; + case "mailbox.json": + text = formatValue(mailboxValue(actor)); + break; + case "config.json": + text = JSON.stringify({ log_level: actor.log_level }, null, 2); + break; + case "meta.json": { + const { + state: _state, + reserved: _reserved, + queued: _queued, + log_level: _level, + ...meta + } = actor; + const formatted = stringify( + { ...meta, connected: entry.connected, removed: entry.removed }, + null, + 2, + ); + if (formatted === undefined) throw new Error("Could not format actor metadata"); + text = formatted; + break; + } + case "behavior": { + if (!entry.connected || entry.removed) return this.cached(uri); + const connection = this.current(entry); + const behavior = await this.readLive(uri, () => + connection.client.actorBehavior(actor.actor_ref), + ); + if (behavior === undefined) return this.cached(uri); + connection.check(); + if ( + !vscode.workspace.textDocuments.some( + (document) => document.uri.toString() === uri.toString() && document.isDirty, + ) + ) + this.behaviorIds.set(uri.toString(), behavior.source_id); + const content = Buffer.from(behavior.source); + this.textCache.set(uri.toString(), content); + return content; + } + case "log": { + if (!entry.connected || entry.removed) return this.cached(uri); + const connection = this.current(entry); + const logs = await this.readLive(uri, () => connection.client.logs(actor.actor_ref)); + if (logs === undefined) return this.cached(uri); + connection.check(); + const content = Buffer.from( + logs + .map((log) => `${log.sequence} ${log.level.toUpperCase()}: ${log.message}\n`) + .join(""), + ); + this.textCache.set(uri.toString(), content); + return content; + } + default: + throw vscode.FileSystemError.FileIsADirectory(uri); + } + return Buffer.from(text + "\n"); + } + private current(entry: Entry): Connection { + const connection = this.connection(); + if (!entry.connected || entry.removed || connection.session !== entry.session) + throw vscode.FileSystemError.Unavailable( + "Actor is removed or belongs to a disconnected session", + ); + return connection; + } + private async readLive(uri: vscode.Uri, read: () => Promise): Promise { + try { + return await read(); + } catch (error) { + if (!(error instanceof ProtocolError) || error.code !== "not_found") throw error; + const current = this.entry(uri).entry; + current.removed = true; + current.mtime++; + this.fire(current.session, current.actor.actor_ref); + if (!this.textCache.has(uri.toString())) + throw vscode.FileSystemError.FileNotFound("Actor was removed"); + return undefined; + } + } + private cached(uri: vscode.Uri): Uint8Array { + const text = this.textCache.get(uri.toString()); + if (!text) + throw vscode.FileSystemError.Unavailable( + "No cached content for this disconnected or removed actor", + ); + return text; + } + async writeFile(uri: vscode.Uri, content: Uint8Array): Promise { + const { entry, file } = this.entry(uri); + if (!this.writable(entry, file)) + throw vscode.FileSystemError.NoPermissions("Actor file is read-only in its current state"); + const connection = this.current(entry); + const text = new TextDecoder("utf-8", { fatal: true }).decode(content); + const ref = entry.actor.actor_ref; + if (file === "behavior") { + const expected = this.behaviorIds.get(uri.toString()); + if (!expected) + throw vscode.FileSystemError.Unavailable("Open the current actor behavior before saving"); + const result = await connection.client.writeActorBehavior(ref, expected, text); + connection.check(); + this.behaviorIds.set(uri.toString(), result.source_id); + } else { + const value = parseValue(text); + if (file === "state.json") await connection.client.editActor(ref, value, null); + else if (file === "config.json") { + if ( + value.type !== "record" || + Object.keys(value.data).length !== 1 || + value.data.log_level?.type !== "string" + ) + throw new Error('Use {"log_level":"info"}'); + const requested = value.data.log_level.data; + const level = LOG_LEVELS.find((level) => level === requested); + if (!level) throw new Error("Invalid log level"); + await connection.client.setLogLevel(ref, level); + } else if (file === "mailbox.json") { + await connection.client.editActor(ref, null, mailboxEdit(value)); + } else throw vscode.FileSystemError.NoPermissions(uri); + } + connection.check(); + await this.refresh(); + } + didSave(document: vscode.TextDocument): void { + if (document.uri.scheme !== ACTOR_SCHEME || document.isDirty) return; + const { entry } = this.entry(document.uri); + entry.mtime++; + this.fire(entry.session, entry.actor.actor_ref); + } + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + createDirectory(uri: vscode.Uri): void { + if (this.entry(uri).file) throw vscode.FileSystemError.NoPermissions(uri); + } + delete(): never { + throw vscode.FileSystemError.NoPermissions("Use Stop or Kill to remove actors"); + } + rename(): never { + throw vscode.FileSystemError.NoPermissions("Actor files cannot be renamed"); + } + dispose(): void { + this.changes.dispose(); + this.actors.clear(); + this.behaviorIds.clear(); + this.textCache.clear(); + } +} diff --git a/vscode/src/client.ts b/vscode/src/client.ts index 80e78bd..2c7e037 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -21,6 +21,7 @@ import { Value, WEBSOCKET_SUBPROTOCOL, LogLevel, + LogEntry, LOG_LEVELS, } from "./protocol"; @@ -125,6 +126,89 @@ export class ImageClient { return reply.data; } + async actorBehavior(actorRef: string): Promise<{ source: string; source_id: string }> { + const reply = await this.request({ + operation: "actor_behavior", + parameters: { actor_ref: actorRef }, + }); + if (reply.kind !== "actor_behavior") throw new Error("Expected actor behavior"); + return reply.data; + } + + async writeActorBehavior( + actorRef: string, + expected: string, + source: string, + ): Promise<{ source: string; source_id: string }> { + const reply = await this.request({ + operation: "write_actor_behavior", + parameters: { actor_ref: actorRef, expected_source_id: expected, source }, + }); + if (reply.kind !== "actor_behavior") throw new Error("Expected actor behavior"); + return reply.data; + } + + async updateBehaviorSource( + actorRef: string, + expected: string, + temporary: string, + path: string, + ): Promise { + const reply = await this.request({ + operation: "update_behavior_source", + parameters: { + actor_ref: actorRef, + expected_source_id: expected, + temporary_source_id: temporary, + path: sourcePath(path), + }, + }); + if (reply.kind !== "source") throw new Error("Expected updated behavior source"); + return reply.data; + } + + async renameSource( + path: string, + target: string, + expected: string, + updateActors: boolean, + ): Promise { + const reply = await this.request({ + operation: "rename_source", + parameters: { + path: sourcePath(path), + target: sourcePath(target), + expected_source_id: expected, + update_actors: updateActors, + }, + }); + if (reply.kind !== "source") throw new Error("Expected renamed source"); + return reply.data; + } + + async deleteSource(path: string, expected: string): Promise { + await this.request({ + operation: "delete_source", + parameters: { path: sourcePath(path), expected_source_id: expected }, + }); + } + + async logs(actorRef: string): Promise { + const entries: LogEntry[] = []; + let after = 0n; + for (;;) { + const reply = await this.request({ + operation: "logs", + parameters: { actors: [actorRef], follow: false, after }, + }); + if (reply.kind !== "logs") throw new Error("Expected actor logs"); + entries.push(...reply.data.entries); + if (!reply.data.has_more) return entries; + if (reply.data.next_sequence <= after) throw new Error("Non-increasing log cursor"); + after = reply.data.next_sequence; + } + } + async inspectActor(actorRef: string): Promise { if (!isActorRef(actorRef)) throw new Error("Invalid actor reference"); const reply = await this.request({ diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index cc47fcf..2222620 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -11,7 +11,8 @@ import { import { RefreshQueue } from "./refresh"; import { parseValue, formatValue } from "./values"; import { SnapshotDocuments } from "./snapshotProvider"; -import { canEditActor } from "./snapshotEdits"; +import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits"; +import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles"; import { ImageSourceFileSystem, ImageSourceTree, @@ -28,22 +29,40 @@ export async function deactivate(): Promise { await closeConnections?.(); } -class ActorTree implements vscode.TreeDataProvider { +type ActorNode = ActorSnapshot | { actor_ref: string; file: string }; +class ActorTree implements vscode.TreeDataProvider { private readonly changes = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.changes.event; actors: ActorSnapshot[] = []; + session = 0; replace(actors: ActorSnapshot[]): void { this.actors = actors; this.changes.fire(); } - getChildren(): ActorSnapshot[] { - return this.actors; + getChildren(parent?: ActorNode): ActorNode[] { + if (!parent) return this.actors; + if ("file" in parent) return []; + return [...ACTOR_FILES, ...(parent.temporary_behavior ? ["behavior"] : [])].map((file) => ({ + actor_ref: parent.actor_ref, + file, + })); } - getTreeItem(actor: ActorSnapshot): vscode.TreeItem { - const item = new vscode.TreeItem(`@${actor.actor_ref}`); + getTreeItem(actor: ActorNode): vscode.TreeItem { + if ("file" in actor) { + const uri = actorFileUri(this.session, actor.actor_ref, actor.file); + const item = new vscode.TreeItem(uri); + item.contextValue = "runa.actorFile"; + item.command = { command: "vscode.open", title: "Open Actor File", arguments: [uri] }; + return item; + } + const item = new vscode.TreeItem( + `@${actor.actor_ref}`, + vscode.TreeItemCollapsibleState.Collapsed, + ); + item.resourceUri = actorFileUri(this.session, actor.actor_ref); item.id = actor.actor_ref; item.contextValue = "runa.actor"; item.description = `${actor.status.status}, queued ${actor.queued.length}${actor.reserved ? ", reserved 1" : ""}`; @@ -54,14 +73,7 @@ class ActorTree implements vscode.TreeDataProvider { `Incarnation: ${actor.incarnation}`, `Implementation: ${actor.implementation_id}`, ].join("\n"); - item.iconPath = new vscode.ThemeIcon( - actor.status.status === "failed" ? "error" : "symbol-interface", - ); - item.command = { - command: "runa.inspect", - title: "Inspect Actor", - arguments: [actor], - }; + item.iconPath = new vscode.ThemeIcon(actor.status.status === "failed" ? "error" : "folder"); return item; } @@ -72,7 +84,10 @@ class ActorTree implements vscode.TreeDataProvider { type SourceTarget = vscode.Uri | { path: string } | { uri: vscode.Uri } | string | undefined; -export function activate(context: vscode.ExtensionContext): { sourceTree: ImageSourceTree } { +export function activate(context: vscode.ExtensionContext): { + sourceTree: ImageSourceTree; + actorTree: ActorTree; +} { const tree = new ActorTree(); const documents = new SnapshotDocuments( async (savedSession, actorRef, edit) => { @@ -99,6 +114,10 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS }, ); const sources = new ImageSourceFileSystem(); + const actorFiles = new ActorFiles( + () => active(), + () => refresh(), + ); const sourceTree = new ImageSourceTree(sources); const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree }); const revisions = new RevisionDocuments(); @@ -151,6 +170,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS const previous = client; client = undefined; documents.disconnect(session); + actorFiles.disconnect(session); sources.store.detach(); session++; const closed = closeClient(previous); @@ -167,7 +187,9 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS if (current !== client || currentSession !== session) return; await sources.store.refresh(); if (current !== client || currentSession !== session) return; + tree.session = session; tree.replace(actors); + actorFiles.refreshActors(session, actors); sourceTree.refresh(); documents.refresh(session, endpoint, actors); view.message = actors.length @@ -213,6 +235,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS updates.dispose(); tree.replace([]); documents.disconnect(currentSession); + actorFiles.disconnect(currentSession); sources.store.detach(); sourceTree.refresh(); connectionStatus(false); @@ -543,19 +566,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS if (!canEditActor(actor)) { throw new Error("Editing requires a paused or failed actor with no active invocation"); } - const current = - kind === "state" - ? actor.state - : { - type: "record" as const, - data: { - reserved: { - type: "list" as const, - data: actor.reserved === null ? [] : [actor.reserved], - }, - queued: { type: "list" as const, data: actor.queued }, - }, - }; + const current = kind === "state" ? actor.state : mailboxValue(actor); const input = typeof source === "string" ? source @@ -566,19 +577,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS if (kind === "state") { await connection.client.editActor(actorRef, value, null); } else { - if ( - value.type !== "record" || - Object.keys(value.data).length !== 2 || - value.data.reserved?.type !== "list" || - value.data.queued?.type !== "list" || - value.data.reserved.data.length > 1 - ) { - throw new Error('Use {"reserved":[],"queued":[]} with zero or one reserved message'); - } - await connection.client.editActor(actorRef, null, { - reserved: value.data.reserved.data[0] ?? null, - queued: value.data.queued.data, - }); + await connection.client.editActor(actorRef, null, mailboxEdit(value)); } await refresh(); output.appendLine(`Updated ${kind} for @${actorRef}`); @@ -642,10 +641,113 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS try { return await action(...args); } catch (error) { - report(error); + if (!(error instanceof vscode.CancellationError)) report(error); } }); + const sourceUri = async (target?: SourceTarget): Promise => { + const connection = active(); + const path = await sourceTarget(target, connection.endpoint, "Select a source file"); + connection.check(); + return path === undefined ? undefined : sourceFileUri(connection.endpoint, path); + }; + sources.mutations = { + delete: async (uri) => { + const connection = active(); + if (uri.authority !== connection.endpoint) throw new Error("Source belongs to another image"); + const path = sourcePathOf(uri); + const source = await connection.client.readSource(path); + connection.check(); + await connection.client.deleteSource(path, source.source_id); + connection.check(); + await refresh(); + }, + rename: async (from, to) => { + const connection = active(); + if (from.authority !== connection.endpoint || to.authority !== connection.endpoint) + throw new Error("Cannot move sources between images"); + const path = sourcePathOf(from), + target = sourcePathOf(to); + if (path === target) return; + const source = await connection.client.readSource(path); + const actors = await connection.client.listActors(); + const inUse = actors.some( + (actor) => actor.behavior_path === path || actor.pending_behavior_path === path, + ); + if ( + inUse && + (await vscode.window.showWarningMessage( + "This behavior is in use. Rename it and update all actors using it?", + { modal: true }, + "Rename and Update Actors", + )) !== "Rename and Update Actors" + ) + throw new vscode.CancellationError(); + connection.check(); + await connection.client.renameSource(path, target, source.source_id, inUse); + connection.check(); + await refresh(); + }, + }; + + const editBehavior = async (selected?: { actor_ref: string }) => { + const connection = active(); + const ref = await chooseActor(selected); + if (ref === undefined) return; + const actor = await connection.client.inspectActor(ref); + connection.check(); + const actors = await connection.client.listActors(); + connection.check(); + actorFiles.refreshActors(connection.session, actors); + const document = await vscode.workspace.openTextDocument( + actorFileUri(connection.session, actor.actor_ref, "behavior"), + ); + await vscode.languages.setTextDocumentLanguage(document, "rune"); + await vscode.window.showTextDocument(document); + }; + + const goToBehavior = async (selected?: { actor_ref: string }) => { + const connection = active(); + const ref = await chooseActor(selected); + if (ref === undefined) return; + const actor = await connection.client.inspectActor(ref); + connection.check(); + if (!actor.behavior_path) throw new Error("Actor has no image source path"); + await vscode.window.showTextDocument( + await vscode.workspace.openTextDocument( + sourceFileUri(connection.endpoint, actor.behavior_path), + ), + ); + }; + + const updateBehavior = async (selected?: { actor_ref: string }) => { + const connection = active(); + const ref = await chooseActor(selected); + if (ref === undefined) return; + const actor = await connection.client.inspectActor(ref); + if (!actor.temporary_behavior || !actor.behavior_path) + throw new Error("Actor has no temporary behavior"); + const temporary = await connection.client.actorBehavior(ref); + const original = await connection.client.readSource(actor.behavior_path); + if ( + (await vscode.window.showWarningMessage( + `Overwrite source/${actor.behavior_path}.rn with this actor's temporary behavior?`, + { modal: true }, + "Update Behavior", + )) !== "Update Behavior" + ) + return; + connection.check(); + await connection.client.updateBehaviorSource( + ref, + original.source_id, + temporary.source_id, + actor.behavior_path, + ); + connection.check(); + await refresh(); + }; + const systemCommand = (kind: SystemKind) => command(`runa.${kind}`, (selected?: { actor_ref: string }) => system(kind, selected)); @@ -656,13 +758,18 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS sourceView, revisions, documents, + actorFiles, view, output, status, vscode.workspace.registerFileSystemProvider(SOURCE_SCHEME, sources, { isCaseSensitive: true }), vscode.workspace.registerFileSystemProvider("runa", documents, { isCaseSensitive: true }), + vscode.workspace.registerFileSystemProvider(ACTOR_SCHEME, actorFiles, { + isCaseSensitive: true, + }), vscode.workspace.onDidSaveTextDocument((document) => { documents.didSave(document); + actorFiles.didSave(document); }), vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions), vscode.workspace.onDidCloseTextDocument((document) => { @@ -672,6 +779,66 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS command("runa.disconnect", disconnect), command("runa.refresh", refresh), command("runa.inspect", inspect), + command("runa.editBehavior", editBehavior), + command("runa.goToBehavior", goToBehavior), + command("runa.updateBehavior", updateBehavior), + command( + "runa.newSource", + async (selected?: { uri: vscode.Uri; directory?: boolean }, path?: string) => { + const connection = active(); + const prefix = selected?.directory + ? selected.uri.path.replace(/^\/source\/?/, "").replace(/\/$/, "") + : ""; + const input = + typeof path === "string" + ? path + : await vscode.window.showInputBox({ + title: "New image source file", + value: prefix ? `${prefix}/` : "actors/", + prompt: "Image path, such as actors/counter.rn", + }); + if (input === undefined) return; + const canonical = input.replace(/\.rn$/, ""); + if (!isSourcePath(canonical)) throw new Error("Enter a canonical image source path"); + connection.check(); + const uri = sourceFileUri(connection.endpoint, canonical); + await sources.writeFile( + uri, + Buffer.from("pub fn handle(ctx, state, sender, message) {\n state\n}\n"), + { create: true, overwrite: false }, + ); + await refresh(); + await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uri)); + }, + ), + command("runa.renameSource", async (selected?: SourceTarget, target?: string) => { + const uri = await sourceUri(selected); + if (!uri) return; + const input = + typeof target === "string" + ? target + : await vscode.window.showInputBox({ + title: "Rename or move image source", + value: `${sourcePathOf(uri)}.rn`, + }); + if (input === undefined) return; + const canonical = input.replace(/\.rn$/, ""); + if (!isSourcePath(canonical)) throw new Error("Enter a canonical image source path"); + await sources.rename(uri, sourceFileUri(uri.authority, canonical)); + }), + command("runa.deleteSource", async (selected?: SourceTarget) => { + const uri = await sourceUri(selected); + if (!uri) return; + if ( + (await vscode.window.showWarningMessage( + `Delete source/${sourcePathOf(uri)}.rn and its commit history?`, + { modal: true }, + "Delete", + )) !== "Delete" + ) + return; + await sources.delete(uri); + }), command("runa.copyActorReference", async (selected?: { actor_ref: string }) => { const actorRef = await chooseActor(selected); if (actorRef !== undefined) await vscode.env.clipboard.writeText(actorRef); @@ -708,5 +875,5 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS ); connectionStatus(false); status.show(); - return { sourceTree }; + return { sourceTree, actorTree: tree }; } diff --git a/vscode/src/protocol.ts b/vscode/src/protocol.ts index b648573..e869bc6 100644 --- a/vscode/src/protocol.ts +++ b/vscode/src/protocol.ts @@ -14,6 +14,9 @@ export type Value = | { type: "record"; data: Record }; export interface ActorSnapshot { + behavior_path?: string | null; + pending_behavior_path?: string | null; + temporary_behavior?: boolean; log_level: LogLevel; actor_ref: string; mailbox_id: bigint; @@ -37,6 +40,12 @@ export type SystemKind = "stop" | "kill" | "restart" | "pause" | "resume" | "rel export type SystemMessage = { kind: SystemKind } | { kind: "become"; path: string }; export const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const; export type LogLevel = (typeof LOG_LEVELS)[number]; +export interface LogEntry { + sequence: bigint; + actor_ref: string; + level: LogLevel; + message: string; +} export interface SourceFile { path: string; @@ -50,6 +59,31 @@ export interface SourceCommit { } export type Command = + | { operation: "actor_behavior"; parameters: { actor_ref: string } } + | { + operation: "write_actor_behavior"; + parameters: { actor_ref: string; expected_source_id: string; source: string }; + } + | { + operation: "update_behavior_source"; + parameters: { + actor_ref: string; + path: string; + expected_source_id: string; + temporary_source_id: string; + }; + } + | { + operation: "rename_source"; + parameters: { + path: string; + target: string; + expected_source_id: string; + update_actors: boolean; + }; + } + | { operation: "delete_source"; parameters: { path: string; expected_source_id: string } } + | { operation: "logs"; parameters: { actors: string[]; follow: false; after: bigint } } | { operation: "set_log_level"; parameters: { actor_ref: string; level: LogLevel } } | { operation: "edit_actor"; @@ -204,6 +238,22 @@ function decodeActor(input: unknown): ActorSnapshot { const level = LOG_LEVELS.find((level) => level === actor.log_level); if (level === undefined) throw new Error("Invalid actor log level"); return { + behavior_path: + actor.behavior_path === undefined || actor.behavior_path === null + ? null + : text(actor.behavior_path), + pending_behavior_path: + actor.pending_behavior_path === undefined || actor.pending_behavior_path === null + ? null + : text(actor.pending_behavior_path), + temporary_behavior: + actor.temporary_behavior === undefined + ? false + : (() => { + if (typeof actor.temporary_behavior !== "boolean") + throw new Error("Invalid temporary behavior flag"); + return actor.temporary_behavior; + })(), log_level: level, actor_ref: actor.actor_ref, mailbox_id: uint(actor.mailbox_id), @@ -238,6 +288,8 @@ function decodeSourceCommit(input: unknown): SourceCommit { } export type Reply = + | { kind: "actor_behavior"; data: { source: string; source_id: string } } + | { kind: "logs"; data: { entries: LogEntry[]; next_sequence: bigint; has_more: boolean } } | { kind: "actors"; data: ActorSnapshot[] } | { kind: "actor"; data: ActorSnapshot } | { kind: "sources"; data: SourceFile[] } @@ -299,6 +351,59 @@ export function decodeResponse(line: string, requestId: string, command: Command throw new ProtocolError(code, text(error.message)); } const reply = object(result.Ok); + if ( + (command.operation === "actor_behavior" || command.operation === "write_actor_behavior") && + reply.kind === "actor_behavior" + ) { + const data = object(reply.data); + const source = text(data.source), + source_id = text(data.source_id); + if (!source_id) throw new Error("Invalid source identity"); + if (command.operation === "write_actor_behavior" && source !== command.parameters.source) + throw new Error("Written actor behavior mismatch"); + return { kind: "actor_behavior", data: { source, source_id } }; + } + if (command.operation === "logs" && reply.kind === "logs") { + const data = object(reply.data); + if (typeof data.has_more !== "boolean") throw new Error("Invalid log pagination flag"); + const entries = array(data.entries).map((input): LogEntry => { + const entry = object(input); + if (!isActorRef(entry.actor_ref)) throw new Error("Invalid log actor"); + const level = LOG_LEVELS.find((level) => level === entry.level); + if (level === undefined) throw new Error("Invalid log level"); + return { + sequence: uint(entry.sequence), + actor_ref: entry.actor_ref, + level, + message: text(entry.message), + }; + }); + let previous = command.parameters.after; + for (const entry of entries) { + if (entry.sequence <= previous || !command.parameters.actors.includes(entry.actor_ref)) + throw new Error("Invalid log ordering or actor filter"); + previous = entry.sequence; + } + const next_sequence = uint(data.next_sequence); + if ( + next_sequence !== previous || + entries.length > 128 || + (data.has_more && entries.length === 0) + ) + throw new Error("Invalid log pagination cursor"); + return { kind: "logs", data: { entries, next_sequence, has_more: data.has_more } }; + } + if ( + (command.operation === "rename_source" || command.operation === "update_behavior_source") && + reply.kind === "source" + ) { + const data = decodeSourceFile(reply.data); + if (command.operation === "rename_source" && data.path !== command.parameters.target) + throw new Error("Renamed source mismatch"); + if (command.operation === "update_behavior_source" && data.path !== command.parameters.path) + throw new Error("Updated source path mismatch"); + return { kind: "source", data }; + } if (command.operation === "revision_source" && reply.kind === "behavior_source") { return { kind: "behavior_source", data: { source: text(object(reply.data).source) } }; } @@ -346,6 +451,7 @@ export function decodeResponse(line: string, requestId: string, command: Command (command.operation === "send" || command.operation === "system" || command.operation === "set_log_level" || + command.operation === "delete_source" || command.operation === "edit_actor") && reply.kind === "acknowledged" ) diff --git a/vscode/src/snapshotEdits.ts b/vscode/src/snapshotEdits.ts index 90e4655..584df08 100644 --- a/vscode/src/snapshotEdits.ts +++ b/vscode/src/snapshotEdits.ts @@ -19,6 +19,28 @@ export interface SnapshotEdit { discarded: string[]; } +export function mailboxValue(actor: ActorSnapshot): Value { + return { + type: "record", + data: { + reserved: { type: "list", data: actor.reserved === null ? [] : [actor.reserved] }, + queued: { type: "list", data: actor.queued }, + }, + }; +} + +export function mailboxEdit(value: Value): NonNullable { + if ( + value.type !== "record" || + Object.keys(value.data).length !== 2 || + value.data.reserved?.type !== "list" || + value.data.queued?.type !== "list" || + value.data.reserved.data.length > 1 + ) + throw new Error('Use {"reserved":[],"queued":[]} with zero or one reserved message'); + return { reserved: value.data.reserved.data[0] ?? null, queued: value.data.queued.data }; +} + export function parseSnapshotEdit(source: string, original: string): SnapshotEdit { if (Buffer.byteLength(source) > MAX_MESSAGE_BYTES) throw new Error("Snapshot exceeds 1 MiB"); const read = (text: string): Record => { diff --git a/vscode/src/sourceProvider.ts b/vscode/src/sourceProvider.ts index 8b6ee68..c97c7f2 100644 --- a/vscode/src/sourceProvider.ts +++ b/vscode/src/sourceProvider.ts @@ -79,6 +79,10 @@ export class ImageSourceFileSystem implements vscode.FileSystemProvider { private readonly changes = new vscode.EventEmitter(); readonly onDidChangeFile = this.changes.event; readonly store: SourceStore; + mutations?: { + delete(uri: vscode.Uri): Promise; + rename(from: vscode.Uri, to: vscode.Uri): Promise; + }; constructor() { this.store = new SourceStore((changes) => this.fire(changes)); @@ -189,12 +193,20 @@ export class ImageSourceFileSystem implements vscode.FileSystemProvider { await this.runAsync(uri, () => this.store.write(uri.path, content, options)); } - delete(uri: vscode.Uri): void { - this.run(uri, () => this.store.rejectMutation("Deleting")); + async delete(uri: vscode.Uri): Promise { + await this.runAsync(uri, () => { + if (!this.mutations) + throw vscode.FileSystemError.Unavailable("Source operations are not connected"); + return this.mutations.delete(uri); + }); } - rename(oldUri: vscode.Uri): void { - this.run(oldUri, () => this.store.rejectMutation("Renaming")); + async rename(oldUri: vscode.Uri, newUri: vscode.Uri): Promise { + await this.runAsync(oldUri, () => { + if (!this.mutations) + throw vscode.FileSystemError.Unavailable("Source operations are not connected"); + return this.mutations.rename(oldUri, newUri); + }); } dispose(): void { diff --git a/vscode/src/sources.ts b/vscode/src/sources.ts index 8d55554..1c49a18 100644 --- a/vscode/src/sources.ts +++ b/vscode/src/sources.ts @@ -284,13 +284,6 @@ export class SourceStore { if (changes.length) this.emit(changes); } - rejectMutation(action: string): never { - throw new SourceFsError( - "NoPermissions", - `${action} image sources is not supported; the image keeps every source path`, - ); - } - private filePath(uriPath: string, parse = parseSourceUriPath): string { const entry = parse(uriPath); if (entry.kind !== "file") { diff --git a/vscode/test/fixtures.ts b/vscode/test/fixtures.ts index 2a56950..1f86dc0 100644 --- a/vscode/test/fixtures.ts +++ b/vscode/test/fixtures.ts @@ -8,6 +8,9 @@ export const REF = "abcdefghijklmnopqrstu"; export function actor(): ActorSnapshot { return { + behavior_path: null, + pending_behavior_path: null, + temporary_behavior: false, log_level: "info", actor_ref: REF, mailbox_id: 18446744073709551615n, diff --git a/vscode/test/protocol.test.ts b/vscode/test/protocol.test.ts index 7d81cc6..b9cb0d6 100644 --- a/vscode/test/protocol.test.ts +++ b/vscode/test/protocol.test.ts @@ -17,6 +17,50 @@ const command: Command = { operation: "list_actors" }; const envelope = (reply: unknown) => json({ version: 5, request_id: "test", result: { Ok: reply } }); +test("actor behavior replies check identity, text and the requested operation", () => { + const command: Command = { + operation: "write_actor_behavior", + parameters: { actor_ref: REF, expected_source_id: "original", source: "updated" }, + }; + const reply = { kind: "actor_behavior", data: { source: "updated", source_id: "temporary" } }; + assert.deepEqual(decodeResponse(envelope(reply), "test", command), reply); + for (const data of [ + { source: "other", source_id: "temporary" }, + { source: "updated", source_id: "" }, + { source: 7, source_id: "temporary" }, + ]) { + assert.throws(() => + decodeResponse(envelope({ kind: "actor_behavior", data }), "test", command), + ); + } + assert.throws(() => decodeResponse(envelope(reply), "test", { operation: "list_sources" })); +}); + +test("log pages preserve exact cursors, actor filters and strictly increasing sequences", () => { + const command: Command = { + operation: "logs", + parameters: { actors: [REF], follow: false, after: 9223372036854775807n }, + }; + const entry = { sequence: 9223372036854775808n, actor_ref: REF, level: "info", message: "line" }; + const data = { entries: [entry], next_sequence: entry.sequence, has_more: false }; + assert.deepEqual(decodeResponse(envelope({ kind: "logs", data }), "test", command), { + kind: "logs", + data, + }); + for (const change of [ + { entries: [entry, entry] }, + { entries: [{ ...entry, actor_ref: "ABCDEFGHIJKLMNOPQRSTU" }] }, + { entries: [{ ...entry, level: "unknown" }] }, + { next_sequence: entry.sequence + 1n }, + { entries: [], has_more: true, next_sequence: command.parameters.after }, + { has_more: "false" }, + ]) { + assert.throws(() => + decodeResponse(envelope({ kind: "logs", data: { ...data, ...change } }), "test", command), + ); + } +}); + test("notification envelopes preserve u64 revisions and reject malformed events", () => { const notification = (data: unknown) => json({ diff --git a/vscode/test/sources.test.ts b/vscode/test/sources.test.ts index 46e9134..46e75ec 100644 --- a/vscode/test/sources.test.ts +++ b/vscode/test/sources.test.ts @@ -189,7 +189,6 @@ test("new files are created with a null expected identity; invalid writes are re code("NoPermissions"), ); await assert.rejects(store.read("/source/actors/missing.rn"), code("FileNotFound")); - assert.throws(() => store.rejectMutation("Deleting"), code("NoPermissions")); assert.equal(image.writes.length, 0); });