runa/docs/protocol.md

415 lines
24 KiB
Markdown
Raw Normal View History

# Runa Protocol
`runa-protocol` defines versioned request, response, and event data contracts
for clients and the `runa` dispatcher. It is independent of transport
and runtime implementation. `runa` accepts WebSocket text messages bound to an IPv4 or
IPv6 loopback address, defaulting to `127.0.0.1:3737` without arguments.
Clap parses `--listen` / `RUNA_LISTEN` on the server and `--connect` /
`RUNA_CONNECT` on `runa-cli`; explicit flags override environment variables.
The listener is unauthenticated and intended only for
trusted local development. Both the REPL and VS Code use WebSockets.
## Version 5
Every request and response carries `version: 5`. Requests also carry a
non-empty `request_id` used to correlate the response. A server must reject
unsupported versions with `unsupported_version` rather than interpreting a
request using a different contract. Events carry the protocol version and do
not have a request ID.
Requests have a tagged command object:
```json
{
"version": 5,
"request_id": "req-1",
"command": {
"operation": "send",
"parameters": {
"actor_ref": "xK8_pQ2-Lm5_012345678",
"message": {
"type": "record",
"data": {
"op": { "type": "string", "data": "send" },
"value": { "type": "integer", "data": 9 },
"target": { "type": "actor_ref", "data": "Hj2_9sM0-pQ4_7aBcD3eF" }
}
}
}
}
}
```
The version 5 operations are source listing/read/write, commit/history/revision
retrieval/rollback, actor spawn, send, tick, inspect, actor listing, event
retrieval, privileged system messages, and cancellation.
Migration closures are intentionally excluded: they are host callbacks, not
portable wire values.
Source paths are extensionless relative paths such as `actors/counter`, not
behavior aliases. They exclude the `source/` root and `.rn` extension.
| Operation | Parameters | Reply kind/data |
| --- | --- | --- |
| `save_image` | `file`: server-local destination, `overwrite`: explicit atomic replacement authorization (defaults false) | `acknowledged` |
| `restore_image` | `file`: server-local snapshot to replace image | `acknowledged` |
| `list_sources` | none | `sources`: file array |
| `read_source` | `path` | `source`: file |
| `check_source` | `source` (including unsaved text) | `source_checked`: `{diagnostics}` |
| `write_source` | `path`, `expected_source_id` (null to create), `source` | `source`: file |
| `rename_source` | `path`, `target`, `expected_source_id`, `update_actors` | `source`: renamed file |
| `delete_source` | `path`, `expected_source_id` | `acknowledged` |
| `actor_behavior` | `actor_ref` | `actor_behavior`: `{source_id, source}` |
| `write_actor_behavior` | `actor_ref`, `expected_source_id`, `source` | `actor_behavior`: `{source_id, source}` |
| `update_behavior_source` | `actor_ref`, `path`, `expected_source_id`, `temporary_source_id` | `source`: updated original file |
| `commit_source` | `path` | `source_committed`: commit |
| `source_history` | `path` | `source_history`: commit array |
| `revision_source` | `path`, `revision` | `behavior_source`: `{source}` |
| `rollback_source` | `path`, `revision` | `source`: restored file |
| `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` |
| `inspect_execution` | `actor_ref` | `execution_inspected`: loaded source and optional execution stack |
| `set_breakpoints` | `actor_ref`, `implementation_id`, `lines` | `breakpoints_set`: `{breakpoints}` |
| `step_source` | `actor_ref`, `implementation_id`, `instruction_budget`, optional `mode` | `debug_step_completed`: `{reason}` |
| `system` | `actor_ref`, `message` | `acknowledged` |
`save_image` and `restore_image` are additive v5 operations. File paths must be
nonempty, contain no NUL and fit 4096 UTF-8 bytes. Snapshot contents are stored
on the server, not transferred through the 1 MiB WebSocket envelope.
Save refuses existing destinations unless `overwrite` is true, and refuses
unfinished handlers. An authorized replacement atomically publishes a complete
synced snapshot. Successful save leaves runnable actors paused. Restore validates and recompiles the entire snapshot
before replacing an image that has no unfinished handlers. Saved runnable actors
resume eligibility; previously paused/failed actors retain their status.
Incarnations advance to invalidate old debugger sessions. Both successful
operations emit the normal coalescible `image_changed` notification.
File/validation failures are explicit protocol errors; neither operation is
retried automatically. See [persistence semantics](runtime.md#image-save-and-restore).
These privileged commands grant server-local file access and must be used only
on a trusted machine, just like code installation.
A file is `{path, source_id, source}` and a commit is
`{revision, source_id}`. Commits generate readable random revision strings and
capture working text without compilation. Rollback restores working source
without appending history. Spawn compiles current working source even when
uncommitted; compilation failures do not spawn an actor.
Rename preserves history and atomically updates installed/pending actor path
bindings when `update_actors` is true. In-use paths require that authorization;
existing destination paths are never overwritten without explicit authorization. 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.
### Compilation diagnostics
`check_source` compiles a standalone text buffer with the image's native modules.
It does not execute, save, commit, cache, install, or emit `image_changed`.
Both invalid and valid source return `source_checked`: compiler errors/warnings
are validation results; infrastructure failures still use the error envelope.
Each diagnostic is `{severity: "error"|"warning", message, range}`. A non-null
range is `{start: {line, character}, end: {line, character}}`, with zero-based
lines, UTF-16 code-unit columns, and an exclusive end. Ranges refer to the exact
submitted text, not the current contents of a source path. `range: null` denotes
an error without a location in the submitted source. There are no synthetic
locations inferred from compiler display strings.
Compilation failures from spawn/reload/become/temporary behavior writes also
carry an optional `diagnostics` array in `ProtocolError`, retaining the existing
`runtime_failure` code and human-readable `message`. This is an additive v5
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
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, debugger}`. 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 or post-mortem stack retention. The separate
`runa-dap` adapter maps these inspections to DAP; WebSocket messages themselves
remain the image protocol.
Responses are point-in-time; clients must pause explicitly for stable inspection.
### Source debugger controls
These additive v5 mutations are available to trusted clients and broadcast
`image_changed`. Both require the expected `implementation_id` display string;
a stale installed identity returns `conflict` without mutation. Pending shared
or temporary source replacements do not retarget the controls.
`set_breakpoints` replaces the selected actor's list with up to 128 zero-based
32-bit line numbers; `[]` clears it. Returned breakpoints are sorted/deduplicated
`{line, instruction_pointers}` entries. Addresses identify mapped line entries
(line transitions, function entries, or labeled branch targets); an empty list
means the requested line is unverified. No relocation to another line occurs.
During ticks, a hit pauses that actor before execution. Resume skips that
stopped address once, not all subsequent hits.
`step_source` requires a paused actor with an active handler/shutdown invocation,
and a positive budget no greater than 10,000. It advances only that actor, stopping
according to the selected mode, another breakpoint,
pending async operation, completion/failure/removal, or budget exhaustion.
`reason` is one of `step`, `breakpoint`, `budget`, `awaiting`, `completed`,
`failed`, `removed`. Failure is an executed actor transition with explicit reason
and failure text in its ordinary snapshot, not an infrastructure success fallback.
Nonfailed surviving actors remain paused, including at a completed handler
boundary; drafts/sends commit only on normal completion. Another explicit step
may poll a pending async operation once; steps do not block waiting for it.
Native calls remain cooperative, not preemptible.
`mode` is `into` (default when absent), `over`, or `out`; other values are rejected.
Into stops at another mapped line or call-depth change. Over skips deeper calls
and stops on another mapped line at the original depth or any mapped shallower
frame. Out stops at a mapped shallower frame, or completes the invocation when
started at the outermost frame. Nested breakpoints interrupt any mode. Budgets,
pending awaits, failure and removal retain their usual stop semantics. Each
request uses the current frame as its origin; no pending over/out plan survives
a budget/await/breakpoint stop. Omitted-mode requests keep their existing into
behavior. Over/out require a server implementing this additive mode field.
`execution_inspected` adds `debugger: {breakpoints, stop_reason}` even when
`execution` is null. Older replies may omit it; Rust defaults to an empty
configuration and no reason. `stop_reason` is nullable and uses the same reason
strings as step replies. Installation clears breakpoints; restart/cancel retain
them for unchanged loaded code and clear stop reasons. Debugger state is actor
local, shared by clients, and in-memory; it is not owned by a client connection.
There are no conditions or hit counts. The standalone `runa-dap` adapter maps
DAP gutter breakpoints to these actor-local controls.
System message shape is `{kind: "stop"|"kill"|"restart"|"pause"|"resume"|"reload"}`
or `{kind: "become", path: "actors/other"}`. This is a trusted host request;
actor `ctx.send` and `ctx.send_from` accept only ordinary data. Stop runs optional
`on_shutdown` after the active handler and removes the actor on success. Kill
removes the actor immediately without shutdown, including failed actors. Removed
references return `not_found`; state, mailbox, and retained logs are discarded.
Failed actors are not automatically removed. A `Stopped` event denotes removal,
and `image_changed` invalidates actor lists. Restart is independent of stop/kill and
retains identity, mailbox, the loaded script and state; removed actors cannot be
restarted. Reload/become defer installation to
the next handler boundary. Normal scheduling advances through `tick`; the
explicit debugger step operation advances only its selected paused actor.
### Standalone Debug Adapter Protocol server
`runa-dap --connect 127.0.0.1:3737` speaks standard Content-Length-framed DAP
on stdin/stdout and connects to an existing trusted loopback image using the
WebSocket operations above. Diagnostics go to stderr. It does not start an
image or tick the scheduler implicitly. Stdio mode opens no DAP network listener.
To accept DAP over TCP instead of stdio, use
`runa-dap --listen 127.0.0.1:4711 --connect 127.0.0.1:3737`.
`RUNA_DAP_LISTEN` also selects the DAP bind address; `--listen` overrides it.
Only loopback addresses are accepted because the API is unauthenticated.
Port `0` chooses an available port, reported on stderr. TCP uses the same
Content-Length framing, handles one session at a time, and accepts a new
session after disconnect or a malformed session. Without `--listen` or
`RUNA_DAP_LISTEN`, the existing stdio behavior is unchanged.
An attach request supplies `actorRef`, optionally `behaviorSourceUri` (the
actor's `behavior.rn` editor URI) and `workingSourceUri` (its shared image source
URI). VS Code supplies these URIs automatically. The adapter exposes one actor
as one thread and binds the session to its installed implementation/incarnation.
It supports actor-local breakpoints, exact loaded source references, stack
inspection, committed snapshot scopes and watches, pause/continue, bounded
source stepping, and failure inspection. Mutable-file breakpoints must match
the exact loaded text; modified documents remain unverified. Disconnect leaves
actor status and breakpoints unchanged.
Responses echo the request ID and carry either a typed reply or a protocol
error. Events use a separate versioned envelope. Actor and source snapshots
are wire DTOs, not serialized runtime objects; implementation identities are
represented by their stable display strings. Public actor identity is a
21-character URL-safe NanoID string carried in `actor_ref`. The server maps known service
failures to the defined error codes and otherwise uses `runtime_failure`
rather than claiming a more specific category.
### Portable values
Spawn state, send messages, snapshot state/mailbox/reservations, and migration
event states use the same tagged value shape:
| Value | JSON |
| --- | --- |
| Null | `{"type":"null"}` |
| Boolean | `{"type":"bool","data":true}` |
| Signed 64-bit integer | `{"type":"integer","data":42}` |
| String | `{"type":"string","data":"hello"}` |
| List | `{"type":"list","data":[<value>, ...]}` |
| Record | `{"type":"record","data":{"key":<value>, ...}}` |
| Actor reference | `{"type":"actor_ref","data":"<21-character-NanoID>"}` |
References are distinct from strings. Their shape is validated even when
nested; existence is checked when a reference is used as a send destination,
not merely stored as data. The old message envelope and protocol v2 are not
accepted. The REPL's natural JSON and `$actor`/`$record` conveniences are
client syntax, not wire encodings.
Each state/message permits a maximum nesting depth of 32 (root depth 0),
4,096 nodes, and 1 MiB of UTF-8 string/key bytes. Requests are also subject to
the transport's 1 MiB total message limit, including tags and envelope.
Unsupported value tags, malformed references, floats, and out-of-range integers
fail decoding; well-formed requests exceeding value limits receive
`invalid_request`. Invalid Rune transition output fails the actor without
acknowledging the reserved message, changing committed state, or enqueueing
any of its sends.
`get_events` drains and returns the events currently accumulated by this
single-worker image. It is not a durable subscription or replayable event log.
The transport limits each request to 1 MiB before parsing. Malformed
JSON or an oversized message terminates that connection with an error; no protocol
response is emitted when a request cannot be decoded. A WebSocket disconnect
closes only that client connection. The listener
allows up to 16 simultaneous clients and serializes each dispatch through the
same image. WebSocket response payloads are capped at 1 MiB, with one
in-flight dispatch per client and a bounded shared dispatch queue. Every local client can use the full development
API; loopback binding is not authentication or authorization. Remote access
must not be enabled until appropriate authentication and authorization exist.
The `runa-client` crate validates response protocol versions and request IDs.
Its `WebSocketClient` connects to
loopback endpoints using bounded WebSocket messages and correlation checks,
validating interleaved notifications while waiting for responses.
Connection establishment has a 5-second timeout; WebSocket writes and response
waits have 30-second timeouts. A failed network request invalidates that client connection.
No automatic retry is performed: loss of a response does not imply that the
operation failed to commit.
The interactive REPL uses WebSockets and never owns the image. Disconnecting clients
does not stop actors or reset source history; reconnecting clients can
retrieve them through `list_actors` and `list_sources`. The network executable
loads `source/actors/counter.rn` on image startup, not on connection.
The stdio transport and child-process client have been removed.
The TypeScript VS Code client issues `list_actors`, `inspect_actor`,
source file/version-control operations, `spawn`, `send`, `tick`, and `system`.
VS Code automatically displays writable image files under a `source` directory
in its dedicated Runa sidebar's source tree, without modifying workspace folders,
and caches read-only commit documents by connection, path, and revision.
It preserves i64 inputs and bounds the complete outgoing envelope as well as
incoming messages. It enforces loopback endpoints, bounded UTF-8 text messages,
version/request-ID/reply-kind checks, and portable snapshot value validation.
It decodes JSON integers as `bigint` and prints them losslessly, including
signed 64-bit state and unsigned 64-bit telemetry. It uses the same 5-second
connect and 30-second request timeouts, closes on transport/protocol corruption,
and never retries automatically. A well-formed server error is surfaced without
invalidating the stream. Server notifications trigger automatic refreshes of
the actor tree and open snapshot documents. Manual refresh remains available.
## WebSocket transport and notifications
### Actor editing and log subscriptions (v5)
- `edit_actor {actor_ref,state,messages}`: `state` is a tagged portable value or
null to leave it unchanged. `messages` is null to leave the mailbox unchanged,
or `{reserved,queued}` where reserved is a tagged value or null for no
reservation, and queued is an ordered array of tagged values. At least one
edit is required. Only paused/failed actors with no active execution
are editable; values and mailbox capacity are validated atomically.
- `set_log_level {actor_ref,level}`: level is one of `trace`, `debug`, `info`,
`warn`, `error`, `fatal`. Both mutations return `acknowledged` and invalidate
image views. Actor snapshots include `log_level`.
- `logs {actors,follow,after}`: actors is an array of references (empty means
all); after is an exclusive u64 sequence cursor. Reply kind `logs` carries
`{entries,next_sequence,has_more}`, with at most 128 entries per page.
Each entry is `{sequence,actor_ref,level,message}`. Missing actors are errors.
History queries are read-only and do not invalidate views.
- With follow true, the connection registers the filter before history is read.
Subsequent notification envelopes carry
`{event:"log",data:{sequence,actor_ref,level,message}}`. Reissuing `logs`
replaces the filter; follow false unsubscribes. Matching entries newer than
the reply's cursor are emitted after its reply, allowing history/follow
deduplication by sequence. Other connections receive no log events.
Transport and client buffers are bounded at 1,024 entries; lag is an explicit
connection error, not a guaranteed-delivery stream. Disconnect removes the
subscription. Image invalidations remain coalescible and independent of logs.
On `quit`, `exit`, Ctrl+C, or end-of-input, the CLI completes the WebSocket close
handshake with a 5-second deadline before releasing the connection. A failed
handshake is reported rather than treating an abrupt reset as a normal close.
Connect to `ws://127.0.0.1:3737/` (or a configured numeric loopback endpoint)
and offer the `runa-debug` WebSocket subprotocol. The server requires and echoes
that subprotocol. Plain TCP JSON Lines is no longer accepted by the network
listener. The request/response contract is version 5.
Each WebSocket text message contains exactly one JSON request, response, or
notification, without newline framing. Binary messages and malformed requests
terminate the connection. Fragmented text is supported with a 1 MiB aggregate
message limit; individual frames are also limited to 1 MiB. Compression is not
enabled. Upgrade handshakes have a 5-second timeout and writes have a
30-second timeout so stalled peers do not hold client slots forever.
The endpoint accepts only the root path and rejects requests carrying an
`Origin` header: browser-origin access is not enabled for this unauthenticated
local API. This is not authentication; local non-browser clients retain full
development privileges.
Every upgraded connection automatically receives non-destructive invalidations:
```json
{
"version": 5,
"event": {
"event": "image_changed",
"data": { "revision": 42 }
}
}
```
Successful mutating requests (spawn, send, tick, lifecycle controls,
source writes/commits/rollback, and system messages) increment a server-session revision
and notify all connected clients, including the initiator. Read-only requests,
failed mutations, and `get_events` do not generate invalidations. A tick
invalidates snapshots even when it only advances execution telemetry or causes
an actor failure. A successful send invalidates mailbox snapshots before any
handler runs. Notifications may appear between requests and their replies;
clients must distinguish them from correlated responses.
Revisions increase within one server session, not across server restarts.
Slow subscribers retain only the latest unsent revision; gaps are expected.
These notifications are hints to re-fetch authoritative data, not individual
runtime events, a durable log, an atomic snapshot revision, or replay.
On connection/reconnection, fetch a full listing first. The VS Code client
coalesces bursts and schedules another refresh if an invalidation arrives
during an in-flight read. Refresh never advances the scheduler or calls `get_events`;
the REPL's explicit `events` command still drains the separate runtime queue.
The current validation checks the protocol version, request ID, actor-reference
shape, structured-value limits, revision ID shape, non-empty paths, mailbox
capacity, and instruction budget. Canonical source paths, actor existence,
expected source identities, mailbox capacity at send time, and all runtime
invariants remain authoritative at the application-service boundary.