feat: add loaded-source execution stack inspection

Expose bounded active Rune frames and exact installed source through read-only image and protocol inspection. Add immutable VS Code source navigation and lifecycle, await, and integration coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 20:37:05 +01:00
parent 7e25cb8ef6
commit b8080fe02e
24 changed files with 1018 additions and 20 deletions

View File

@ -56,6 +56,11 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Read-only execution-stack inspection with exact loaded source identities,
bounded nested Rune frames, UTF-16 locations, and explicit pending-await status.
- VS Code **Inspect Execution Stack...** actor action, navigating frames in
immutable loaded-source documents even after shared or temporary source edits.
- Structured Rune compilation errors and warnings through a read-only - Structured Rune compilation errors and warnings through a read-only
`check_source` protocol operation and compilation-failure error details. `check_source` protocol operation and compilation-failure error details.
- Automatic VS Code inline/Problems diagnostics for open image sources and - Automatic VS Code inline/Problems diagnostics for open image sources and

View File

@ -30,8 +30,10 @@ Server change notifications automatically refresh VS Code's actor tree and inspe
The prototype uses worker-local invocation stacks and cooperative instruction The prototype uses worker-local invocation stacks and cooperative instruction
slices, and actor snapshots report basic active-execution progress. Native slices, and actor snapshots report basic active-execution progress. Native
callbacks are not preemptible; source-level debugging and persistent storage callbacks are not preemptible. The debugger foundation exposes bounded active Rune
are not implemented. stack frames and exact loaded source through read-only protocol inspection and
VS Code's **Inspect Execution Stack...** actor action. Stepping, breakpoints,
locals, and persistent storage remain unimplemented.
## Design goals ## Design goals

View File

