feat: add versioned behavior registry

Add optimistic behavior publishing, immutable revision history and rollback, plus runtime implementation installation at idle handler boundaries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-02 10:18:07 +01:00
parent c04a7ed12f
commit 3be18f2f53
6 changed files with 284 additions and 7 deletions

View File

@ -18,6 +18,9 @@ All notable changes to this project will be documented in this file.
- Versioned BLAKE3 source and implementation identities, original-source
retention, a Rune compile-input fingerprint, and an in-memory compiled
artifact cache.
- In-memory named behavior revisions with optimistic publish checks, immutable
history, rollback revisions, and runtime installation at idle handler
boundaries.
- Execution-model decision and Phase 1 runtime documentation.
- Lifecycle tests proving failed-message retry from committed state and
stop/restart preservation of reserved-first FIFO order.

View File

@ -13,9 +13,11 @@ experiment using Rune 0.14.2: script execution, native calls, async suspension,
instruction budgets, stepping, and compiled-code sharing. It also runs a minimal
single-worker actor demonstration with bounded mailboxes and explicit message
effects. Source and compiled implementation identities are BLAKE3 content
addresses with an in-memory compile cache. Persistence, behavior editing,
canonicalization, the graphical world, and development API are not implemented
yet.
addresses with an in-memory compile cache. Persistence, interactive behavior
editing, canonicalization, the graphical world, and development API are not
implemented yet. The code library now supports an in-memory versioned behavior
registry, and the runtime can install an implementation at an idle handler
boundary; these operations are not yet exposed in the CLI.
The prototype uses worker-local invocation stacks and cooperative instruction
slices. Native callbacks are not preemptible.

View File

@ -42,4 +42,5 @@ pub enum Event {
Failed(ActorId, String),
Stopped(ActorId),
Restarted(ActorId),
BehaviorInstalled(ActorId, ImplementationId),
}

View File

