18 KiB
Phase 1 actor prototype
Start the server-owned, in-memory image:
mise run start
Then connect from another terminal with mise run repl, or use
cargo run -p runa-cli -- --connect <loopback-address> repl for a custom listener.
The CLI requires a subcommand: repl starts the interactive session, while
image operations such as ps, spawn, and send run once and exit.
Use runa-cli --help or runa-cli <command> --help for argument details.
One-shot JSON values must be quoted as a single shell argument; actor names
and let bindings exist only within a REPL session.
The server (runa) defaults to 127.0.0.1:3737; --listen or RUNA_LISTEN
select another loopback address. The REPL (runa-cli) accepts --connect or
RUNA_CONNECT, defaulting to the same address. Explicit flags override environment
variables. Both executables provide Clap-generated --help and --version.
Quitting the REPL leaves the image running. Reconnecting clients can list and
inspect the same actors; local bindings and history do not survive a REPL
restart. Server exit still loses the entire image.
The WebSocket server loads counter.rn into the
image's working source/actors/counter.rn. Commands include spawn, send,
tick, inspect, ps, events, stop, kill, restart, pause, resume,
reload, become, sources, write, commit, history, and
rollback; enter help to see usage. Paths exclude the root and extension:
spawn actors/counter 1. Spawn compiles the current working source, not a
committed revision. write <path> <local-file> updates working source using
source-identity conflict checks. commit <path> captures it without compilation,
and generates a readable revision ID. history <path> lists snapshots;
rollback <path> <revision> restores working source without making a commit.
Saving, committing, spawning, and reloading are distinct operations.
The CLI sends versioned requests to the loopback-only WebSocket listener using
the runa-debug subprotocol. VS Code uses the same protocol and automatically
refreshes on non-destructive server change notifications. These notifications
do not tick the image or drain the REPL's explicit events queue.
There is no stdio transport; all external clients use WebSockets.
Example:
runa> let sender = spawn actors/counter 0
sender = @<actor-reference>
runa> let receiver = spawn actors/counter 10
receiver = @<actor-reference>
runa> send sender {"op": "send", "value": 5, "target": {"$actor": "receiver"}}
message queued
runa> tick 100
runa> inspect sender
runa> inspect receiver
The sender commits state 1 and sends a structured message; the receiver commits
state 15. Actor references are 21-character NanoIDs; names are local REPL
bindings. Tab completes commands, behaviors, and bound actor references, while
Up/Down navigate command history. tick <count> [instruction-budget]
explicitly advances the scheduler. The default per-actor slice budget is 32
instructions.
State and messages use JSON literals in the REPL, preserving whitespace inside
strings. spawn <path> <json> and let <name> = spawn <path> <json>
require explicit initial state; send <actor> <json> takes one message value.
For example, send receiver {"op": "add", "value": 7} adds to the bundled
counter. Its supported operations are add and send; unknown operations
fail explicitly.
The sole-key object {"$actor": "receiver"} resolves a local binding to a
distinct actor-reference value. A literal @<NanoID> or raw NanoID string
can be used in place of the binding. Ordinary strings are never implicitly
converted to references. To encode a literal record that would otherwise
match a marker, wrap it in {"$record": {...}}. The wrapper escapes only that
record; nested values still use the normal conversion rules. Tab completion
also offers references inside a "$actor" string.
Current contracts
runa-actorowns actor references, mailbox and implementation IDs, structured messages, snapshots, statuses and lifecycle events.runa-coreowns stable source/implementation identities, actor references, and the portable owned value model shared by the runtime and protocol.runa-codepreserves original source, derives identities, caches compiled artifacts, mutable path-based working source, and independent commit snapshots.runa-runeowns compilation and typed bounded invocation execution.runa-runtimeowns implementation registration, queues, private committed state, scheduling, message effects and lifecycle coordination.runa-image-apicomposes code publication and runtime operations behind a trusted in-process service boundary.runa-protocoldefines versioned transport-neutral request, response, and event DTOs; it does not dispatch requests or access runtime state.runadispatches those requests throughruna-image-apiand maps results to protocol replies; its executable accepts JSON text messages over loopback WebSockets, with a 1 MiB request limit.runa-clientprovides a correlated, bounded loopback WebSocket client.runa-cliprovides an interactive CLI using the WebSocket client, without an embedded runtime. Connection errors are explicit; requests are never auto-retried.
The image API is process-local and intended for trusted callers. It has no transport, authentication, or authorization layer; do not expose it directly to untrusted clients. Its install operation also supports the runtime's explicit host-provided state-migration closure. Revision source retrieval returns the exact saved text.
An implementation is published once under an ID. Actors reference that exact implementation. Implementation IDs use source content and the compile fingerprint; actor references are random NanoIDs and mailbox IDs remain process-local counters. Incarnation counters use checked increments.
Source files have canonical case-sensitive relative paths, without source/
or .rn, rejecting traversal and empty components. Writes require the expected
current source identity (null means create). Conflicting saves do not overwrite
another client's work. Commits are version control only, independent of
compilation and actor installation; invalid Rune source can be committed.
Commit IDs are generated from a readable lowercase alphabet, not caller-chosen
revision numbers. Source and history remain in memory until server exit.
The older named behavior registry survives as an internal compatibility helper,
but its publish/history/install commands are not part of the v4 wire contract.
The runtime's low-level install operation switches an actor's implementation only
when it is idle, has no active invocation, and has no reserved input. Queued
messages may remain; they will use the newly installed implementation. Active
or awaiting handlers keep their original implementation, and installation
attempts during execution fail explicitly so the caller can retry at the next
handler boundary. install preserves the current structured state unchanged.
Client-facing reload and become <path> instead queue installation for the
next handler boundary, keeping active frames immutable. Reload compiles the
latest working source at the actor's current path; become chooses another path.
Compilation failure preserves its current implementation and source binding.
Runtime callers that need a state transformation can use
install_with_migration, which receives the committed state and computes its
replacement before changing either state or implementation. The operation
requires the same idle safe point; if the migration returns an error, neither
value changes and no installation event is emitted. A successful changed
state emits StateMigrated as well as BehaviorInstalled. This prototype
accepts a host-provided migration closure only: it does not infer schema
compatibility or execute migration code from Rune source. The closure receives
an owned copy of the structured state; an invalid result also leaves the
original state and implementation unchanged. Schema/version policy remains
future work.
pause prevents an actor from being scheduled on subsequent worker ticks and
retains its active invocation, reserved message, and prior Running, Awaiting,
or Idle status. resume restores that status; an awaiting invocation resumes
only if its future has been woken. These operations take effect between calls
to tick and cannot interrupt a currently executing Rune slice or native
callback. They provide cooperative scheduling control, not preemption.
Actor snapshots include basic execution telemetry while an invocation is active: completed VM instruction-step count, Rune instruction pointer, and call-frame depth. Paused invocations retain the same telemetry. These values are execution-local diagnostics only; they do not map to source locations or expose locals, and disappear when the invocation completes or is cancelled.
Runtime invocation cancellation (not exposed as a CLI/REPL command) is distinct
from stop: it drops an actor's active invocation, moves
its reserved message to the front of the mailbox, and returns the actor to
Idle. The input is therefore eligible for retry on the next tick, ahead of
messages that were already queued. Committed state is unchanged and no
transition effects are applied. Cancellation is also available for paused
invocations. It does not undo external side effects already performed by
native callbacks, so retries may repeat such effects.
The source identity hashes the exact original UTF-8 bytes with the versioned
runa-source:v1 domain. Whitespace/comment canonicalization is intentionally
not attempted; textually different source has a different identity. The
implementation identity includes the source ID, pinned Rune version, compiler
settings/features, registered native-module manifest, runtime ABI version, and
Cargo target triple. The native-module manifest and runtime ABI identifier must
be updated when those compile inputs change.
The cache is in-memory only. Invalid source is retained in the current
CodeStore for diagnostics, but does not produce a compiled artifact. Cache
identity is not a claim that Rune bytecode is portable or safe to persist.
CodeStore::compile_with_module requires an explicit stable module identity;
callers must change it when registered native functions or their contracts
change. The server creates a fresh CodeStore when its image starts.
State and messages are owned Value trees: null, boolean, signed 64-bit
integer, UTF-8 string, list, string-keyed record, or actor reference. Public
actor references are 21-character URL-safe NanoIDs. They are generated when
actors are spawned and remain stable across stop/restart; mailbox IDs are
separate internal counters. Rune handlers have this interface:
handle(ctx, state, sender, message) -> new_state
on_shutdown(state) -> new_state // optional
The runtime uses internal envelopes containing sender and data, then passes them separately to the handler. Host-originated messages have unit/null sender. Actors choose their own message data. For example:
pub fn handle(ctx, state, sender, message) {
state.items.push(message.text);
ctx.send(sender, #{accepted: true});
state
}
The runtime allocates a fresh Rune object graph for each state and message
input, and copies portable data back out before committing. ctx.send copies
message data and records the executing actor as sender.
ctx.send_from(sender, recipient, message) permits forwarding the original
sender (including unit for host-originated input). A Rune handler can
mutate its invocation-local draft but cannot mutate committed state, the
reserved input, or another actor's data. Copies made for separate effect
messages do not share mutable Rune objects.
Null maps to Rune unit (), lists to Rune vectors, records to anonymous
objects (#{...}), and references to an opaque native runa::ActorReference.
Effect targets must be references, not lookalike strings. References support
equality and cloning in Rune and can be retained and forwarded through
state/messages; this is not authentication.
Floats, nonempty tuples, options, closures, futures, custom Rune structs, and
other native objects are not portable actor data and fail conversion. Cyclic
or excessively deep Rune output also fails without committing.
Each state/message is limited to depth 32 (root depth 0), 4,096 value nodes (including the root), and 1 MiB of UTF-8 string/key bytes. A transition permits at most 4,096 send effects. Transport line-size limits apply separately. These are conversion limits, not a Rune heap quota or hostile-code sandbox. Schema/version policy remains future work. Actor contexts expose only ordinary data sends, never privileged system messages.
Scheduling and lifecycle
tick(nonzero_budget) gives each runnable actor one bounded slice in actor
reference order.
FIFO queues are bounded, including the reserved message in capacity accounting.
A queued message is reserved when its invocation starts. Subsequent messages
stay queued, including while the actor awaits. A wake marks an awaiting actor
runnable; the caller still needs to drive ticks.
Successful handlers validate every send target and its aggregate queue capacity before updating state, acknowledging the reserved message, and enqueueing effects. Invalid effects fail the actor without partial sends or a state commit. This is a logical, single-worker in-memory atomicity guarantee: observers using the runtime see either the transition before commit or after commit, not an intermediate state/effect set. It is not crash-safe or durable. External side effects performed by native callbacks are outside this boundary and cannot be rolled back.
Runtime errors retain the last committed state and reserved message, set an observable failed status, and emit a failure event. The failed message is not silently acknowledged or automatically retried. External sends return errors for unknown, unavailable, or full actors.
System messages are host-only commands, separate from ordinary data messages:
stop, kill, restart, pause, resume, reload, and become. A record containing
kind: "stop" sent by an actor is just application data.
Graceful stop finishes the active handler, then runs optional on_shutdown(state)
with cooperative instruction slices and commits its returned state. It does not
drain queued input. A missing hook stops normally; a failing hook marks the actor
failed and retains its last committed state. Stop unpauses a paused actor so
shutdown can progress; explicit scheduler ticks are still needed.
Kill drops the invocation immediately between slices, skips shutdown, and
retains committed state, mailbox and reserved message. Restart preserves its actor reference,
mailbox ID, implementation ID, committed state, and queued-message order; it
increments its incarnation and puts the reserved message at the front of the
queue.
It can also restart a running actor, killing the current invocation first.
It does not reload working source. Repeated failure of the same reserved message can therefore
block later messages until an operator changes the implementation or otherwise
resolves it; there is no automatic poison-message policy or supervision.
Transition and retry guarantees
| Situation | State | Reserved input | Effects | Status |
|---|---|---|---|---|
| Handler returns a valid transition | New state commits | Acknowledged | All validated sends enqueue | Idle |
| Rune execution or transition decoding fails | Last committed state retained | Retained | None from the returned transition | Failed |
| Any target is unknown, stopped, failed, or over capacity | Last committed state retained | Retained | No sends from the transition enqueue | Failed |
| Graceful stop | Active handler then shutdown state commits | Active input acknowledged | Valid active-handler sends commit | Stopped after shutdown |
| Kill during an invocation | Last committed state retained | Retained | None from incomplete invocation | Stopped |
| Restart after stop or failure | Retained until retried | Moved to queue front | None until retry completes | Idle |
| Pause between ticks | Last committed state retained | Retained | None while paused | Paused |
| Resume a paused invocation | Retained until invocation completes | Retained | None until completion | Prior runnable/awaiting status |
| Cancel an invocation | Last committed state retained | Moved to queue front | None from incomplete transition | Idle |
Mailbox capacity counts queued plus reserved messages. Before applying a transition, the runtime validates all effects, including aggregate sends to a single target. For a self-send, the completing input is acknowledged as part of the same transition, freeing its reserved slot before the new effects are enqueued. An invalid target or over-capacity effect rejects the entire returned transition.
These are process-local guarantees only. A host crash loses state, mailboxes, reservations and events. External/native callback effects may already have occurred even when the Rune invocation later fails or is stopped.
Lifecycle tests verify that failed and cancelled reservations retry from the last committed state, that stop/restart preserves reserved-first FIFO order, and that cancellation followed by installation retries queued input in order with the new implementation. Tests also cover that a running actor's queued messages are not processed until the reservation finishes. Transition tests cover malformed results, runtime failures, invalid targets, aggregate capacity, and successful message effects.
Limits
This is not yet a full image: no persistent behavior registry or mailboxes,
transactional outbox, supervisors, GUI, or automatic image bootstrap. The
behavior registry and implementation installation are process-local;
installation requires an idle handler boundary and does not upgrade active
execution. The example counter script is bundled through include_str!.
Messages are not accepted by stopped/failed actors.
Native functions must be nonblocking. Dropping a future proves cleanup only for the controlled test operation, not rollback of arbitrary external effects. Instruction slices cannot interrupt a native callback.
The execution decision records the scheduling trade-offs. Heap profiling and source-level debugging remain open.