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:
parent
938f447505
commit
318bb5dbd2
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -6,6 +6,11 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Changed
|
### 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,
|
- **Breaking:** Successful graceful stop and immediate kill now remove actors,
|
||||||
discarding their state, mailbox, and retained logs. Failed actors remain until
|
discarding their state, mailbox, and retained logs. Failed actors remain until
|
||||||
killed. Restart remains an independent, identity-preserving operation.
|
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
|
### 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
|
- Editable VS Code inspection JSON for inactive actors, restoring protected
|
||||||
fields with a warning; actor-reference copy actions and a scheduler toolbar button.
|
fields with a warning; actor-reference copy actions and a scheduler toolbar button.
|
||||||
- Read-only `ctx.this` actor reference for transactional self-messaging.
|
- Read-only `ctx.this` actor reference for transactional self-messaging.
|
||||||
|
|
|
||||||
25
README.md
25
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**
|
(F5). Start `mise run start`, then use **Runa: Connect to Local Image**
|
||||||
in the Extension Development Host. Open the **Runa** Activity Bar sidebar.
|
in the Extension Development Host. Open the **Runa** Activity Bar sidebar.
|
||||||
Its **Actors** view lists
|
Its **Actors** view lists
|
||||||
actors created through the REPL or another client. Click an actor to inspect
|
actors created through the REPL or another client as expandable directories.
|
||||||
it. Server notifications automatically refresh the tree and open snapshots;
|
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.
|
**Runa: Refresh Image** is also available on demand.
|
||||||
|
|
||||||
The **Image Sources** view in the Runa sidebar automatically shows the image's virtual
|
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
|
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
|
State** (which prompts for JSON), or to commit, browse history, and roll back. Historical revisions
|
||||||
remain read-only.
|
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.
|
Use **Runa: Spawn Actor** to choose a source path and supply JSON initial state.
|
||||||
Actor context menus offer structured messages and system lifecycle commands.
|
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.
|
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...**,
|
In VS Code, actor context menus offer **Edit Actor State...**,
|
||||||
**Edit Reserved and Queued Messages...**, and **Set Actor Log Level...**.
|
**Edit Reserved and Queued Messages...**, and **Set Actor Log Level...**.
|
||||||
You can also edit those fields directly in inspection JSON and save. Protected
|
The actor directory provides editable state/mailbox files and a `config.json`
|
||||||
fields are restored with a warning. The Actors toolbar advances the scheduler,
|
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.
|
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`,
|
Rune handlers can call `ctx.log("info", "message")`, or `ctx.trace`, `ctx.debug`,
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,48 @@ impl CodeStore {
|
||||||
self.working_sources.get(path)
|
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
|
/// Capture the current working source as an immutable commit without
|
||||||
/// compiling it.
|
/// compiling it.
|
||||||
pub fn commit_source(&mut self, path: &str) -> ExperimentResult<SourceCommit> {
|
pub fn commit_source(&mut self, path: &str) -> ExperimentResult<SourceCommit> {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ pub struct ImageApi {
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
actor_paths: BTreeMap<ActorRef, String>,
|
actor_paths: BTreeMap<ActorRef, String>,
|
||||||
pending_paths: BTreeMap<ActorRef, (String, ImplementationId)>,
|
pending_paths: BTreeMap<ActorRef, (String, ImplementationId)>,
|
||||||
|
temporary: BTreeMap<ActorRef, SourceFile>,
|
||||||
events: Vec<Event>,
|
events: Vec<Event>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ impl ImageApi {
|
||||||
runtime: Runtime::default(),
|
runtime: Runtime::default(),
|
||||||
actor_paths: BTreeMap::new(),
|
actor_paths: BTreeMap::new(),
|
||||||
pending_paths: BTreeMap::new(),
|
pending_paths: BTreeMap::new(),
|
||||||
|
temporary: BTreeMap::new(),
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +122,145 @@ impl ImageApi {
|
||||||
self.code.rollback_source(path, revision)
|
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(
|
pub fn spawn(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: &str,
|
path: &str,
|
||||||
|
|
@ -147,7 +288,9 @@ impl ImageApi {
|
||||||
.get(&actor)
|
.get(&actor)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or("actor has no source path")?;
|
.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> {
|
pub fn r#become(&mut self, actor: ActorRef, path: &str) -> RuntimeResult<ImplementationId> {
|
||||||
|
|
@ -168,9 +311,11 @@ impl ImageApi {
|
||||||
let artifact = self.code.compile(&source)?;
|
let artifact = self.code.compile(&source)?;
|
||||||
self.runtime.publish(Arc::clone(&artifact))?;
|
self.runtime.publish(Arc::clone(&artifact))?;
|
||||||
self.runtime
|
self.runtime
|
||||||
.schedule_install(actor, artifact.implementation_id)?;
|
.set_actor_behavior(actor, artifact.implementation_id)?;
|
||||||
self.pending_paths
|
self.pending_paths
|
||||||
.insert(actor, (path.to_owned(), artifact.implementation_id));
|
.insert(actor, (path.to_owned(), artifact.implementation_id));
|
||||||
|
self.temporary.remove(&actor);
|
||||||
|
self.collect_runtime_events();
|
||||||
Ok(artifact.implementation_id)
|
Ok(artifact.implementation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,6 +377,7 @@ impl ImageApi {
|
||||||
if let Event::Stopped(actor) = &event {
|
if let Event::Stopped(actor) = &event {
|
||||||
self.actor_paths.remove(actor);
|
self.actor_paths.remove(actor);
|
||||||
self.pending_paths.remove(actor);
|
self.pending_paths.remove(actor);
|
||||||
|
self.temporary.remove(actor);
|
||||||
}
|
}
|
||||||
if let Event::BehaviorInstalled(actor, implementation) = &event
|
if let Event::BehaviorInstalled(actor, implementation) = &event
|
||||||
&& self
|
&& self
|
||||||
|
|
@ -255,6 +401,13 @@ impl ImageApi {
|
||||||
if matches!(message, SystemMessage::Kill | SystemMessage::Restart) {
|
if matches!(message, SystemMessage::Kill | SystemMessage::Restart) {
|
||||||
self.pending_paths.remove(&actor);
|
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();
|
self.collect_runtime_events();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -414,6 +567,196 @@ mod tests {
|
||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn invalid_uncommitted_source_fails_spawn_without_creating_actor() -> RuntimeResult<()> {
|
fn invalid_uncommitted_source_fails_spawn_without_creating_actor() -> RuntimeResult<()> {
|
||||||
let mut image = ImageApi::new();
|
let mut image = ImageApi::new();
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,19 @@ impl Request {
|
||||||
}
|
}
|
||||||
|
|
||||||
match &self.command {
|
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 {
|
Command::EditActor {
|
||||||
actor_ref,
|
actor_ref,
|
||||||
state,
|
state,
|
||||||
|
|
@ -158,6 +171,30 @@ impl Request {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "operation", content = "parameters", rename_all = "snake_case")]
|
#[serde(tag = "operation", content = "parameters", rename_all = "snake_case")]
|
||||||
pub enum Command {
|
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 {
|
EditActor {
|
||||||
actor_ref: String,
|
actor_ref: String,
|
||||||
state: Option<Value>,
|
state: Option<Value>,
|
||||||
|
|
@ -281,6 +318,10 @@ pub struct Response {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
||||||
pub enum Reply {
|
pub enum Reply {
|
||||||
|
ActorBehavior {
|
||||||
|
source_id: String,
|
||||||
|
source: String,
|
||||||
|
},
|
||||||
Logs {
|
Logs {
|
||||||
entries: Vec<LogEntry>,
|
entries: Vec<LogEntry>,
|
||||||
next_sequence: u64,
|
next_sequence: u64,
|
||||||
|
|
@ -404,6 +445,12 @@ pub type MessageDto = Value;
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ActorSnapshotDto {
|
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 log_level: LogLevel,
|
||||||
pub actor_ref: String,
|
pub actor_ref: String,
|
||||||
pub mailbox_id: u64,
|
pub mailbox_id: u64,
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,30 @@ impl Runtime {
|
||||||
Ok(())
|
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.
|
/// Pause scheduling between worker ticks without discarding an invocation.
|
||||||
pub fn pause(&mut self, id: ActorRef) -> ExperimentResult<()> {
|
pub fn pause(&mut self, id: ActorRef) -> ExperimentResult<()> {
|
||||||
let actor = self.actors.get_mut(&id).ok_or("unknown actor")?;
|
let actor = self.actors.get_mut(&id).ok_or("unknown actor")?;
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,73 @@ impl Server {
|
||||||
|
|
||||||
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
|
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
|
||||||
match command {
|
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 {
|
Command::EditActor {
|
||||||
actor_ref,
|
actor_ref,
|
||||||
state,
|
state,
|
||||||
|
|
@ -247,12 +314,16 @@ impl Server {
|
||||||
let actor_ref = parse_actor_ref(&actor_ref)?;
|
let actor_ref = parse_actor_ref(&actor_ref)?;
|
||||||
self.image
|
self.image
|
||||||
.inspect(actor_ref)
|
.inspect(actor_ref)
|
||||||
.map(|snapshot| actor_snapshot_dto(&snapshot))
|
.map(|snapshot| actor_snapshot_dto(&snapshot, &self.image))
|
||||||
.map(Reply::Actor)
|
.map(Reply::Actor)
|
||||||
.map_err(api_error)
|
.map_err(api_error)
|
||||||
}
|
}
|
||||||
Command::ListActors => Ok(Reply::Actors(
|
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(
|
Command::GetEvents => Ok(Reply::Events(
|
||||||
self.image.events().iter().map(event_dto).collect(),
|
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 {
|
fn source_dto(file: runa_code::SourceFile) -> SourceFileDto {
|
||||||
SourceFileDto {
|
SourceFileDto {
|
||||||
path: file.path,
|
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 {
|
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,
|
log_level: snapshot.log_level,
|
||||||
actor_ref: snapshot.actor_ref.as_str().to_owned(),
|
actor_ref: snapshot.actor_ref.as_str().to_owned(),
|
||||||
mailbox_id: snapshot.mailbox.0,
|
mailbox_id: snapshot.mailbox.0,
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> {
|
||||||
Command::GetBehaviorSource { .. } | Command::InspectActor { .. } |
|
Command::GetBehaviorSource { .. } | Command::InspectActor { .. } |
|
||||||
Command::ListActors | Command::GetEvents | Command::ListSources |
|
Command::ListActors | Command::GetEvents | Command::ListSources |
|
||||||
Command::ReadSource { .. } | Command::SourceHistory { .. } |
|
Command::ReadSource { .. } | Command::SourceHistory { .. } |
|
||||||
Command::RevisionSource { .. } | Command::Logs { .. });
|
Command::RevisionSource { .. } | Command::ActorBehavior { .. } | Command::Logs { .. });
|
||||||
let response = server.dispatch(dispatch.request);
|
let response = server.dispatch(dispatch.request);
|
||||||
let changed = mutates && response.result.is_ok();
|
let changed = mutates && response.result.is_ok();
|
||||||
// Dispatch may commit even if the initiating client has disconnected.
|
// Dispatch may commit even if the initiating client has disconnected.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- Connection, actor explorer, state/mailbox inspection, spawn/send controls.
|
||||||
- Virtual behavior documents, compilation diagnostics, hot upgrades, runtime events.
|
- 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
|
are implemented in `vscode/`, along with spawn/send, lifecycle controls, and
|
||||||
explicit scheduler ticks, editable image source files, commit history, and
|
explicit scheduler ticks, editable image source files, commit history, and
|
||||||
immutable revision documents. Reload/become install scripts at safe boundaries.
|
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.
|
Structured compilation diagnostics and detailed runtime event subscriptions remain planned.
|
||||||
Non-destructive image-change notifications and automatic snapshot refresh are
|
Non-destructive image-change notifications and automatic snapshot refresh are
|
||||||
implemented over the shared `runa-debug` WebSocket transport.
|
implemented over the shared `runa-debug` WebSocket transport.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Clap parses `--listen` / `RUNA_LISTEN` on the server and `--connect` /
|
||||||
The listener is unauthenticated and intended only for
|
The listener is unauthenticated and intended only for
|
||||||
trusted local development. Both the REPL and VS Code use WebSockets.
|
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
|
Every request and response carries `version: 5`. Requests also carry a
|
||||||
non-empty `request_id` used to correlate the response. A server must reject
|
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 |
|
| `list_sources` | none | `sources`: file array |
|
||||||
| `read_source` | `path` | `source`: file |
|
| `read_source` | `path` | `source`: file |
|
||||||
| `write_source` | `path`, `expected_source_id` (null to create), `source` | `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 |
|
| `commit_source` | `path` | `source_committed`: commit |
|
||||||
| `source_history` | `path` | `source_history`: commit array |
|
| `source_history` | `path` | `source_history`: commit array |
|
||||||
| `revision_source` | `path`, `revision` | `behavior_source`: `{source}` |
|
| `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
|
without appending history. Spawn compiles current working source even when
|
||||||
uncommitted; compilation failures do not spawn an actor.
|
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"}`
|
System message shape is `{kind: "stop"|"kill"|"restart"|"pause"|"resume"|"reload"}`
|
||||||
or `{kind: "become", path: "actors/other"}`. This is a trusted host request;
|
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
|
actor `ctx.send` and `ctx.send_from` accept only ordinary data. Stop runs optional
|
||||||
|
|
|
||||||
|
|
@ -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.
|
latest working source at the actor's current path; become chooses another path.
|
||||||
Compilation failure preserves its current implementation and source binding.
|
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
|
Runtime callers that need a state transformation can use
|
||||||
`install_with_migration`, which receives the committed state and computes its
|
`install_with_migration`, which receives the committed state and computes its
|
||||||
replacement before changing either state or implementation. The operation
|
replacement before changing either state or implementation. The operation
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ In that host:
|
||||||
`actors/counter`). **Runa: Spawn Actor** in **Actors** prompts for a source
|
`actors/counter`). **Runa: Spawn Actor** in **Actors** prompts for a source
|
||||||
path and state. The command reports and returns the new actor reference.
|
path and state. The command reports and returns the new actor reference.
|
||||||
Mailbox capacity is 64.
|
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,
|
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
|
resume, stop, kill, restart, reload, or become another source path. These
|
||||||
actions are also available in the Command Palette with an actor picker.
|
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
|
## 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.
|
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
|
- Inspect fetches a fresh snapshot through `inspect_actor`. Documents show
|
||||||
committed state, FIFO queued messages, the reserved message, status, and
|
committed state, FIFO queued messages, the reserved message, status, and
|
||||||
active-execution telemetry. Tagged values distinguish actor references from
|
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
|
reserved message. Clearing the reservation removes the failed input that would
|
||||||
otherwise retry on restart. Edits do not resume actors. **Set Actor Log Level...**
|
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.
|
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,
|
The dedicated **Runa** Activity Bar sidebar contains **Image Sources** first,
|
||||||
followed by **Actors**. Both views are visible by default and do not require
|
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
|
- **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
|
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
|
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.
|
never commits. **New Source File...** in the toolbar or a directory context
|
||||||
Deleting and renaming are not supported.
|
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
|
- **Spawn** uses the latest saved working source, committed or not. If the
|
||||||
editor has unsaved changes, spawn and commit ask to save first; cancelling
|
editor has unsaved changes, spawn and commit ask to save first; cancelling
|
||||||
does nothing. Unsaved editor text is never sent implicitly.
|
does nothing. Unsaved editor text is never sent implicitly.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import assert from "node:assert/strict";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { control, spawnCounter, startImage } from "./server";
|
import { control, spawnCounter, startImage } from "./server";
|
||||||
import { ImageSourceTree, RevisionDocuments } from "../src/sourceProvider";
|
import { ImageSourceTree, RevisionDocuments } from "../src/sourceProvider";
|
||||||
|
import { actorFileUri } from "../src/actorFiles";
|
||||||
|
import type { activate } from "../src/extension";
|
||||||
|
|
||||||
export async function run(): Promise<void> {
|
export async function run(): Promise<void> {
|
||||||
const image = await startImage();
|
const image = await startImage();
|
||||||
|
|
@ -36,6 +38,7 @@ export async function run(): Promise<void> {
|
||||||
folders,
|
folders,
|
||||||
);
|
);
|
||||||
await editSources(image.address);
|
await editSources(image.address);
|
||||||
|
await editActorFiles(image.address);
|
||||||
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
|
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
|
||||||
path: "actors/counter",
|
path: "actors/counter",
|
||||||
state: "5",
|
state: "5",
|
||||||
|
|
@ -444,6 +447,209 @@ async function replaceText(document: vscode.TextDocument, text: string): Promise
|
||||||
assert.ok(await vscode.workspace.applyEdit(edit));
|
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(
|
function documentChanged(
|
||||||
document: vscode.TextDocument,
|
document: vscode.TextDocument,
|
||||||
expected: (text: string) => boolean,
|
expected: (text: string) => boolean,
|
||||||
|
|
@ -461,7 +667,11 @@ function documentChanged(
|
||||||
});
|
});
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
subscription.dispose();
|
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);
|
}, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,5 +232,9 @@ test(
|
||||||
sequences,
|
sequences,
|
||||||
Array.from({ length: 300 }, (_, index) => index + 1),
|
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)),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,96 @@ import { startImage } from "./server";
|
||||||
|
|
||||||
const conflict = (error: unknown) => error instanceof ProtocolError && error.code === "conflict";
|
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(
|
test(
|
||||||
"image sources are saved with compare-and-swap, spawned uncommitted, committed, and rolled back",
|
"image sources are saved with compare-and-swap, spawned uncommitted, committed, and rolled back",
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
],
|
],
|
||||||
"main": "./out/src/extension.js",
|
"main": "./out/src/extension.js",
|
||||||
"activationEvents": [
|
"activationEvents": [
|
||||||
|
"onFileSystem:runa-actor",
|
||||||
"onView:runa.actors",
|
"onView:runa.actors",
|
||||||
"onView:runa.sources",
|
"onView:runa.sources",
|
||||||
"onFileSystem:runa-image"
|
"onFileSystem:runa-image"
|
||||||
|
|
@ -38,7 +39,55 @@
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"contributes": {
|
"contributes": {
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"id": "rune",
|
||||||
|
"aliases": [
|
||||||
|
"Rune"
|
||||||
|
],
|
||||||
|
"extensions": [
|
||||||
|
".rn"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"commands": [
|
"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",
|
"command": "runa.editState",
|
||||||
"title": "Edit Actor State...",
|
"title": "Edit Actor State...",
|
||||||
|
|
@ -215,6 +264,11 @@
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
"view/title": [
|
"view/title": [
|
||||||
|
{
|
||||||
|
"command": "runa.newSource",
|
||||||
|
"when": "view == runa.sources && runa.connected",
|
||||||
|
"group": "navigation"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.tick",
|
"command": "runa.tick",
|
||||||
"when": "view == runa.actors && runa.connected",
|
"when": "view == runa.actors && runa.connected",
|
||||||
|
|
@ -251,6 +305,36 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"view/item/context": [
|
"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",
|
"command": "runa.copyActorReference",
|
||||||
"when": "view == runa.actors && viewItem == runa.actor",
|
"when": "view == runa.actors && viewItem == runa.actor",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
Value,
|
Value,
|
||||||
WEBSOCKET_SUBPROTOCOL,
|
WEBSOCKET_SUBPROTOCOL,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
|
LogEntry,
|
||||||
LOG_LEVELS,
|
LOG_LEVELS,
|
||||||
} from "./protocol";
|
} from "./protocol";
|
||||||
|
|
||||||
|
|
@ -125,6 +126,89 @@ export class ImageClient {
|
||||||
return reply.data;
|
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> {
|
async inspectActor(actorRef: string): Promise<ActorSnapshot> {
|
||||||
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
|
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
|
||||||
const reply = await this.request({
|
const reply = await this.request({
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import {
|
||||||
import { RefreshQueue } from "./refresh";
|
import { RefreshQueue } from "./refresh";
|
||||||
import { parseValue, formatValue } from "./values";
|
import { parseValue, formatValue } from "./values";
|
||||||
import { SnapshotDocuments } from "./snapshotProvider";
|
import { SnapshotDocuments } from "./snapshotProvider";
|
||||||
import { canEditActor } from "./snapshotEdits";
|
import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits";
|
||||||
|
import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles";
|
||||||
import {
|
import {
|
||||||
ImageSourceFileSystem,
|
ImageSourceFileSystem,
|
||||||
ImageSourceTree,
|
ImageSourceTree,
|
||||||
|
|
@ -28,22 +29,40 @@ export async function deactivate(): Promise<void> {
|
||||||
await closeConnections?.();
|
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>();
|
private readonly changes = new vscode.EventEmitter<void>();
|
||||||
readonly onDidChangeTreeData = this.changes.event;
|
readonly onDidChangeTreeData = this.changes.event;
|
||||||
actors: ActorSnapshot[] = [];
|
actors: ActorSnapshot[] = [];
|
||||||
|
session = 0;
|
||||||
|
|
||||||
replace(actors: ActorSnapshot[]): void {
|
replace(actors: ActorSnapshot[]): void {
|
||||||
this.actors = actors;
|
this.actors = actors;
|
||||||
this.changes.fire();
|
this.changes.fire();
|
||||||
}
|
}
|
||||||
|
|
||||||
getChildren(): ActorSnapshot[] {
|
getChildren(parent?: ActorNode): ActorNode[] {
|
||||||
return this.actors;
|
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 {
|
getTreeItem(actor: ActorNode): vscode.TreeItem {
|
||||||
const item = new vscode.TreeItem(`@${actor.actor_ref}`);
|
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.id = actor.actor_ref;
|
||||||
item.contextValue = "runa.actor";
|
item.contextValue = "runa.actor";
|
||||||
item.description = `${actor.status.status}, queued ${actor.queued.length}${actor.reserved ? ", reserved 1" : ""}`;
|
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}`,
|
`Incarnation: ${actor.incarnation}`,
|
||||||
`Implementation: ${actor.implementation_id}`,
|
`Implementation: ${actor.implementation_id}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
item.iconPath = new vscode.ThemeIcon(
|
item.iconPath = new vscode.ThemeIcon(actor.status.status === "failed" ? "error" : "folder");
|
||||||
actor.status.status === "failed" ? "error" : "symbol-interface",
|
|
||||||
);
|
|
||||||
item.command = {
|
|
||||||
command: "runa.inspect",
|
|
||||||
title: "Inspect Actor",
|
|
||||||
arguments: [actor],
|
|
||||||
};
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +84,10 @@ class ActorTree implements vscode.TreeDataProvider<ActorSnapshot> {
|
||||||
|
|
||||||
type SourceTarget = vscode.Uri | { path: string } | { uri: vscode.Uri } | string | undefined;
|
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 tree = new ActorTree();
|
||||||
const documents = new SnapshotDocuments(
|
const documents = new SnapshotDocuments(
|
||||||
async (savedSession, actorRef, edit) => {
|
async (savedSession, actorRef, edit) => {
|
||||||
|
|
@ -99,6 +114,10 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const sources = new ImageSourceFileSystem();
|
const sources = new ImageSourceFileSystem();
|
||||||
|
const actorFiles = new ActorFiles(
|
||||||
|
() => active(),
|
||||||
|
() => refresh(),
|
||||||
|
);
|
||||||
const sourceTree = new ImageSourceTree(sources);
|
const sourceTree = new ImageSourceTree(sources);
|
||||||
const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree });
|
const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree });
|
||||||
const revisions = new RevisionDocuments();
|
const revisions = new RevisionDocuments();
|
||||||
|
|
@ -151,6 +170,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
const previous = client;
|
const previous = client;
|
||||||
client = undefined;
|
client = undefined;
|
||||||
documents.disconnect(session);
|
documents.disconnect(session);
|
||||||
|
actorFiles.disconnect(session);
|
||||||
sources.store.detach();
|
sources.store.detach();
|
||||||
session++;
|
session++;
|
||||||
const closed = closeClient(previous);
|
const closed = closeClient(previous);
|
||||||
|
|
@ -167,7 +187,9 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
if (current !== client || currentSession !== session) return;
|
if (current !== client || currentSession !== session) return;
|
||||||
await sources.store.refresh();
|
await sources.store.refresh();
|
||||||
if (current !== client || currentSession !== session) return;
|
if (current !== client || currentSession !== session) return;
|
||||||
|
tree.session = session;
|
||||||
tree.replace(actors);
|
tree.replace(actors);
|
||||||
|
actorFiles.refreshActors(session, actors);
|
||||||
sourceTree.refresh();
|
sourceTree.refresh();
|
||||||
documents.refresh(session, endpoint, actors);
|
documents.refresh(session, endpoint, actors);
|
||||||
view.message = actors.length
|
view.message = actors.length
|
||||||
|
|
@ -213,6 +235,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
updates.dispose();
|
updates.dispose();
|
||||||
tree.replace([]);
|
tree.replace([]);
|
||||||
documents.disconnect(currentSession);
|
documents.disconnect(currentSession);
|
||||||
|
actorFiles.disconnect(currentSession);
|
||||||
sources.store.detach();
|
sources.store.detach();
|
||||||
sourceTree.refresh();
|
sourceTree.refresh();
|
||||||
connectionStatus(false);
|
connectionStatus(false);
|
||||||
|
|
@ -543,19 +566,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
if (!canEditActor(actor)) {
|
if (!canEditActor(actor)) {
|
||||||
throw new Error("Editing requires a paused or failed actor with no active invocation");
|
throw new Error("Editing requires a paused or failed actor with no active invocation");
|
||||||
}
|
}
|
||||||
const current =
|
const current = kind === "state" ? actor.state : mailboxValue(actor);
|
||||||
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 input =
|
const input =
|
||||||
typeof source === "string"
|
typeof source === "string"
|
||||||
? source
|
? source
|
||||||
|
|
@ -566,19 +577,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
if (kind === "state") {
|
if (kind === "state") {
|
||||||
await connection.client.editActor(actorRef, value, null);
|
await connection.client.editActor(actorRef, value, null);
|
||||||
} else {
|
} else {
|
||||||
if (
|
await connection.client.editActor(actorRef, null, mailboxEdit(value));
|
||||||
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 refresh();
|
await refresh();
|
||||||
output.appendLine(`Updated ${kind} for @${actorRef}`);
|
output.appendLine(`Updated ${kind} for @${actorRef}`);
|
||||||
|
|
@ -642,10 +641,113 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
try {
|
try {
|
||||||
return await action(...args);
|
return await action(...args);
|
||||||
} catch (error) {
|
} 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) =>
|
const systemCommand = (kind: SystemKind) =>
|
||||||
command(`runa.${kind}`, (selected?: { actor_ref: string }) => system(kind, selected));
|
command(`runa.${kind}`, (selected?: { actor_ref: string }) => system(kind, selected));
|
||||||
|
|
||||||
|
|
@ -656,13 +758,18 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
sourceView,
|
sourceView,
|
||||||
revisions,
|
revisions,
|
||||||
documents,
|
documents,
|
||||||
|
actorFiles,
|
||||||
view,
|
view,
|
||||||
output,
|
output,
|
||||||
status,
|
status,
|
||||||
vscode.workspace.registerFileSystemProvider(SOURCE_SCHEME, sources, { isCaseSensitive: true }),
|
vscode.workspace.registerFileSystemProvider(SOURCE_SCHEME, sources, { isCaseSensitive: true }),
|
||||||
vscode.workspace.registerFileSystemProvider("runa", documents, { isCaseSensitive: true }),
|
vscode.workspace.registerFileSystemProvider("runa", documents, { isCaseSensitive: true }),
|
||||||
|
vscode.workspace.registerFileSystemProvider(ACTOR_SCHEME, actorFiles, {
|
||||||
|
isCaseSensitive: true,
|
||||||
|
}),
|
||||||
vscode.workspace.onDidSaveTextDocument((document) => {
|
vscode.workspace.onDidSaveTextDocument((document) => {
|
||||||
documents.didSave(document);
|
documents.didSave(document);
|
||||||
|
actorFiles.didSave(document);
|
||||||
}),
|
}),
|
||||||
vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions),
|
vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions),
|
||||||
vscode.workspace.onDidCloseTextDocument((document) => {
|
vscode.workspace.onDidCloseTextDocument((document) => {
|
||||||
|
|
@ -672,6 +779,66 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
command("runa.disconnect", disconnect),
|
command("runa.disconnect", disconnect),
|
||||||
command("runa.refresh", refresh),
|
command("runa.refresh", refresh),
|
||||||
command("runa.inspect", inspect),
|
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 }) => {
|
command("runa.copyActorReference", async (selected?: { actor_ref: string }) => {
|
||||||
const actorRef = await chooseActor(selected);
|
const actorRef = await chooseActor(selected);
|
||||||
if (actorRef !== undefined) await vscode.env.clipboard.writeText(actorRef);
|
if (actorRef !== undefined) await vscode.env.clipboard.writeText(actorRef);
|
||||||
|
|
@ -708,5 +875,5 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
);
|
);
|
||||||
connectionStatus(false);
|
connectionStatus(false);
|
||||||
status.show();
|
status.show();
|
||||||
return { sourceTree };
|
return { sourceTree, actorTree: tree };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ export type Value =
|
||||||
| { type: "record"; data: Record<string, Value> };
|
| { type: "record"; data: Record<string, Value> };
|
||||||
|
|
||||||
export interface ActorSnapshot {
|
export interface ActorSnapshot {
|
||||||
|
behavior_path?: string | null;
|
||||||
|
pending_behavior_path?: string | null;
|
||||||
|
temporary_behavior?: boolean;
|
||||||
log_level: LogLevel;
|
log_level: LogLevel;
|
||||||
actor_ref: string;
|
actor_ref: string;
|
||||||
mailbox_id: bigint;
|
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 type SystemMessage = { kind: SystemKind } | { kind: "become"; path: string };
|
||||||
export const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
|
export const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
|
||||||
export type LogLevel = (typeof LOG_LEVELS)[number];
|
export type LogLevel = (typeof LOG_LEVELS)[number];
|
||||||
|
export interface LogEntry {
|
||||||
|
sequence: bigint;
|
||||||
|
actor_ref: string;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SourceFile {
|
export interface SourceFile {
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -50,6 +59,31 @@ export interface SourceCommit {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Command =
|
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: "set_log_level"; parameters: { actor_ref: string; level: LogLevel } }
|
||||||
| {
|
| {
|
||||||
operation: "edit_actor";
|
operation: "edit_actor";
|
||||||
|
|
@ -204,6 +238,22 @@ function decodeActor(input: unknown): ActorSnapshot {
|
||||||
const level = LOG_LEVELS.find((level) => level === actor.log_level);
|
const level = LOG_LEVELS.find((level) => level === actor.log_level);
|
||||||
if (level === undefined) throw new Error("Invalid actor log level");
|
if (level === undefined) throw new Error("Invalid actor log level");
|
||||||
return {
|
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,
|
log_level: level,
|
||||||
actor_ref: actor.actor_ref,
|
actor_ref: actor.actor_ref,
|
||||||
mailbox_id: uint(actor.mailbox_id),
|
mailbox_id: uint(actor.mailbox_id),
|
||||||
|
|
@ -238,6 +288,8 @@ function decodeSourceCommit(input: unknown): SourceCommit {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Reply =
|
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: "actors"; data: ActorSnapshot[] }
|
||||||
| { kind: "actor"; data: ActorSnapshot }
|
| { kind: "actor"; data: ActorSnapshot }
|
||||||
| { kind: "sources"; data: SourceFile[] }
|
| { kind: "sources"; data: SourceFile[] }
|
||||||
|
|
@ -299,6 +351,59 @@ export function decodeResponse(line: string, requestId: string, command: Command
|
||||||
throw new ProtocolError(code, text(error.message));
|
throw new ProtocolError(code, text(error.message));
|
||||||
}
|
}
|
||||||
const reply = object(result.Ok);
|
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") {
|
if (command.operation === "revision_source" && reply.kind === "behavior_source") {
|
||||||
return { kind: "behavior_source", data: { source: text(object(reply.data).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 === "send" ||
|
||||||
command.operation === "system" ||
|
command.operation === "system" ||
|
||||||
command.operation === "set_log_level" ||
|
command.operation === "set_log_level" ||
|
||||||
|
command.operation === "delete_source" ||
|
||||||
command.operation === "edit_actor") &&
|
command.operation === "edit_actor") &&
|
||||||
reply.kind === "acknowledged"
|
reply.kind === "acknowledged"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,28 @@ export interface SnapshotEdit {
|
||||||
discarded: string[];
|
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 {
|
export function parseSnapshotEdit(source: string, original: string): SnapshotEdit {
|
||||||
if (Buffer.byteLength(source) > MAX_MESSAGE_BYTES) throw new Error("Snapshot exceeds 1 MiB");
|
if (Buffer.byteLength(source) > MAX_MESSAGE_BYTES) throw new Error("Snapshot exceeds 1 MiB");
|
||||||
const read = (text: string): Record<string, unknown> => {
|
const read = (text: string): Record<string, unknown> => {
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ export class ImageSourceFileSystem implements vscode.FileSystemProvider {
|
||||||
private readonly changes = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
|
private readonly changes = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
|
||||||
readonly onDidChangeFile = this.changes.event;
|
readonly onDidChangeFile = this.changes.event;
|
||||||
readonly store: SourceStore;
|
readonly store: SourceStore;
|
||||||
|
mutations?: {
|
||||||
|
delete(uri: vscode.Uri): Promise<void>;
|
||||||
|
rename(from: vscode.Uri, to: vscode.Uri): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.store = new SourceStore((changes) => this.fire(changes));
|
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));
|
await this.runAsync(uri, () => this.store.write(uri.path, content, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(uri: vscode.Uri): void {
|
async delete(uri: vscode.Uri): Promise<void> {
|
||||||
this.run(uri, () => this.store.rejectMutation("Deleting"));
|
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 {
|
async rename(oldUri: vscode.Uri, newUri: vscode.Uri): Promise<void> {
|
||||||
this.run(oldUri, () => this.store.rejectMutation("Renaming"));
|
await this.runAsync(oldUri, () => {
|
||||||
|
if (!this.mutations)
|
||||||
|
throw vscode.FileSystemError.Unavailable("Source operations are not connected");
|
||||||
|
return this.mutations.rename(oldUri, newUri);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
|
|
||||||
|
|
@ -284,13 +284,6 @@ export class SourceStore {
|
||||||
if (changes.length) this.emit(changes);
|
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 {
|
private filePath(uriPath: string, parse = parseSourceUriPath): string {
|
||||||
const entry = parse(uriPath);
|
const entry = parse(uriPath);
|
||||||
if (entry.kind !== "file") {
|
if (entry.kind !== "file") {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ export const REF = "abcdefghijklmnopqrstu";
|
||||||
|
|
||||||
export function actor(): ActorSnapshot {
|
export function actor(): ActorSnapshot {
|
||||||
return {
|
return {
|
||||||
|
behavior_path: null,
|
||||||
|
pending_behavior_path: null,
|
||||||
|
temporary_behavior: false,
|
||||||
log_level: "info",
|
log_level: "info",
|
||||||
actor_ref: REF,
|
actor_ref: REF,
|
||||||
mailbox_id: 18446744073709551615n,
|
mailbox_id: 18446744073709551615n,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,50 @@ const command: Command = { operation: "list_actors" };
|
||||||
const envelope = (reply: unknown) =>
|
const envelope = (reply: unknown) =>
|
||||||
json({ version: 5, request_id: "test", result: { Ok: reply } });
|
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", () => {
|
test("notification envelopes preserve u64 revisions and reject malformed events", () => {
|
||||||
const notification = (data: unknown) =>
|
const notification = (data: unknown) =>
|
||||||
json({
|
json({
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,6 @@ test("new files are created with a null expected identity; invalid writes are re
|
||||||
code("NoPermissions"),
|
code("NoPermissions"),
|
||||||
);
|
);
|
||||||
await assert.rejects(store.read("/source/actors/missing.rn"), code("FileNotFound"));
|
await assert.rejects(store.read("/source/actors/missing.rn"), code("FileNotFound"));
|
||||||
assert.throws(() => store.rejectMutation("Deleting"), code("NoPermissions"));
|
|
||||||
assert.equal(image.writes.length, 0);
|
assert.equal(image.writes.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue