feat: add actor file trees and temporary behavior editing

Expose actor metadata, live configuration, natural state/mailbox edits and retained logs as virtual files. Add guarded image source creation, rename/move and deletion with actor path tracking.

Compile actor-local temporary behavior edits at safe boundaries, preserve them across restart, clear them on reload, and confirm copying them to shared source. Cover protocol, runtime and actual VS Code host workflows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 20:11:39 +01:00
parent 938f447505
commit 318bb5dbd2
26 changed files with 1842 additions and 73 deletions

View File

@ -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.

View File

@ -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`,

View File

@ -210,6 +210,48 @@ impl CodeStore {
self.working_sources.get(path)
}
pub fn rename_source(
&mut self,
path: &str,
target: &str,
expected: SourceId,
) -> ExperimentResult<SourceFile> {
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<SourceCommit> {

View File

@ -14,6 +14,7 @@ pub struct ImageApi {
runtime: Runtime,
actor_paths: BTreeMap<ActorRef, String>,
pending_paths: BTreeMap<ActorRef, (String, ImplementationId)>,
temporary: BTreeMap<ActorRef, SourceFile>,
events: Vec<Event>,
}
@ -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<ActorRef> {
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<SourceFile> {
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<SourceFile> {
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<SourceFile> {
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<SourceFile> {
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<ImplementationId> {
@ -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();

View File

@ -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<Value>,
@ -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<LogEntry>,
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<String>,
#[serde(default)]
pub behavior_path: Option<String>,
#[serde(default)]
pub temporary_behavior: bool,
pub log_level: LogLevel,
pub actor_ref: String,
pub mailbox_id: u64,

View File

@ -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")?;

View File

@ -65,6 +65,73 @@ impl Server {
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
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<runa_code::SourceFile, ProtocolError> {
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,

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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<void> {
const image = await startImage();
@ -36,6 +38,7 @@ export async function run(): Promise<void> {
folders,
);
await editSources(image.address);
await editActorFiles(image.address);
const spawned = await vscode.commands.executeCommand<unknown>("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<void> {
const extension = vscode.extensions.all.find(
(candidate) => candidate.packageJSON.name === "runa",
);
assert.ok(extension);
const { actorTree, sourceTree } = extension.exports as ReturnType<typeof activate>;
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<string>("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);
});
}

View File

@ -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)),
);
},
);

View File

@ -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 },

View File

@ -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",

278
vscode/src/actorFiles.ts Normal file
View File

@ -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<vscode.FileChangeEvent[]>();
readonly onDidChangeFile = this.changes.event;
private readonly actors = new Map<string, Entry>();
private readonly behaviorIds = new Map<string, string>();
private readonly textCache = new Map<string, Uint8Array>();
constructor(
private readonly connection: () => Connection,
private readonly refresh: () => Promise<void>,
) {}
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<Uint8Array> {
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<T>(uri: vscode.Uri, read: () => Promise<T>): Promise<T | undefined> {
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<void> {
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();
}
}

View File

@ -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<SourceFile> {
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<SourceFile> {
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<void> {
await this.request({
operation: "delete_source",
parameters: { path: sourcePath(path), expected_source_id: expected },
});
}
async logs(actorRef: string): Promise<LogEntry[]> {
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<ActorSnapshot> {
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
const reply = await this.request({

View File

@ -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<void> {
await closeConnections?.();
}
class ActorTree implements vscode.TreeDataProvider<ActorSnapshot> {
type ActorNode = ActorSnapshot | { actor_ref: string; file: string };
class ActorTree implements vscode.TreeDataProvider<ActorNode> {
private readonly changes = new vscode.EventEmitter<void>();
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<ActorSnapshot> {
`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<ActorSnapshot> {
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<vscode.Uri | undefined> => {
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 };
}

View File

@ -14,6 +14,9 @@ export type Value =
| { type: "record"; data: Record<string, Value> };
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"
)

View File

@ -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<SnapshotEdit["messages"]> {
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<string, unknown> => {

View File

@ -79,6 +79,10 @@ export class ImageSourceFileSystem implements vscode.FileSystemProvider {
private readonly changes = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
readonly onDidChangeFile = this.changes.event;
readonly store: SourceStore;
mutations?: {
delete(uri: vscode.Uri): Promise<void>;
rename(from: vscode.Uri, to: vscode.Uri): Promise<void>;
};
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<void> {
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<void> {
await this.runAsync(oldUri, () => {
if (!this.mutations)
throw vscode.FileSystemError.Unavailable("Source operations are not connected");
return this.mutations.rename(oldUri, newUri);
});
}
dispose(): void {

View File

@ -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") {

View File

@ -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,

View File

@ -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({

View File

@ -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);
});