@ -66,10 +66,25 @@ impl CompiledArtifact {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BehaviorVersion {
pub revision: u64,
pub implementation_id: ImplementationId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BehaviorSnapshot {
pub name: String,
pub revision: u64,
pub current: ImplementationId,
pub history: Vec<BehaviorVersion>,
}
#[derive(Default)]
pub struct CodeStore {
sources: BTreeMap<SourceId, Arc<str>>,
artifacts: BTreeMap<ImplementationId, Arc<CompiledArtifact>>,
behaviors: BTreeMap<String, Vec<BehaviorVersion>>,
}
impl CodeStore {
@ -139,6 +154,115 @@ impl CodeStore {
pub fn artifact_count(&self) -> usize {
self.artifacts.len()
}
/// Compile and publish only if the caller's expected revision is current.
///
/// A new behavior requires `expected_revision == None`; updating an
/// existing behavior requires its current revision.
pub fn publish_behavior(
&mut self,
name: &str,
expected_revision: Option<u64>,
source: &str,
) -> ExperimentResult<BehaviorSnapshot> {
validate_behavior_name(name)?;
let history = self.behaviors.get(name);
let current_revision = history.and_then(|versions| versions.last().map(|v| v.revision));
if current_revision != expected_revision {
return Err(format!(
"behavior revision conflict: expected {expected_revision:?}, current {current_revision:?}"
)
.into());
}
let artifact = self.compile(source)?;
let revision = current_revision
.unwrap_or(0)
.checked_add(1)
.ok_or("behavior revision exhausted")?;
let history = self.behaviors.entry(name.to_owned()).or_default();
history.push(BehaviorVersion {
revision,
implementation_id: artifact.implementation_id,
});
Ok(snapshot(name, history))
}
/// Move the behavior reference to a prior immutable implementation.
/// Rollback creates a new revision; history is never rewritten.
pub fn rollback_behavior(
&mut self,
name: &str,
expected_revision: u64,
target_revision: u64,
) -> ExperimentResult<BehaviorSnapshot> {
validate_behavior_name(name)?;
let history = self.behaviors.get_mut(name).ok_or("unknown behavior")?;
let current = history.last().ok_or("behavior has no revisions")?;
if current.revision != expected_revision {
return Err(format!(
"behavior revision conflict: expected {expected_revision}, current {}",
current.revision
)
.into());
}
let target = history
.iter()
.find(|version| version.revision == target_revision)
.ok_or("unknown target behavior revision")?;
let implementation_id = target.implementation_id;
let revision = current
.revision
.checked_add(1)
.ok_or("behavior revision exhausted")?;
history.push(BehaviorVersion {
revision,
implementation_id,
});
Ok(snapshot(name, history))
}
pub fn behavior(&self, name: &str) -> Option<BehaviorSnapshot> {
self.behaviors
.get(name)
.map(|history| snapshot(name, history))
}
pub fn behaviors(&self) -> Vec<BehaviorSnapshot> {
self.behaviors
.iter()
.map(|(name, history)| snapshot(name, history))
.collect()
}
pub fn behavior_history(&self, name: &str) -> Option<&[BehaviorVersion]> {
self.behaviors.get(name).map(Vec::as_slice)
}
}
fn validate_behavior_name(name: &str) -> ExperimentResult<()> {
if name.is_empty()
|| name.len() > 128
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/'))
|| name
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
{
return Err("invalid behavior name".into());
}
Ok(())
}
fn snapshot(name: &str, history: &[BehaviorVersion]) -> BehaviorSnapshot {
let current = history.last().expect("published behavior has history");
BehaviorSnapshot {
name: name.to_owned(),
revision: current.revision,
current: current.implementation_id,
history: history.to_vec(),
}
}
pub fn source_identity(original_source: &str) -> SourceId {
@ -235,4 +359,70 @@ mod tests {
assert_ne!(first.implementation_id, second.implementation_id);
Ok(())
}
#[test]
fn behavior_publish_checks_revisions_and_keeps_immutable_history() -> ExperimentResult<()> {
let mut store = CodeStore::default();
let first = store.publish_behavior("actors/counter", None, "pub fn add(a, b) { a + b }")?;
assert_eq!(first.revision, 1);
assert!(
store
.publish_behavior("actors/counter", None, "pub fn add(a, b) { a - b }")
.is_err()
);
let second =
store.publish_behavior("actors/counter", Some(1), "pub fn add(a, b) { a - b }")?;
assert_eq!(second.revision, 2);
assert_ne!(first.current, second.current);
assert_eq!(store.behavior_history("actors/counter").unwrap().len(), 2);
assert_eq!(
store.behavior("actors/counter").unwrap().current,
second.current
);
Ok(())
}
#[test]
fn failed_compile_does_not_change_behavior_reference() -> ExperimentResult<()> {
let mut store = CodeStore::default();
let first = store.publish_behavior("counter", None, "pub fn add(a, b) { a + b }")?;
assert!(
store
.publish_behavior("counter", Some(1), "pub fn broken( {")
.is_err()
);
let current = store.behavior("counter").unwrap();
assert_eq!(current.revision, first.revision);
assert_eq!(current.current, first.current);
assert_eq!(current.history.len(), 1);
Ok(())
}
#[test]
fn rollback_appends_new_revision_and_conflicts_are_rejected() -> ExperimentResult<()> {
let mut store = CodeStore::default();
let first = store.publish_behavior("counter", None, "pub fn add(a, b) { a + b }")?;
let second = store.publish_behavior("counter", Some(1), "pub fn add(a, b) { a - b }")?;
assert!(store.rollback_behavior("counter", 1, 1).is_err());
assert!(store.rollback_behavior("counter", 2, 99).is_err());
let rolled_back = store.rollback_behavior("counter", 2, 1)?;
assert_eq!(rolled_back.revision, 3);
assert_eq!(rolled_back.current, first.current);
assert_eq!(rolled_back.history.len(), 3);
assert_eq!(rolled_back.history[1].implementation_id, second.current);
Ok(())
}
#[test]
fn behavior_names_are_validated() {
let mut store = CodeStore::default();
for invalid in ["", "../escape", "a//b", ".", "a b", "a\\b"] {
assert!(
store
.publish_behavior(invalid, None, "pub fn add(a, b) { a + b }")
.is_err(),
"accepted invalid behavior name: {invalid}"
);
}
}
}

View File

@ -152,6 +152,28 @@ impl Runtime {
Ok(())
}
/// Install an immutable implementation between handler invocations.
pub fn install(
&mut self,
id: ActorId,
implementation: ImplementationId,
) -> ExperimentResult<()> {
if !self.implementations.contains_key(&implementation) {
return Err("unknown implementation".into());
}
let actor = self.actors.get_mut(&id).ok_or("unknown actor")?;
if actor.active.is_some()
|| actor.snapshot.reserved.is_some()
|| actor.snapshot.status != Status::Idle
{
return Err("behavior can only be installed at an idle handler boundary".into());
}
actor.snapshot.implementation = implementation;
self.events
.push(Event::BehaviorInstalled(id, implementation));
Ok(())
}
pub fn drain_events(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
@ -514,6 +536,45 @@ mod tests {
Ok(())
}
#[test]
fn behavior_installation_waits_for_handler_boundary() -> ExperimentResult<()> {
let mut code = CodeStore::default();
let first = code.compile("pub fn handle(state, kind, value) { (state + value, []) }")?;
let second =
code.compile("pub fn handle(state, kind, value) { (state + value * 10, []) }")?;
let first_id = first.implementation_id;
let second_id = second.implementation_id;
let mut runtime = Runtime::default();
runtime.publish(first)?;
runtime.publish(second)?;
let actor = runtime.spawn(first_id, 0, capacity(2))?;
runtime.send(actor, Message { kind: 0, value: 1 })?;
runtime.tick(capacity(1));
let active = runtime.inspect(actor)?;
assert_eq!(active.implementation, first_id);
assert!(runtime.install(actor, second_id).is_err());
for _ in 0..100 {
runtime.tick(capacity(10));
}
assert_eq!(runtime.inspect(actor)?.state, 1);
runtime.send(actor, Message { kind: 0, value: 2 })?;
runtime.install(actor, second_id)?;
assert_eq!(runtime.inspect(actor)?.implementation, second_id);
for _ in 0..100 {
runtime.tick(capacity(10));
}
let updated = runtime.inspect(actor)?;
assert_eq!(updated.state, 21);
assert_eq!(updated.implementation, second_id);
assert!(
runtime
.drain_events()
.iter()
.any(|event| matches!(event, Event::BehaviorInstalled(id, version) if *id == actor && *version == second_id))
);
Ok(())
}
#[test]
fn restart_retries_failed_reservation_from_last_committed_state() -> ExperimentResult<()> {
use std::sync::atomic::AtomicBool;

View File

@ -35,8 +35,8 @@ The default per-actor slice budget is 32 instructions.
- `runa-actor` owns actor, mailbox and implementation IDs, structured messages,
snapshots, statuses and lifecycle events.
- `runa-core` owns stable source and implementation identity types.
- `runa-code` preserves original source, derives identities, and caches
compiled artifacts for the process lifetime.
- `runa-code` preserves original source, derives identities, caches compiled
artifacts, and provides an in-memory named behavior registry with revisions.
- `runa-rune` owns compilation and typed bounded invocation execution.
- `runa-runtime` owns implementation registration, queues, private committed
state, scheduling, message effects and lifecycle coordination.
@ -47,6 +47,24 @@ implementation. Implementation IDs use source content and the compile
fingerprint; actor and mailbox IDs remain process-local counters. Actor IDs and
incarnation counters use checked increments.
`CodeStore::publish_behavior` compiles source before appending a behavior
revision. Creation expects no existing revision; updates require the caller's
expected current revision, so stale edits fail without changing the behavior
reference. Failed compilation also leaves the reference and history unchanged
(the exact source remains available for diagnostics). Rollback points the
behavior at an earlier immutable implementation and appends a new monotonically
increasing revision; it does not rewrite history. Behavior names are
case-sensitive paths limited to ASCII letters, digits, `_`, `-`, `.`, and `/`,
with empty, `.` and `..` path components rejected.
The runtime's `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. This is an in-memory library capability; the interactive CLI
does not yet expose behavior publishing, history, rollback, or installation.
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
@ -132,8 +150,10 @@ targets, aggregate capacity, and successful message effects.
## Limits
This is not yet a full image: no persistent mailboxes, transactional outbox,
supervisors, hot upgrades, API server, GUI, or automatic image bootstrap.
This is not yet a full image: no persistent behavior registry or mailboxes,
transactional outbox, supervisors, API server, GUI, or automatic image
bootstrap. The initial implementation installation API is process-local and
requires an idle handler boundary; it does not upgrade active execution.
The example default script is bundled through `include_str!`.
Messages are not accepted by stopped/failed actors.