diff --git a/CHANGELOG.md b/CHANGELOG.md index 27ed7a4..dcf1b45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 64212f6..921660d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/runa-actor/src/lib.rs b/crates/runa-actor/src/lib.rs index e0deda9..af70e82 100644 --- a/crates/runa-actor/src/lib.rs +++ b/crates/runa-actor/src/lib.rs @@ -42,4 +42,5 @@ pub enum Event { Failed(ActorId, String), Stopped(ActorId), Restarted(ActorId), + BehaviorInstalled(ActorId, ImplementationId), } diff --git a/crates/runa-code/src/lib.rs b/crates/runa-code/src/lib.rs index 14225db..d9b4b79 100644 --- a/crates/runa-code/src/lib.rs +++ b/crates/runa-code/src/lib.rs @@ -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, +} + #[derive(Default)] pub struct CodeStore { sources: BTreeMap>, artifacts: BTreeMap>, + behaviors: BTreeMap>, } 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, + source: &str, + ) -> ExperimentResult { + 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 { + 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 { + self.behaviors + .get(name) + .map(|history| snapshot(name, history)) + } + + pub fn behaviors(&self) -> Vec { + 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}" + ); + } + } } diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index d635e5f..51e5c47 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -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 { 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; diff --git a/docs/runtime.md b/docs/runtime.md index 5047332..b7fa8bd 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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.