@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
use crate::SourceRange;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StackFrameKind {
Current,
Caller,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StackFrame {
pub kind: StackFrameKind,
/// Current instruction or caller's saved return address.
pub instruction_pointer: usize,
/// Instruction mapped to source (the call instruction for a caller).
pub source_instruction_pointer: Option<usize>,
pub function: Option<String>,
pub range: Option<SourceRange>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionInspection {
pub vm_steps: u64,
pub awaiting: bool,
pub call_depth: usize,
pub frames: Vec<StackFrame>,
pub frames_truncated: bool,
}

View File

@ -3,9 +3,11 @@
use std::fmt; use std::fmt;
mod diagnostic; mod diagnostic;
mod execution;
mod log; mod log;
mod value; mod value;
pub use diagnostic::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange}; pub use diagnostic::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
pub use execution::{ExecutionInspection, StackFrame, StackFrameKind};
pub use log::{LogEntry, LogLevel}; pub use log::{LogEntry, LogLevel};
pub use value::{ActorRef, MAX_VALUE_BYTES, MAX_VALUE_DEPTH, MAX_VALUE_NODES, Value}; pub use value::{ActorRef, MAX_VALUE_BYTES, MAX_VALUE_DEPTH, MAX_VALUE_NODES, Value};

View File

@ -18,6 +18,16 @@ pub struct ImageApi {
events: Vec<Event>, events: Vec<Event>,
} }
pub struct ExecutionInspection {
pub actor_ref: ActorRef,
pub incarnation: u64,
pub implementation_id: ImplementationId,
pub source_id: runa_core::SourceId,
pub source: String,
pub behavior_path: Option<String>,
pub execution: Option<runa_core::ExecutionInspection>,
}
impl Default for ImageApi { impl Default for ImageApi {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@ -338,6 +348,23 @@ impl ImageApi {
self.runtime.inspect(actor) self.runtime.inspect(actor)
} }
pub fn inspect_execution(&self, actor: ActorRef) -> RuntimeResult<ExecutionInspection> {
let snapshot = self.runtime.inspect(actor)?;
let artifact = self
.code
.artifact(snapshot.implementation)
.ok_or("behavior artifact is unavailable")?;
Ok(ExecutionInspection {
actor_ref: actor,
incarnation: snapshot.incarnation,
implementation_id: snapshot.implementation,
source_id: artifact.source_id,
source: artifact.original_source().to_owned(),
behavior_path: self.actor_path(actor).map(str::to_owned),
execution: self.runtime.inspect_execution(actor)?,
})
}
pub fn edit_actor( pub fn edit_actor(
&mut self, &mut self,
actor: ActorRef, actor: ActorRef,
@ -571,6 +598,113 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn execution_inspection_keeps_exact_loaded_source_across_edits_pause_and_reload()
-> RuntimeResult<()> {
let mut image = ImageApi::new();
let original = "pub fn handle(ctx, state, sender, message) {\n nested()\n}\nfn nested() { loop {} }";
let file = image.write_source("actors/stack", None, original)?;
let actor = image.spawn(&file.path, Value::Null, capacity(2))?;
assert!(image.inspect_execution(actor)?.execution.is_none());
image.send(actor, Value::Null)?;
image.tick(1, capacity(10));
let before = image.inspect_execution(actor)?;
assert_eq!(before.source, original);
assert_eq!(before.source_id, file.source_id);
assert_eq!(before.behavior_path.as_deref(), Some("actors/stack"));
let frames = before.execution.as_ref().unwrap();
assert_eq!(frames.frames[0].function.as_deref(), Some("nested"));
assert_eq!(frames.frames[0].range.unwrap().start.line, 3);
let updated = image.write_source(
&file.path,
Some(file.source_id),
"pub fn handle(ctx, state, sender, message) { 1 }",
)?;
image.system(actor, SystemMessage::Pause)?;
image.tick(1, capacity(1));
let paused = image.inspect_execution(actor)?.execution.unwrap();
image.tick(2, capacity(100));
assert_eq!(image.inspect_execution(actor)?.execution, Some(paused));
image.reload(actor)?;
let pending = image.inspect_execution(actor)?;
assert_eq!(pending.implementation_id, before.implementation_id);
assert_eq!(pending.source, original);
image.system(actor, SystemMessage::Restart)?;
assert!(image.inspect_execution(actor)?.execution.is_none());
assert_eq!(image.inspect_execution(actor)?.source, original);
image.reload(actor)?;
image.tick(2, capacity(100));
let after = image.inspect_execution(actor)?;
assert_eq!(after.source, updated.source);
assert_eq!(after.source_id, updated.source_id);
assert!(after.execution.is_none());
assert_eq!(after.incarnation, before.incarnation + 1);
image.system(actor, SystemMessage::Kill)?;
assert!(image.inspect_execution(actor).is_err());
Ok(())
}
#[test]
fn execution_inspection_distinguishes_installed_and_pending_temporary_source()
-> RuntimeResult<()> {
let mut image = ImageApi::new();
let original = image.write_source(
"actors/temp_stack",
None,
"pub fn handle(ctx, state, sender, message) { state }",
)?;
let actor = image.spawn(&original.path, Value::Null, capacity(2))?;
let temporary = image.write_actor_behavior(
actor,
original.source_id.to_string(),
"pub fn handle(ctx, state, sender, message) { loop {} }",
)?;
assert_eq!(
image.inspect_execution(actor)?.source_id,
original.source_id
);
image.send(actor, Value::Null)?;
image.tick(1, capacity(10));
let running = image.inspect_execution(actor)?;
assert_eq!(running.source_id, temporary.source_id);
assert_eq!(running.source, temporary.source);
assert!(running.execution.is_some());
let replacement = image.write_actor_behavior(
actor,
temporary.source_id.to_string(),
"pub fn handle(ctx, state, sender, message) { 42 }",
)?;
let pending = image.inspect_execution(actor)?;
assert_eq!(pending.source_id, temporary.source_id);
assert_ne!(pending.source_id, replacement.source_id);
assert_eq!(pending.source, running.source);
Ok(())
}
#[test]
fn execution_inspection_maps_shutdown_and_has_no_frames_after_failure() -> RuntimeResult<()> {
let mut image = ImageApi::new();
let file = image.write_source("actors/shutdown_stack", None, "pub fn handle(ctx, state, sender, message) { state }\npub fn on_shutdown(state) { loop {} }")?;
let actor = image.spawn(&file.path, Value::Null, capacity(2))?;
image.system(actor, SystemMessage::Stop)?;
image.tick(1, capacity(10));
let shutdown = image.inspect_execution(actor)?.execution.unwrap();
assert_eq!(shutdown.frames[0].function.as_deref(), Some("on_shutdown"));
assert_eq!(shutdown.frames[0].range.unwrap().start.line, 1);
let file = image.write_source(
"actors/failing_stack",
None,
"pub fn handle(ctx, state, sender, message) { 1 / 0 }",
)?;
let failed = image.spawn(&file.path, Value::Null, capacity(2))?;
image.send(failed, Value::Null)?;
image.tick(1, capacity(100));
let failed = image.inspect_execution(failed)?;
assert!(failed.execution.is_none());
assert_eq!(failed.source, file.source);
Ok(())
}
#[test] #[test]
fn validation_never_mutates_sources_actors_or_compile_cache() -> RuntimeResult<()> { fn validation_never_mutates_sources_actors_or_compile_cache() -> RuntimeResult<()> {
let mut image = ImageApi::new(); let mut image = ImageApi::new();

View File

@ -2,6 +2,7 @@
pub use runa_core::Value; pub use runa_core::Value;
pub use runa_core::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange}; pub use runa_core::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
pub use runa_core::{ExecutionInspection, StackFrame, StackFrameKind};
pub use runa_core::{LogEntry, LogLevel}; pub use runa_core::{LogEntry, LogLevel};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -53,7 +54,8 @@ impl Request {
validate_name(target)?; validate_name(target)?;
} }
Command::DeleteSource { path, .. } => validate_name(path)?, Command::DeleteSource { path, .. } => validate_name(path)?,
Command::ActorBehavior { actor_ref } Command::InspectExecution { actor_ref }
| Command::ActorBehavior { actor_ref }
| Command::WriteActorBehavior { actor_ref, .. } => validate_actor_ref(actor_ref)?, | Command::WriteActorBehavior { actor_ref, .. } => validate_actor_ref(actor_ref)?,
Command::UpdateBehaviorSource { Command::UpdateBehaviorSource {
actor_ref, path, .. actor_ref, path, ..
@ -173,6 +175,9 @@ 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 {
InspectExecution {
actor_ref: String,
},
CheckSource { CheckSource {
source: String, source: String,
}, },
@ -323,6 +328,7 @@ 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 {
ExecutionInspected(ExecutionInspectionDto),
SourceChecked { SourceChecked {
diagnostics: Vec<SourceDiagnostic>, diagnostics: Vec<SourceDiagnostic>,
}, },
@ -490,6 +496,17 @@ pub struct ExecutionSnapshotDto {
pub call_depth: usize, pub call_depth: usize,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionInspectionDto {
pub actor_ref: String,
pub incarnation: u64,
pub implementation_id: String,
pub source_id: String,
pub source: String,
pub behavior_path: Option<String>,
pub execution: Option<ExecutionInspection>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ErrorCode { pub enum ErrorCode {

View File

@ -79,7 +79,7 @@ pub(crate) fn collect(
output output
} }
fn source_range(source: &str, span: Span) -> Option<SourceRange> { pub(crate) fn source_range(source: &str, span: Span) -> Option<SourceRange> {
let range = span.range(); let range = span.range();
let start = source.get(..range.start)?; let start = source.get(..range.start)?;
let end = source.get(..range.end)?; let end = source.get(..range.end)?;

View File

@ -1,11 +1,12 @@
use std::cell::Cell; use std::cell::{Cell, RefCell};
use std::future::{Future, poll_fn}; use std::future::{Future, poll_fn};
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::pin::Pin; use std::pin::Pin;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use rune::runtime::{Args, VmError}; use rune::runtime::{Args, Unit, VmError};
use rune::{FromValue, Vm}; use rune::{FromValue, Vm};
use crate::ExperimentResult; use crate::ExperimentResult;
@ -28,10 +29,54 @@ pub enum Slice<T> {
type ExecutionFuture<T> = Pin<Box<dyn Future<Output = ExperimentResult<T>>>>; type ExecutionFuture<T> = Pin<Box<dyn Future<Output = ExperimentResult<T>>>>;
pub const MAX_INSPECTION_FRAMES: usize = 64;
/// Owned instruction addresses only; no Rune stack values escape the worker.
#[derive(Clone)]
pub struct InvocationStack {
pub unit: Arc<Unit>,
pub instruction_pointer: usize,
pub return_addresses: Vec<usize>,
pub call_depth: usize,
pub awaiting: bool,
}
impl InvocationStack {
fn capture(vm: &Vm) -> Self {
let mut stack = Self {
unit: Arc::clone(vm.unit()),
instruction_pointer: vm.ip(),
return_addresses: Vec::new(),
call_depth: vm.call_frames().len(),
awaiting: false,
};
stack.update(vm);
stack
}
fn update(&mut self, vm: &Vm) {
if !Arc::ptr_eq(&self.unit, vm.unit()) {
self.unit = Arc::clone(vm.unit());
}
self.instruction_pointer = vm.ip();
self.call_depth = vm.call_frames().len();
self.return_addresses.clear();
self.return_addresses.extend(
vm.call_frames()
.iter()
.rev()
.take(MAX_INSPECTION_FRAMES - 1)
.map(|frame| frame.ip),
);
self.awaiting = false;
}
}
pub struct Invocation<T> { pub struct Invocation<T> {
future: Option<ExecutionFuture<T>>, future: Option<ExecutionFuture<T>>,
stepped: Rc<Cell<bool>>, stepped: Rc<Cell<bool>>,
progress: Rc<Cell<InvocationProgress>>, progress: Rc<Cell<InvocationProgress>>,
stack: Rc<RefCell<InvocationStack>>,
} }
impl<T: FromValue + 'static> Invocation<T> { impl<T: FromValue + 'static> Invocation<T> {
@ -43,13 +88,19 @@ impl<T: FromValue + 'static> Invocation<T> {
execution.vm_mut().set_ip(entrypoint); execution.vm_mut().set_ip(entrypoint);
let stepped = Rc::new(Cell::new(false)); let stepped = Rc::new(Cell::new(false));
let marker = Rc::clone(&stepped); let marker = Rc::clone(&stepped);
let progress = Rc::new(Cell::new(InvocationProgress::default())); let progress = Rc::new(Cell::new(InvocationProgress {
instruction_pointer: entrypoint,
..InvocationProgress::default()
}));
let inspector = Rc::clone(&progress); let inspector = Rc::clone(&progress);
let stack = Rc::new(RefCell::new(InvocationStack::capture(execution.vm())));
let stack_inspector = Rc::clone(&stack);
let future = Box::pin(async move { let future = Box::pin(async move {
loop { loop {
let result = execution.async_step().await; let result = execution.async_step().await;
let vm = execution.vm(); let vm = execution.vm();
let previous = inspector.get(); let previous = inspector.get();
stack_inspector.borrow_mut().update(vm);
inspector.set(InvocationProgress { inspector.set(InvocationProgress {
vm_steps: previous.vm_steps.saturating_add(1), vm_steps: previous.vm_steps.saturating_add(1),
instruction_pointer: vm.ip(), instruction_pointer: vm.ip(),
@ -76,6 +127,7 @@ impl<T: FromValue + 'static> Invocation<T> {
future: Some(future), future: Some(future),
stepped, stepped,
progress, progress,
stack,
}) })
} }
@ -84,6 +136,10 @@ impl<T: FromValue + 'static> Invocation<T> {
self.progress.get() self.progress.get()
} }
pub fn stack(&self) -> InvocationStack {
self.stack.borrow().clone()
}
/// Bound VM steps per call, preserving the exact pending async step. /// Bound VM steps per call, preserving the exact pending async step.
pub fn poll_slice(&mut self, cx: &mut Context<'_>, budget: NonZeroUsize) -> Slice<T> { pub fn poll_slice(&mut self, cx: &mut Context<'_>, budget: NonZeroUsize) -> Slice<T> {
let Some(future) = self.future.as_mut() else { let Some(future) = self.future.as_mut() else {
@ -99,7 +155,10 @@ impl<T: FromValue + 'static> Invocation<T> {
Err(error) => Slice::Failed(error), Err(error) => Slice::Failed(error),
}; };
} }
Poll::Pending if !self.stepped.get() => return Slice::Awaiting, Poll::Pending if !self.stepped.get() => {
self.stack.borrow_mut().awaiting = true;
return Slice::Awaiting;
}
Poll::Pending => {} Poll::Pending => {}
} }
} }
@ -112,11 +171,101 @@ mod tests {
use super::*; use super::*;
use crate::CompiledProgram; use crate::CompiledProgram;
use rune::Module; use rune::Module;
use std::sync::{Arc, Mutex}; use std::sync::Mutex;
use std::task::{Wake, Waker}; use std::task::{Wake, Waker};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
#[test]
fn stack_frames_map_nested_calls_and_return_addresses_to_exact_source() -> ExperimentResult<()>
{
let source = "pub fn handle() {\n outer()\n}\nfn outer() {\n inner()\n}\nfn inner() {\n loop {}\n}";
let program = CompiledProgram::compile(source)?;
let mut invocation = Invocation::<i64>::start(program.vm(), "handle", ())?;
let mut cx = Context::from_waker(Waker::noop());
for _ in 0..100 {
assert!(matches!(
invocation.poll_slice(&mut cx, NonZeroUsize::new(1).unwrap()),
Slice::BudgetExhausted
));
let inspection =
program.inspect_stack(source, &invocation.stack(), invocation.progress().vm_steps);
if inspection.frames.len() == 3
&& inspection.frames[0]
.range
.is_some_and(|range| range.start.line == 7)
{
assert_eq!(
inspection
.frames
.iter()
.map(|frame| frame.function.as_deref().unwrap())
.collect::<Vec<_>>(),
["inner", "outer", "handle"]
);
assert_eq!(
inspection
.frames
.iter()
.map(|frame| frame.range.unwrap().start.line)
.collect::<Vec<_>>(),
[7, 4, 1]
);
assert!(
inspection.frames[1].source_instruction_pointer.unwrap()
< inspection.frames[1].instruction_pointer
);
assert_eq!(inspection.call_depth, 2);
assert!(!inspection.awaiting);
assert!(!inspection.frames_truncated);
return Ok(());
}
}
Err("nested source stack was not observed".into())
}
#[test]
fn unavailable_debug_locations_are_not_guessed() -> ExperimentResult<()> {
let source = "pub fn handle() { loop {} }";
let program = CompiledProgram::compile(source)?;
let other = CompiledProgram::compile("pub fn handle() { 1 }")?;
let mut stack = InvocationStack::capture(&program.vm());
// A different compiled unit cannot be mapped against this source.
let invocation = Invocation::<i64>::start(program.vm(), "handle", ())?;
let inspection = other.inspect_stack("pub fn handle() { 1 }", &invocation.stack(), 0);
assert!(inspection.frames.iter().all(|frame| frame.range.is_none()));
stack.instruction_pointer = usize::MAX;
stack.return_addresses.clear();
stack.call_depth = 0;
let inspection = program.inspect_stack(source, &stack, 0);
assert_eq!(inspection.frames[0].range, None);
assert_eq!(inspection.frames[0].function, None);
assert_eq!(inspection.frames[0].source_instruction_pointer, None);
Ok(())
}
#[test]
fn deep_stack_inspection_is_bounded_and_reports_truncation() -> ExperimentResult<()> {
let source = "pub fn handle() { recurse(100) } fn recurse(n) { if n == 0 { loop {} } else { recurse(n - 1) } }";
let program = CompiledProgram::compile(source)?;
let mut invocation = Invocation::<i64>::start(program.vm(), "handle", ())?;
let mut cx = Context::from_waker(Waker::noop());
for _ in 0..100 {
assert!(matches!(
invocation.poll_slice(&mut cx, NonZeroUsize::new(32).unwrap()),
Slice::BudgetExhausted
));
let inspection =
program.inspect_stack(source, &invocation.stack(), invocation.progress().vm_steps);
if inspection.call_depth > MAX_INSPECTION_FRAMES {
assert_eq!(inspection.frames.len(), MAX_INSPECTION_FRAMES);
assert!(inspection.frames_truncated);
return Ok(());
}
}
Err("deep recursive stack was not observed".into())
}
#[test] #[test]
fn shutdown_invocation_preserves_state_argument() -> ExperimentResult<()> { fn shutdown_invocation_preserves_state_argument() -> ExperimentResult<()> {
let program = CompiledProgram::compile( let program = CompiledProgram::compile(
@ -186,10 +335,8 @@ mod tests {
module module
.function("wait", move || PendingOperation(Arc::clone(&native_signal))) .function("wait", move || PendingOperation(Arc::clone(&native_signal)))
.build()?; .build()?;
let program = CompiledProgram::compile_with_module( let source = "pub async fn handle() { let n = 0; while n < 10 { n += 1; } let x = wait().await; while n < 20 { n += 1; } n + x }";
"pub async fn handle() { let n = 0; while n < 10 { n += 1; } let x = wait().await; while n < 20 { n += 1; } n + x }", let program = CompiledProgram::compile_with_module(source, module)?;
module,
)?;
let counter = Arc::new(WakeCounter(std::sync::atomic::AtomicUsize::new(0))); let counter = Arc::new(WakeCounter(std::sync::atomic::AtomicUsize::new(0)));
let waker = Waker::from(Arc::clone(&counter)); let waker = Waker::from(Arc::clone(&counter));
let mut cx = Context::from_waker(&waker); let mut cx = Context::from_waker(&waker);
@ -210,6 +357,15 @@ mod tests {
.unwrap() .unwrap()
.take() .take()
.expect("await registered waker"); .expect("await registered waker");
let stack = invocation.stack();
assert!(stack.awaiting);
let inspection = program.inspect_stack(source, &stack, invocation.progress().vm_steps);
let range = inspection.frames[0].range.unwrap();
assert_eq!(range.start.line, 0);
assert_eq!(
&source[range.start.character..range.end.character],
"wait().await"
);
assert!(matches!( assert!(matches!(
invocation.poll_slice(&mut cx, budget), invocation.poll_slice(&mut cx, budget),
Slice::Awaiting Slice::Awaiting

View File

@ -16,7 +16,7 @@ pub const RUNE_VERSION: &str = "0.14.2";
pub const COMPILER_CONFIGURATION_ID: &str = "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1; runa::ActorContext:v3:this,send,send_from,log,trace,debug,info,warn,error,fatal"; pub const COMPILER_CONFIGURATION_ID: &str = "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1; runa::ActorContext:v3:this,send,send_from,log,trace,debug,info,warn,error,fatal";
mod execution; mod execution;
pub use execution::{Invocation, InvocationProgress, Slice}; pub use execution::{Invocation, InvocationProgress, InvocationStack, Slice};
mod value; mod value;
pub use value::{value_from_rune, value_to_rune}; pub use value::{value_from_rune, value_to_rune};
mod context; mod context;
@ -52,6 +52,7 @@ pub struct CompiledProgram {
runtime: Arc<RuntimeContext>, runtime: Arc<RuntimeContext>,
unit: Arc<Unit>, unit: Arc<Unit>,
diagnostics: Vec<runa_core::SourceDiagnostic>, diagnostics: Vec<runa_core::SourceDiagnostic>,
source_id: rune::SourceId,
} }
impl CompiledProgram { impl CompiledProgram {
@ -146,6 +147,7 @@ impl CompiledProgram {
runtime: Arc::new(context.runtime()?), runtime: Arc::new(context.runtime()?),
unit: Arc::new(unit), unit: Arc::new(unit),
diagnostics, diagnostics,
source_id,
}) })
} }
@ -156,6 +158,71 @@ impl CompiledProgram {
pub fn has_function(&self, name: &str) -> bool { pub fn has_function(&self, name: &str) -> bool {
self.vm().lookup_function([name]).is_ok() self.vm().lookup_function([name]).is_ok()
} }
pub fn inspect_stack(
&self,
source: &str,
stack: &InvocationStack,
vm_steps: u64,
) -> runa_core::ExecutionInspection {
use runa_core::{StackFrame, StackFrameKind};
let debug = stack.unit.debug_info();
let frame = |ip, kind| {
let source_ip = debug.and_then(|debug| match kind {
StackFrameKind::Current => debug.instruction_at(ip).map(|_| ip),
StackFrameKind::Caller => debug
.instructions
.keys()
.copied()
.filter(|offset| *offset < ip)
.max(),
});
let function = debug.and_then(|debug| {
let offset = source_ip?;
let entry = debug
.functions_rev
.keys()
.copied()
.filter(|start| *start <= offset)
.max()?;
debug
.function_at(entry)
.map(|(_, signature)| signature.path.to_string())
});
let range = source_ip.and_then(|ip| {
if !Arc::ptr_eq(&self.unit, &stack.unit) {
return None;
}
let instruction = debug?.instruction_at(ip)?;
if instruction.source_id != self.source_id {
return None;
}
diagnostics::source_range(source, instruction.span)
});
StackFrame {
kind,
instruction_pointer: ip,
source_instruction_pointer: source_ip,
function,
range,
}
};
let frames = std::iter::once(frame(stack.instruction_pointer, StackFrameKind::Current))
.chain(
stack
.return_addresses
.iter()
.map(|ip| frame(*ip, StackFrameKind::Caller)),
)
.collect();
runa_core::ExecutionInspection {
vm_steps,
awaiting: stack.awaiting,
call_depth: stack.call_depth,
frames,
frames_truncated: stack.call_depth >= execution::MAX_INSPECTION_FRAMES,
}
}
} }
#[derive(Debug)] #[derive(Debug)]

View File

@ -162,6 +162,25 @@ impl Runtime {
Ok(snapshot) Ok(snapshot)
} }
pub fn inspect_execution(
&self,
id: ActorRef,
) -> ExperimentResult<Option<runa_core::ExecutionInspection>> {
let actor = self.actors.get(&id).ok_or("unknown actor")?;
let Some(invocation) = &actor.active else {
return Ok(None);
};
let artifact = self
.implementations
.get(&actor.snapshot.implementation)
.ok_or("unknown implementation")?;
Ok(Some(artifact.program().inspect_stack(
artifact.original_source(),
&invocation.stack(),
invocation.progress().vm_steps,
)))
}
pub fn edit_actor( pub fn edit_actor(
&mut self, &mut self,
id: ActorRef, id: ActorRef,

View File

@ -65,6 +65,21 @@ 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::InspectExecution { actor_ref } => self
.image
.inspect_execution(parse_actor_ref(&actor_ref)?)
.map(|inspection| {
Reply::ExecutionInspected(runa_protocol::ExecutionInspectionDto {
actor_ref: inspection.actor_ref.to_string(),
incarnation: inspection.incarnation,
implementation_id: inspection.implementation_id.to_string(),
source_id: inspection.source_id.to_string(),
source: inspection.source,
behavior_path: inspection.behavior_path,
execution: inspection.execution,
})
})
.map_err(api_error),
Command::CheckSource { source } => self Command::CheckSource { source } => self
.image .image
.check_source(&source) .check_source(&source)

View File

@ -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::ActorBehavior { .. } | Command::CheckSource { .. } | Command::Logs { .. }); Command::RevisionSource { .. } | Command::ActorBehavior { .. } | Command::CheckSource { .. } | Command::InspectExecution { .. } | 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.

View File

@ -534,6 +534,11 @@ protocol into VS Code Problems for open sources and temporary behaviors,
including unsaved text. Validation does not mutate the image; source positions including unsaved text. Validation does not mutate the image; source positions
use UTF-16 editor coordinates. Single-source checking is implemented, not an LSP use UTF-16 editor coordinates. Single-source checking is implemented, not an LSP
or a cross-image module resolver. Detailed runtime event subscriptions remain planned. or a cross-image module resolver. Detailed runtime event subscriptions remain planned.
Debugger foundations now expose bounded head-VM stack frames and UTF-16 source
locations through read-only execution inspection. VS Code navigates immutable
exact-loaded-source documents, including old code during pending replacements.
Await locations represent the last safe snapshot; native/hidden async frames,
locals, post-mortem stacks, stepping, breakpoints, and DAP remain unimplemented.
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.

View File

@ -65,6 +65,7 @@ behavior aliases. They exclude the `source/` root and `.rn` extension.
| `revision_source` | `path`, `revision` | `behavior_source`: `{source}` | | `revision_source` | `path`, `revision` | `behavior_source`: `{source}` |
| `rollback_source` | `path`, `revision` | `source`: restored file | | `rollback_source` | `path`, `revision` | `source`: restored file |
| `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` | | `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` |
| `inspect_execution` | `actor_ref` | `execution_inspected`: loaded source and optional execution stack |
| `system` | `actor_ref`, `message` | `acknowledged` | | `system` | `actor_ref`, `message` | `acknowledged` |
A file is `{path, source_id, source}` and a commit is A file is `{path, source_id, source}` and a commit is
@ -115,6 +116,35 @@ field: absent diagnostics deserialize as an empty list and are omitted when
empty. Source checking is single-file; it does not resolve other image files empty. Source checking is single-file; it does not resolve other image files
as modules. as modules.
### Execution inspection
`inspect_execution` is an additive, read-only v5 operation. It returns
`{actor_ref, incarnation, implementation_id, source_id, source, behavior_path,
execution}`. Source is the exact original text of the installed artifact, not
working source or a pending temporary replacement. The nullable behavior path
is a logical binding, not a guarantee that a mutable file still matches the code.
Idle/failed actors return `execution: null`; removed actors return `not_found`.
Inspection does not tick, mutate the image, consume events, or emit `image_changed`.
A non-null execution is `{vm_steps, awaiting, call_depth, frames,
frames_truncated}`. Frames are ordered innermost first, limited to 64 including
the current frame; `call_depth` reports the full number of saved callers.
Each frame is `{kind: "current"|"caller", instruction_pointer,
source_instruction_pointer, function, range}`. Current IP is the next instruction
at a completed step boundary; while awaiting, it is the await instruction from
the last safe snapshot, not an IP observed inside a borrowed pending future.
Caller IPs are saved return addresses; source IPs identify the preceding call
instruction. Source IP, function, and range may be null when debug information
is unavailable. Ranges use the same zero-based UTF-16/exclusive-end coordinates
as diagnostics and refer only to the returned loaded source.
Only the head Rune VM's visible frames are captured, not native stacks or stacks
hidden inside nested futures. Pending async steps are not counted as completed
VM steps. Snapshots contain no VM stack values or mutable execution handles.
There is no locals inspection, post-mortem stack retention, stepping, breakpoint,
or DAP contract. Responses are point-in-time; clients must pause explicitly for
stable inspection.
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

View File

@ -1,5 +1,24 @@
# Phase 1 actor prototype # Phase 1 actor prototype
## Execution inspection
The image API and `inspect_execution` protocol operation expose a read-only,
bounded snapshot of an active invocation's head Rune VM frames and the exact
installed source/implementation identities. The current frame maps the next
instruction; saved caller return addresses map back to their call instructions.
While a native await is pending, the snapshot retains the last safe instruction
boundary and is marked awaiting. It does not borrow the active VM or expose
stack values across actor boundaries. At most 64 innermost frames are copied;
depth/truncation remain explicit. Unknown mappings are nullable.
VS Code can navigate these frames in immutable loaded-source documents.
Shared working edits and pending temporary replacements cannot change an active
frame's source. Idle/failed actors have no active stack; failures still discard
the invocation, so this is not post-mortem debugging. Shutdown hooks use the same
mapping. Pause retains a stable active snapshot; restart/kill discard it.
Native Rust frames and hidden nested-future stacks are not inspected.
Breakpoints, source stepping, variables, and durable stack storage remain planned.
## Actor repair and logging ## Actor repair and logging
`set-state <actor> <json>` replaces committed state without touching the mailbox. `set-state <actor> <json>` replaces committed state without touching the mailbox.

View File

@ -126,6 +126,30 @@ machine running the extension host, not necessarily the desktop.
Notifications are non-destructive invalidations, not a replayable runtime Notifications are non-destructive invalidations, not a replayable runtime
event stream; reconnect fetches the current image rather than replaying history. event stream; reconnect fetches the current image rather than replaying history.
## Execution stack inspection
Right-click an actor and choose **Inspect Execution Stack...** (also available
in the Command Palette). Select a frame to open the exact loaded Rune source
in a read-only `runa-execution:` document and highlight its source span.
The picker identifies the incarnation, completed VM steps, functions, and source
positions. The current frame points to the next instruction, or the pending
await instruction at the last safe snapshot; callers point to call sites, not
their saved return addresses. At most 64 innermost frames are shown, with an
explicit truncation notice. Missing debug locations are labeled unavailable.
This never advances the scheduler or pauses the actor. Pause first for a stable
snapshot; running inspection is a point-in-time view. Idle/failed actors have no
active stack, and removed actors cannot be inspected. Pending source/temporary
behavior changes do not replace the loaded code. Open loaded documents retain
their text after edits, actor removal, and disconnect; connections use distinct
URIs. These are not source editors or temporary behavior documents.
Only the head Rune VM's visible frames are available, not native Rust frames or
stacks hidden inside nested futures. There are no locals, post-mortem stacks,
breakpoints, source stepping, or Debug Adapter Protocol support yet.
For automation, `runa.inspectExecution` accepts `{actor_ref: string}` and an
optional zero-based frame index to navigate without the picker.
For automation, the `runa.connect` command also accepts an explicit endpoint For automation, the `runa.connect` command also accepts an explicit endpoint
string, and `runa.inspect` accepts an object with an `actor_ref` field. string, and `runa.inspect` accepts an object with an `actor_ref` field.
**Copy Actor Reference** in the actor context menu or inline button copies the **Copy Actor Reference** in the actor context menu or inline button copies the

View File

@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ImageClient } from "../src/client";
import { parseValue } from "../src/values";
import { startImage } from "./server";
test(
"execution inspection returns exact loaded code and mapped nested frames without mutations",
{ timeout: 10000 },
async (t) => {
const image = await startImage();
let changes = 0;
const client = await ImageClient.connect(
image.address,
(error) => assert.equal(error, undefined),
() => changes++,
);
t.after(async () => {
await client.close();
await image.stop();
});
const source =
"pub fn handle(ctx, state, sender, message) {\n nested()\n}\nfn nested() {\n loop {}\n}";
const file = await client.writeSource("actors/debug", null, source);
const actor = await client.spawn(file.path, parseValue("null"));
assert.equal((await client.inspectExecution(actor)).execution, null);
await client.send(actor, parseValue("null"));
await client.tick(1, 16);
const before = await client.inspectActor(actor);
const count = changes;
const inspection = await client.inspectExecution(actor);
assert.equal(changes, count, "read-only inspection does not broadcast image_changed");
assert.deepEqual(await client.inspectActor(actor), before);
assert.equal(inspection.source_id, file.source_id);
assert.equal(inspection.source, source);
assert.equal(inspection.implementation_id, before.implementation_id);
const execution = inspection.execution;
assert.ok(execution);
assert.equal(execution.vm_steps, before.execution?.vm_steps);
assert.equal(execution.call_depth, 1n);
assert.deepEqual(
execution.frames.map((frame) => frame.function),
["nested", "handle"],
);
assert.deepEqual(
execution.frames.map((frame) => frame.range?.start.line),
[4, 1],
);
await client.writeSource(
file.path,
file.source_id,
"pub fn handle(ctx, state, sender, message) { 42 }",
);
await client.system(actor, { kind: "reload" });
assert.equal((await client.inspectExecution(actor)).source, source);
await client.system(actor, { kind: "pause" });
await client.tick(1, 1);
const paused = await client.inspectExecution(actor);
await client.tick(1, 100);
assert.deepEqual(await client.inspectExecution(actor), paused);
await client.system(actor, { kind: "kill" });
await assert.rejects(client.inspectExecution(actor), /unknown actor/);
},
);

View File

@ -6,6 +6,9 @@ import { actorFileUri } from "../src/actorFiles";
import type { activate } from "../src/extension"; import type { activate } from "../src/extension";
import { SourceDiagnostics } from "../src/diagnostics"; import { SourceDiagnostics } from "../src/diagnostics";
import { SourceDiagnostic } from "../src/protocol"; import { SourceDiagnostic } from "../src/protocol";
import { ImageClient } from "../src/client";
import { parseValue } from "../src/values";
import { ExecutionDocuments, frameSelection } from "../src/executionProvider";
export async function run(): Promise<void> { export async function run(): Promise<void> {
const image = await startImage(); const image = await startImage();
@ -41,6 +44,7 @@ export async function run(): Promise<void> {
); );
await editSources(image.address); await editSources(image.address);
await checkSourceDiagnostics(image.address); await checkSourceDiagnostics(image.address);
await inspectExecution(image.address);
await editActorFiles(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",
@ -242,6 +246,99 @@ export async function run(): Promise<void> {
} }
} }
async function inspectExecution(address: string): Promise<void> {
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
try {
const source =
"pub fn handle(ctx, state, sender, message) {\n nested()\n}\nfn nested() {\n loop {}\n}";
const file = await client.writeSource("tests/execution", null, source);
const actor = await client.spawn(file.path, parseValue("null"));
await client.send(actor, parseValue("null"));
await client.tick(1, 16);
await client.system(actor, { kind: "pause" });
await client.tick(1, 1);
const loaded = await client.inspectExecution(actor);
const location = loaded.execution?.frames[0]?.range;
assert.ok(location);
await client.writeSource(
file.path,
file.source_id,
"pub fn handle(ctx, state, sender, message) { 42 }",
);
await client.system(actor, { kind: "reload" });
await vscode.commands.executeCommand("runa.inspectExecution", { actor_ref: actor }, 0);
const editor = vscode.window.activeTextEditor;
assert.ok(editor);
const document = editor.document;
assert.equal(document.uri.scheme, "runa-execution");
assert.equal(document.languageId, "rune");
const frame = loaded.execution?.frames[0];
assert.ok(frame);
assert.throws(
() =>
frameSelection(document, {
...frame,
range: {
start: { line: document.lineCount, character: 0 },
end: { line: document.lineCount, character: 0 },
},
}),
/outside the loaded source/,
);
assert.equal(frameSelection(document, { ...frame, range: null }), undefined);
const cache = new ExecutionDocuments();
const cached = cache.set(1, loaded);
assert.throws(
() => cache.set(1, { ...loaded, source: "changed" }),
/Immutable execution source changed/,
);
assert.equal(cache.provideTextDocumentContent(cached), source);
cache.remove(cached);
assert.throws(() => cache.provideTextDocumentContent(cached), /no longer cached/);
cache.dispose();
assert.equal(
document.getText(),
source,
"navigation opens loaded code, not newer shared source",
);
assert.equal(editor.selection.start.line, location.start.line);
assert.equal(editor.selection.start.character, location.start.character);
assert.equal(editor.selection.end.character, location.end.character);
await vscode.commands.executeCommand("type", { text: "// cannot edit\n" });
assert.equal(document.getText(), source);
assert.equal(document.isDirty, false);
await vscode.commands.executeCommand("runa.inspectExecution", { actor_ref: actor }, 1);
assert.equal(vscode.window.activeTextEditor?.document.uri.toString(), document.uri.toString());
assert.equal(
vscode.window.activeTextEditor?.selection.start.line,
1,
"caller selects call site, not return location",
);
await client.system(actor, { kind: "kill" });
assert.equal(document.getText(), source, "loaded document survives actor removal");
const firstUri = document.uri.toString();
await vscode.commands.executeCommand("runa.disconnect");
assert.equal(document.getText(), source, "loaded document survives disconnect");
await vscode.commands.executeCommand("runa.connect", address);
const second = await client.spawn(file.path, parseValue("null"));
const temporary = await client.actorBehavior(second);
await client.writeActorBehavior(second, temporary.source_id, source);
await client.send(second, parseValue("null"));
await client.tick(1, 16);
await vscode.commands.executeCommand("runa.inspectExecution", { actor_ref: second }, 0);
const reopened = vscode.window.activeTextEditor?.document;
assert.ok(reopened);
assert.notEqual(reopened.uri.toString(), firstUri, "loaded documents are connection-scoped");
assert.equal(reopened.getText(), source, "temporary implementations expose their exact source");
await client.system(second, { kind: "kill" });
console.log(
"Runa execution inspection: exact loaded source, caller navigation, read-only documents and session isolation passed",
);
} finally {
await client.close();
}
}
async function editSources(address: string): Promise<void> { async function editSources(address: string): Promise<void> {
const extension = vscode.extensions.all.find( const extension = vscode.extensions.all.find(
(candidate) => candidate.packageJSON.name === "runa", (candidate) => candidate.packageJSON.name === "runa",

View File

@ -51,6 +51,12 @@
} }
], ],
"commands": [ "commands": [
{
"command": "runa.inspectExecution",
"title": "Inspect Execution Stack...",
"category": "Runa",
"enablement": "runa.connected"
},
{ {
"command": "runa.newSource", "command": "runa.newSource",
"title": "New Source File...", "title": "New Source File...",
@ -305,6 +311,11 @@
} }
], ],
"view/item/context": [ "view/item/context": [
{
"command": "runa.inspectExecution",
"when": "view == runa.actors && viewItem == runa.actor && runa.connected",
"group": "debug@1"
},
{ {
"command": "runa.newSource", "command": "runa.newSource",
"when": "view == runa.sources && viewItem == runa.sourceDirectory && runa.connected", "when": "view == runa.sources && viewItem == runa.sourceDirectory && runa.connected",

View File

@ -4,6 +4,7 @@ import WebSocket, { RawData } from "ws";
import { stringify } from "lossless-json"; import { stringify } from "lossless-json";
import { import {
ActorSnapshot, ActorSnapshot,
ExecutionInspection,
decodeNotification, decodeNotification,
decodeResponse, decodeResponse,
decodeValue, decodeValue,
@ -226,6 +227,15 @@ export class ImageClient {
return reply.data; return reply.data;
} }
async inspectExecution(actorRef: string): Promise<ExecutionInspection> {
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
const reply = await this.request({
operation: "inspect_execution",
parameters: { actor_ref: actorRef },
});
if (reply.kind !== "execution_inspected") throw new Error("Expected execution inspection");
return reply.data;
}
async listSources(): Promise<SourceFile[]> { async listSources(): Promise<SourceFile[]> {
const reply = await this.request({ operation: "list_sources" }); const reply = await this.request({ operation: "list_sources" });
if (reply.kind !== "sources") throw new Error("Expected source listing"); if (reply.kind !== "sources") throw new Error("Expected source listing");

View File

@ -0,0 +1,48 @@
import * as vscode from "vscode";
import { ExecutionInspection, StackFrame } from "./protocol";
export const EXECUTION_SCHEME = "runa-execution";
/** Immutable loaded code, independent of mutable source and temporary behavior documents. */
export class ExecutionDocuments implements vscode.TextDocumentContentProvider {
private readonly sources = new Map<string, string>();
set(session: number, inspection: ExecutionInspection): vscode.Uri {
const uri = vscode.Uri.from({
scheme: EXECUTION_SCHEME,
authority: String(session),
path: `/loaded/${inspection.source_id}.rn`,
});
const previous = this.sources.get(uri.toString());
if (previous !== undefined && previous !== inspection.source)
throw new Error("Immutable execution source changed");
this.sources.set(uri.toString(), inspection.source);
return uri;
}
provideTextDocumentContent(uri: vscode.Uri): string {
const source = this.sources.get(uri.toString());
if (source === undefined)
throw new Error("Execution source is no longer cached; inspect the stack again");
return source;
}
remove(uri: vscode.Uri): void {
this.sources.delete(uri.toString());
}
dispose(): void {
this.sources.clear();
}
}
export function frameSelection(
document: vscode.TextDocument,
frame: StackFrame,
): vscode.Range | undefined {
if (!frame.range) return undefined;
const { start, end } = frame.range;
const range = new vscode.Range(start.line, start.character, end.line, end.character);
if (!document.validateRange(range).isEqual(range))
throw new Error("Execution location is outside the loaded source");
return range;
}

View File

@ -13,6 +13,7 @@ import { parseValue, formatValue } from "./values";
import { SnapshotDocuments } from "./snapshotProvider"; import { SnapshotDocuments } from "./snapshotProvider";
import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits"; import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits";
import { SourceDiagnostics } from "./diagnostics"; import { SourceDiagnostics } from "./diagnostics";
import { ExecutionDocuments, EXECUTION_SCHEME, frameSelection } from "./executionProvider";
import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles"; import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles";
import { import {
ImageSourceFileSystem, ImageSourceFileSystem,
@ -122,6 +123,7 @@ export function activate(context: vscode.ExtensionContext): {
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();
const executions = new ExecutionDocuments();
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree }); const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
const output = vscode.window.createOutputChannel("Runa"); const output = vscode.window.createOutputChannel("Runa");
const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
@ -333,6 +335,58 @@ export function activate(context: vscode.ExtensionContext): {
}; };
}; };
const inspectExecution = async (selected?: { actor_ref: string }, frameIndex?: number) => {
const connection = active();
const actorRef = await chooseActor(selected);
if (!actorRef) return;
connection.check();
const inspection = await connection.client.inspectExecution(actorRef);
connection.check();
if (!inspection.execution) {
await vscode.window.showInformationMessage("Runa: this actor has no active invocation.");
return;
}
const execution = inspection.execution;
if (
frameIndex !== undefined &&
(!Number.isInteger(frameIndex) || frameIndex < 0 || frameIndex >= execution.frames.length)
)
throw new Error("Execution frame index is out of range");
const choices = execution.frames.map((frame, index) => ({
label: `${index}: ${frame.function ?? "<unknown function>"}`,
description: frame.range
? `${inspection.behavior_path ?? "loaded source"}:${frame.range.start.line + 1}:${frame.range.start.character + 1}`
: "source location unavailable",
detail:
index === 0
? execution.awaiting
? "Pending await instruction (last safe snapshot)"
: "Next instruction"
: "Caller at call instruction",
frame,
}));
const chosen =
frameIndex === undefined
? await vscode.window.showQuickPick(choices, {
title: `@${actorRef} / incarnation ${inspection.incarnation} / ${execution.vm_steps} completed steps`,
placeHolder: execution.frames_truncated
? "Choose a frame (innermost 64 shown; stack truncated)"
: "Choose a frame to open its exact loaded source",
})
: choices[frameIndex];
if (!chosen) return;
connection.check();
const uri = executions.set(connection.session, inspection);
const document = await vscode.workspace.openTextDocument(uri);
const selection = frameSelection(document, chosen.frame);
connection.check();
await vscode.window.showTextDocument(document, { preview: true, selection });
if (!selection)
await vscode.window.showWarningMessage(
"Runa: source location is unavailable for this frame; showing the loaded actor source without a highlight.",
);
};
const jsonInput = (title: string, value?: string) => const jsonInput = (title: string, value?: string) =>
vscode.window.showInputBox({ vscode.window.showInputBox({
title, title,
@ -765,6 +819,7 @@ export function activate(context: vscode.ExtensionContext): {
sourceTree, sourceTree,
sourceView, sourceView,
revisions, revisions,
executions,
documents, documents,
actorFiles, actorFiles,
diagnostics, diagnostics,
@ -781,13 +836,16 @@ export function activate(context: vscode.ExtensionContext): {
actorFiles.didSave(document); actorFiles.didSave(document);
}), }),
vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions), vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions),
vscode.workspace.registerTextDocumentContentProvider(EXECUTION_SCHEME, executions),
vscode.workspace.onDidCloseTextDocument((document) => { vscode.workspace.onDidCloseTextDocument((document) => {
revisions.remove(document.uri); revisions.remove(document.uri);
executions.remove(document.uri);
}), }),
command("runa.connect", connect), command("runa.connect", connect),
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.inspectExecution", inspectExecution),
command("runa.editBehavior", editBehavior), command("runa.editBehavior", editBehavior),
command("runa.goToBehavior", goToBehavior), command("runa.goToBehavior", goToBehavior),
command("runa.updateBehavior", updateBehavior), command("runa.updateBehavior", updateBehavior),

View File

@ -81,7 +81,32 @@ export interface SourceDiagnostic {
} | null; } | null;
} }
export interface StackFrame {
kind: "current" | "caller";
instruction_pointer: bigint;
source_instruction_pointer: bigint | null;
function: string | null;
range: SourceDiagnostic["range"];
}
export interface ExecutionInspection {
actor_ref: string;
incarnation: bigint;
implementation_id: string;
source_id: string;
source: string;
behavior_path: string | null;
execution: {
vm_steps: bigint;
awaiting: boolean;
call_depth: bigint;
frames: StackFrame[];
frames_truncated: boolean;
} | null;
}
export type Command = export type Command =
| { operation: "inspect_execution"; parameters: { actor_ref: string } }
| { operation: "check_source"; parameters: { source: string } } | { operation: "check_source"; parameters: { source: string } }
| { operation: "actor_behavior"; parameters: { actor_ref: string } } | { operation: "actor_behavior"; parameters: { actor_ref: string } }
| { | {
@ -318,6 +343,10 @@ function decodeSourceDiagnostic(input: unknown): SourceDiagnostic {
throw new Error("Invalid diagnostic severity"); throw new Error("Invalid diagnostic severity");
const message = text(diagnostic.message); const message = text(diagnostic.message);
if (!message) throw new Error("Empty diagnostic message"); if (!message) throw new Error("Empty diagnostic message");
return { severity: diagnostic.severity, message, range: decodeSourceRange(diagnostic.range) };
}
function decodeSourceRange(input: unknown): SourceDiagnostic["range"] {
const position = (input: unknown) => { const position = (input: unknown) => {
const value = object(input); const value = object(input);
const line = uint(value.line), const line = uint(value.line),
@ -326,20 +355,21 @@ function decodeSourceDiagnostic(input: unknown): SourceDiagnostic {
throw new Error("Diagnostic coordinate exceeds editor limits"); throw new Error("Diagnostic coordinate exceeds editor limits");
return { line: Number(line), character: Number(character) }; return { line: Number(line), character: Number(character) };
}; };
let range: SourceDiagnostic["range"] = null; if (input !== null) {
if (diagnostic.range !== null) { const value = object(input);
const value = object(diagnostic.range); const range = { start: position(value.start), end: position(value.end) };
range = { start: position(value.start), end: position(value.end) };
if ( if (
range.start.line > range.end.line || range.start.line > range.end.line ||
(range.start.line === range.end.line && range.start.character > range.end.character) (range.start.line === range.end.line && range.start.character > range.end.character)
) )
throw new Error("Reversed diagnostic range"); throw new Error("Reversed diagnostic range");
return range;
} }
return { severity: diagnostic.severity, message, range }; return null;
} }
export type Reply = export type Reply =
| { kind: "execution_inspected"; data: ExecutionInspection }
| { kind: "source_checked"; data: { diagnostics: SourceDiagnostic[] } } | { kind: "source_checked"; data: { diagnostics: SourceDiagnostic[] } }
| { kind: "actor_behavior"; data: { source: string; source_id: string } } | { kind: "actor_behavior"; data: { source: string; source_id: string } }
| { kind: "logs"; data: { entries: LogEntry[]; next_sequence: bigint; has_more: boolean } } | { kind: "logs"; data: { entries: LogEntry[]; next_sequence: bigint; has_more: boolean } }
@ -408,6 +438,62 @@ export function decodeResponse(line: string, requestId: string, command: Command
); );
} }
const reply = object(result.Ok); const reply = object(result.Ok);
if (command.operation === "inspect_execution" && reply.kind === "execution_inspected") {
const data = object(reply.data);
if (data.actor_ref !== command.parameters.actor_ref || !isActorRef(data.actor_ref))
throw new Error("Execution actor mismatch");
const implementation_id = text(data.implementation_id),
source_id = text(data.source_id);
if (!implementation_id || !source_id) throw new Error("Missing execution source identity");
if (data.behavior_path !== null && !isSourcePath(data.behavior_path))
throw new Error("Invalid execution behavior path");
let execution: ExecutionInspection["execution"] = null;
if (data.execution !== null) {
const value = object(data.execution);
if (typeof value.awaiting !== "boolean" || typeof value.frames_truncated !== "boolean")
throw new Error("Invalid execution flags");
const frames = array(value.frames).map((input, index): StackFrame => {
const frame = object(input);
const kind = index === 0 ? "current" : "caller";
if (frame.kind !== kind) throw new Error("Invalid stack frame order");
return {
kind,
instruction_pointer: uint(frame.instruction_pointer),
source_instruction_pointer:
frame.source_instruction_pointer === null
? null
: uint(frame.source_instruction_pointer),
function: frame.function === null ? null : text(frame.function),
range: decodeSourceRange(frame.range),
};
});
const call_depth = uint(value.call_depth);
if (
frames.length !== Number(call_depth < 64n ? call_depth + 1n : 64n) ||
value.frames_truncated !== call_depth >= 64n
)
throw new Error("Invalid stack frame count");
execution = {
vm_steps: uint(value.vm_steps),
awaiting: value.awaiting,
call_depth,
frames,
frames_truncated: value.frames_truncated,
};
}
return {
kind: "execution_inspected",
data: {
actor_ref: data.actor_ref,
incarnation: uint(data.incarnation),
implementation_id,
source_id,
source: text(data.source),
behavior_path: data.behavior_path,
execution,
},
};
}
if (command.operation === "check_source" && reply.kind === "source_checked") { if (command.operation === "check_source" && reply.kind === "source_checked") {
return { return {
kind: "source_checked", kind: "source_checked",

View File

@ -19,6 +19,105 @@ 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("execution inspections preserve u64 fields and validate stack order, bounds, identities and ranges", () => {
const command: Command = { operation: "inspect_execution", parameters: { actor_ref: REF } };
const frame = {
kind: "current",
instruction_pointer: 18446744073709551615n,
source_instruction_pointer: 18446744073709551615n,
function: "nested",
range: { start: { line: 1n, character: 2n }, end: { line: 1n, character: 5n } },
};
const execution = {
vm_steps: 18446744073709551615n,
awaiting: false,
call_depth: 0n,
frames: [frame],
frames_truncated: false,
};
const data = {
actor_ref: REF,
incarnation: 18446744073709551615n,
implementation_id: "impl",
source_id: "source",
source: "exact source",
behavior_path: "actors/test",
execution,
};
const decoded = decodeResponse(envelope({ kind: "execution_inspected", data }), "test", command);
assert.equal(decoded.kind, "execution_inspected");
if (decoded.kind !== "execution_inspected") throw new Error("unexpected reply");
assert.equal(decoded.data.incarnation, data.incarnation);
assert.deepEqual(decoded.data.execution?.frames[0]?.range, {
start: { line: 1, character: 2 },
end: { line: 1, character: 5 },
});
for (const change of [
{ actor_ref: "ABCDEFGHIJKLMNOPQRSTU" },
{ implementation_id: "" },
{ source_id: "" },
{ behavior_path: "../bad" },
{ incarnation: -1n },
{ execution: { ...execution, awaiting: "false" } },
{ execution: { ...execution, frames: [] } },
{ execution: { ...execution, frames_truncated: true } },
{ execution: { ...execution, frames: [{ ...frame, kind: "caller" }] } },
{ execution: { ...execution, frames: [{ ...frame, instruction_pointer: -1n }] } },
{
execution: {
...execution,
frames: [
{
...frame,
range: { start: { line: 2n, character: 0n }, end: { line: 1n, character: 0n } },
},
],
},
},
{
execution: {
...execution,
frames: [
{
...frame,
range: {
start: { line: 9007199254740992n, character: 0n },
end: { line: 9007199254740992n, character: 0n },
},
},
],
},
},
])
assert.throws(() =>
decodeResponse(
envelope({ kind: "execution_inspected", data: { ...data, ...change } }),
"test",
command,
),
);
assert.equal(
decodeResponse(
envelope({ kind: "execution_inspected", data: { ...data, execution: null } }),
"test",
command,
).kind,
"execution_inspected",
);
const unknown = {
...execution,
frames: [{ ...frame, source_instruction_pointer: null, function: null, range: null }],
};
assert.equal(
decodeResponse(
envelope({ kind: "execution_inspected", data: { ...data, execution: unknown } }),
"test",
command,
).kind,
"execution_inspected",
);
});
test("actor log lines use syslog priorities, exact sequence IDs and single-line messages", () => { test("actor log lines use syslog priorities, exact sequence IDs and single-line messages", () => {
const priorities = [135, 135, 134, 132, 131, 130]; const priorities = [135, 135, 134, 132, 131, 130];
for (const [index, level] of LOG_LEVELS.entries()) { for (const [index, level] of LOG_LEVELS.entries()) {