add axon-events: typed event schema for the full neuron-technologies stack

This commit is contained in:
2026-04-27 20:01:39 -05:00
parent 745278c902
commit 7a81bb11a7
17 changed files with 2115 additions and 36 deletions
+1
View File
@@ -5,6 +5,7 @@ members = [
"crates/neuron-store",
"crates/neuron-api",
"crates/neuron-protocol",
"crates/axon-events",
]
[workspace.dependencies]
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "axon-events"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
+190
View File
@@ -0,0 +1,190 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::project::ProjectType;
/// Opaque unique identifier for an event.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct EventId(pub Uuid);
impl EventId {
/// Generate a new random event ID.
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for EventId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for EventId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Milliseconds since the Unix epoch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Timestamp(pub i64);
impl Timestamp {
/// Current time as a millisecond timestamp.
pub fn now() -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before epoch")
.as_millis() as i64;
Self(ms)
}
/// Construct from a raw millisecond value.
pub fn from_ms(ms: i64) -> Self {
Self(ms)
}
/// Return the raw millisecond value.
pub fn as_ms(self) -> i64 {
self.0
}
}
impl Default for Timestamp {
fn default() -> Self {
Self::now()
}
}
/// Diagnostic / compile-error severity level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
#[default]
Info,
Warning,
Error,
}
/// Universal envelope that wraps every typed event.
///
/// Services never send raw events — they always send an `AxonEnvelope<T>`.
/// This provides routing metadata (project type, timestamp) without
/// coupling the event payload itself to those concerns.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AxonEnvelope<T> {
/// Unique ID for this event instance.
pub event_id: EventId,
/// Which project emitted this event.
pub project_type: ProjectType,
/// When the event was created (ms since epoch).
pub timestamp: Timestamp,
/// The typed event payload.
pub payload: T,
}
impl<T: Serialize + for<'de> Deserialize<'de>> AxonEnvelope<T> {
/// Wrap a payload in an envelope for the given project type.
pub fn new(project_type: ProjectType, payload: T) -> Self {
Self {
event_id: EventId::new(),
project_type,
timestamp: Timestamp::now(),
payload,
}
}
/// Serialise this envelope to a JSON string.
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
/// Deserialise an envelope from a JSON string.
pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::ProjectType;
#[test]
fn event_id_is_unique() {
let a = EventId::new();
let b = EventId::new();
assert_ne!(a, b);
}
#[test]
fn event_id_default_is_unique() {
let a = EventId::default();
let b = EventId::default();
assert_ne!(a, b);
}
#[test]
fn timestamp_now_is_positive() {
let t = Timestamp::now();
assert!(t.as_ms() > 0);
}
#[test]
fn timestamp_roundtrip() {
let t = Timestamp::from_ms(1_700_000_000_000);
let json = serde_json::to_string(&t).unwrap();
let back: Timestamp = serde_json::from_str(&json).unwrap();
assert_eq!(back.as_ms(), 1_700_000_000_000);
}
#[test]
fn severity_serde_roundtrip() {
for s in [Severity::Info, Severity::Warning, Severity::Error] {
let json = serde_json::to_string(&s).unwrap();
let back: Severity = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
}
#[test]
fn severity_default_is_info() {
assert_eq!(Severity::default(), Severity::Info);
}
#[test]
fn axon_envelope_roundtrip() {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct Ping {
msg: String,
}
let payload = Ping { msg: "hello".into() };
let env = AxonEnvelope::new(ProjectType::NeuronRs, payload);
let json = env.to_json().unwrap();
let back: AxonEnvelope<Ping> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.project_type, ProjectType::NeuronRs);
assert_eq!(back.payload.msg, "hello");
assert_eq!(back.event_id, env.event_id);
}
#[test]
fn axon_envelope_timestamp_is_recent() {
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Empty;
let env = AxonEnvelope::new(ProjectType::Engram, Empty);
let now = Timestamp::now().as_ms();
// Should be within 5 seconds
assert!(now - env.timestamp.as_ms() < 5_000);
}
#[test]
fn event_id_display() {
let id = EventId(Uuid::nil());
assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
}
}
+183
View File
@@ -0,0 +1,183 @@
use serde::{Deserialize, Serialize};
use crate::common::Severity;
/// Events emitted by the engram-lang compiler toolchain.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CompilerEvent {
CompileStarted(CompileStarted),
CompileSucceeded(CompileSucceeded),
CompileFailed(CompileFailed),
TestRunStarted(TestRunStarted),
TestPassed(TestPassed),
TestFailed(TestFailed),
TestRunCompleted(TestRunCompleted),
}
/// Compilation target platform.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum CompileTarget {
#[default]
Web,
Native,
Sealed,
}
/// A compilation job has started.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompileStarted {
pub file: String,
pub target: CompileTarget,
}
/// Compilation finished successfully and produced an artifact.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompileSucceeded {
pub file: String,
pub target: CompileTarget,
pub artifact_path: String,
pub duration_ms: u64,
}
/// Compilation failed with one or more errors.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompileFailed {
pub file: String,
pub target: CompileTarget,
pub errors: Vec<CompileError>,
}
/// A single compiler diagnostic.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompileError {
pub line: u32,
pub col: u32,
pub message: String,
pub severity: Severity,
}
/// A test run has started.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestRunStarted {
pub test_count: u64,
}
/// An individual test passed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestPassed {
pub name: String,
pub duration_ms: u64,
}
/// An individual test failed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestFailed {
pub name: String,
pub message: String,
pub duration_ms: u64,
}
/// All tests in the run have finished.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestRunCompleted {
pub passed: u64,
pub failed: u64,
pub duration_ms: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn compile_started_roundtrip() {
let evt = CompileStarted {
file: "src/main.el".into(),
target: CompileTarget::Web,
};
let env = AxonEnvelope::new(ProjectType::EngramLang, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<CompileStarted> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.file, "src/main.el");
assert_eq!(back.payload.target, CompileTarget::Web);
}
#[test]
fn compile_failed_with_errors() {
let evt = CompileFailed {
file: "lib.el".into(),
target: CompileTarget::Native,
errors: vec![
CompileError {
line: 10,
col: 5,
message: "unexpected token".into(),
severity: Severity::Error,
},
CompileError {
line: 20,
col: 1,
message: "unused variable".into(),
severity: Severity::Warning,
},
],
};
let json = serde_json::to_string(&evt).unwrap();
let back: CompileFailed = serde_json::from_str(&json).unwrap();
assert_eq!(back.errors.len(), 2);
assert_eq!(back.errors[0].severity, Severity::Error);
assert_eq!(back.errors[1].severity, Severity::Warning);
}
#[test]
fn test_run_completed_roundtrip() {
let evt = TestRunCompleted {
passed: 42,
failed: 3,
duration_ms: 1500,
};
let json = serde_json::to_string(&evt).unwrap();
let back: TestRunCompleted = serde_json::from_str(&json).unwrap();
assert_eq!(back.passed, 42);
assert_eq!(back.failed, 3);
}
#[test]
fn compile_target_default_is_web() {
assert_eq!(CompileTarget::default(), CompileTarget::Web);
}
#[test]
fn compiler_event_enum_roundtrip() {
let variant = CompilerEvent::TestFailed(TestFailed {
name: "test_parse".into(),
message: "assertion failed".into(),
duration_ms: 10,
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("test_failed"));
let back: CompilerEvent = serde_json::from_str(&json).unwrap();
if let CompilerEvent::TestFailed(t) = back {
assert_eq!(t.name, "test_parse");
} else {
panic!("wrong variant");
}
}
#[test]
fn compile_succeeded_envelope() {
let evt = CompileSucceeded {
file: "app.el".into(),
target: CompileTarget::Sealed,
artifact_path: "dist/app.sealed".into(),
duration_ms: 2300,
};
let env = AxonEnvelope::new(ProjectType::EngramLang, evt);
assert_eq!(env.project_type, ProjectType::EngramLang);
assert_eq!(env.payload.artifact_path, "dist/app.sealed");
}
}
+156
View File
@@ -0,0 +1,156 @@
use serde::{Deserialize, Serialize};
/// Events emitted by the engram graph-database layer.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EngramEvent {
NodeCreated(NodeCreated),
NodeUpdated(NodeUpdated),
NodeDeleted(NodeDeleted),
EdgeCreated(EdgeCreated),
ActivationCompleted(ActivationCompleted),
ConsolidationRan(ConsolidationRan),
SyncStarted(SyncStarted),
SyncCompleted(SyncCompleted),
SyncFailed(SyncFailed),
}
/// A new node was inserted into the graph.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NodeCreated {
pub id: String,
pub node_type: String,
pub salience: f64,
}
/// An existing node's fields were modified.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NodeUpdated {
pub id: String,
pub node_type: String,
/// Free-form list of field names that changed.
pub changes: Vec<String>,
}
/// A node was removed from the graph.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NodeDeleted {
pub id: String,
pub node_type: String,
}
/// A directed edge was added between two nodes.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EdgeCreated {
pub from_id: String,
pub to_id: String,
pub edge_type: String,
}
/// Spreading activation finished from a root node.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ActivationCompleted {
pub root_id: String,
pub activated_count: u64,
pub duration_ms: u64,
}
/// The consolidation pass ran — promotes high-salience nodes.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConsolidationRan {
pub promoted_count: u64,
pub duration_ms: u64,
}
/// A peer-sync session began.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SyncStarted {
pub peer_id: String,
pub peer_name: String,
}
/// A peer-sync session completed successfully.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SyncCompleted {
pub peer_id: String,
pub nodes_sent: u64,
pub nodes_received: u64,
pub duration_ms: u64,
}
/// A peer-sync session failed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SyncFailed {
pub peer_id: String,
pub error: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn node_created_envelope_roundtrip() {
let evt = NodeCreated {
id: "node-1".into(),
node_type: "memory".into(),
salience: 0.85,
};
let env = AxonEnvelope::new(ProjectType::Engram, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<NodeCreated> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.id, "node-1");
assert_eq!(back.payload.node_type, "memory");
assert!((back.payload.salience - 0.85).abs() < f64::EPSILON);
}
#[test]
fn edge_created_roundtrip() {
let evt = EdgeCreated {
from_id: "a".into(),
to_id: "b".into(),
edge_type: "relates_to".into(),
};
let json = serde_json::to_string(&evt).unwrap();
let back: EdgeCreated = serde_json::from_str(&json).unwrap();
assert_eq!(back.from_id, "a");
assert_eq!(back.to_id, "b");
}
#[test]
fn activation_completed_defaults() {
let evt = ActivationCompleted::default();
assert_eq!(evt.activated_count, 0);
assert_eq!(evt.duration_ms, 0);
}
#[test]
fn engram_event_enum_serde() {
let variant = EngramEvent::SyncFailed(SyncFailed {
peer_id: "peer-99".into(),
error: "connection refused".into(),
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("sync_failed"));
let back: EngramEvent = serde_json::from_str(&json).unwrap();
if let EngramEvent::SyncFailed(f) = back {
assert_eq!(f.peer_id, "peer-99");
} else {
panic!("wrong variant");
}
}
#[test]
fn sync_completed_envelope() {
let evt = SyncCompleted {
peer_id: "p1".into(),
nodes_sent: 10,
nodes_received: 5,
duration_ms: 200,
};
let env = AxonEnvelope::new(ProjectType::Engram, evt);
assert_eq!(env.project_type, ProjectType::Engram);
}
}
+163
View File
@@ -0,0 +1,163 @@
use serde::{Deserialize, Serialize};
use crate::common::Severity;
/// Events emitted by the el-ide editor.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum IdeEvent {
FileOpened(FileOpened),
FileSaved(FileSaved),
DiagnosticPublished(DiagnosticPublished),
CompletionRequested(CompletionRequested),
HoverRequested(HoverRequested),
GoToDefinitionRequested(GoToDefinitionRequested),
FormatRequested(FormatRequested),
ActionExecuted(ActionExecuted),
}
/// A file was opened in the editor.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FileOpened {
pub path: String,
}
/// A file was saved.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FileSaved {
pub path: String,
}
/// A single IDE diagnostic (error, warning, or info).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Diagnostic {
pub line: u32,
pub col: u32,
pub message: String,
pub severity: Severity,
}
/// New diagnostics were published for a file.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DiagnosticPublished {
pub path: String,
pub diagnostics: Vec<Diagnostic>,
}
/// An auto-completion was requested at a cursor position.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompletionRequested {
pub path: String,
pub line: u32,
pub col: u32,
}
/// A hover tooltip was requested at a cursor position.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HoverRequested {
pub path: String,
pub line: u32,
pub col: u32,
}
/// Go-to-definition was requested at a cursor position.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GoToDefinitionRequested {
pub path: String,
pub line: u32,
pub col: u32,
}
/// Document formatting was requested for a file.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FormatRequested {
pub path: String,
}
/// A named IDE action was executed (e.g. "run-tests", "open-terminal").
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ActionExecuted {
pub action_name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn file_opened_roundtrip() {
let evt = FileOpened {
path: "src/main.el".into(),
};
let env = AxonEnvelope::new(ProjectType::ElIde, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<FileOpened> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.path, "src/main.el");
assert_eq!(back.project_type, ProjectType::ElIde);
}
#[test]
fn diagnostic_published_with_multiple() {
let evt = DiagnosticPublished {
path: "lib.el".into(),
diagnostics: vec![
Diagnostic {
line: 5,
col: 3,
message: "type mismatch".into(),
severity: Severity::Error,
},
Diagnostic {
line: 12,
col: 1,
message: "shadowed binding".into(),
severity: Severity::Warning,
},
],
};
let json = serde_json::to_string(&evt).unwrap();
let back: DiagnosticPublished = serde_json::from_str(&json).unwrap();
assert_eq!(back.diagnostics.len(), 2);
assert_eq!(back.diagnostics[0].severity, Severity::Error);
}
#[test]
fn completion_requested_roundtrip() {
let evt = CompletionRequested {
path: "src/lib.el".into(),
line: 42,
col: 10,
};
let json = serde_json::to_string(&evt).unwrap();
let back: CompletionRequested = serde_json::from_str(&json).unwrap();
assert_eq!(back.line, 42);
assert_eq!(back.col, 10);
}
#[test]
fn ide_event_enum_roundtrip() {
let variant = IdeEvent::ActionExecuted(ActionExecuted {
action_name: "run-tests".into(),
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("action_executed"));
let back: IdeEvent = serde_json::from_str(&json).unwrap();
if let IdeEvent::ActionExecuted(a) = back {
assert_eq!(a.action_name, "run-tests");
} else {
panic!("wrong variant");
}
}
#[test]
fn format_requested_roundtrip() {
let evt = FormatRequested {
path: "src/fmt.el".into(),
};
let json = serde_json::to_string(&evt).unwrap();
let back: FormatRequested = serde_json::from_str(&json).unwrap();
assert_eq!(back.path, "src/fmt.el");
}
}
+37
View File
@@ -0,0 +1,37 @@
#![deny(warnings)]
//! `axon-events` — typed event schema for the full neuron-technologies stack.
//!
//! Every service emits and consumes typed events wrapped in [`common::AxonEnvelope`].
//! Events are grouped by the project that emits them:
//!
//! | Module | Project |
//! |--------|---------|
//! | [`engram`] | engram database layer |
//! | [`compiler`] | engram-lang compiler |
//! | [`ui`] | el-ui frontend framework |
//! | [`ide`] | el-ide editor |
//! | [`runtime`] | neuron-rs agent host |
//! | [`publish`] | el-publish app-store pipeline |
//!
//! [`project`] defines [`project::ProjectType`], the discriminant tag
//! carried by every envelope.
//!
//! [`routing`] provides [`routing::AxonRouter`], the type-safe async
//! event dispatcher, and [`routing::AxonSubscription`] for subscription
//! negotiation.
pub mod common;
pub mod compiler;
pub mod engram;
pub mod ide;
pub mod project;
pub mod publish;
pub mod routing;
pub mod runtime;
pub mod ui;
// Convenience re-exports
pub use common::{AxonEnvelope, EventId, Severity, Timestamp};
pub use project::{ProjectContext, ProjectType};
pub use routing::{AxonError, AxonRouter, AxonSubscription};
+136
View File
@@ -0,0 +1,136 @@
use serde::{Deserialize, Serialize};
/// Every service in the neuron-technologies stack is identified by a
/// `ProjectType`. All Axon envelopes carry this tag so routers and
/// subscribers can filter without inspecting the payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectType {
/// engram — the graph database layer.
Engram,
/// engram-lang — the compiler / language toolchain.
EngramLang,
/// el-ui — the cross-platform UI framework.
ElUi,
/// el-ide — the integrated development environment.
ElIde,
/// neuron-rs — the Rust runtime / agent host.
NeuronRs,
/// neuron-agent — the cognitive agent layer.
NeuronAgent,
/// el-publish — the app-store publishing pipeline.
ElPublish,
}
impl std::fmt::Display for ProjectType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Engram => "engram",
Self::EngramLang => "engram_lang",
Self::ElUi => "el_ui",
Self::ElIde => "el_ide",
Self::NeuronRs => "neuron_rs",
Self::NeuronAgent => "neuron_agent",
Self::ElPublish => "el_publish",
};
write!(f, "{}", s)
}
}
/// Contextual metadata attached to a service or session — optional, but
/// useful when a single process manages multiple project contexts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectContext {
pub project_type: ProjectType,
/// Human-readable label for this context (e.g. service instance name).
pub label: Option<String>,
/// Semantic version of the emitting service, e.g. `"0.3.1"`.
pub version: Option<String>,
}
impl ProjectContext {
pub fn new(project_type: ProjectType) -> Self {
Self {
project_type,
label: None,
version: None,
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_type_serde_roundtrip() {
let variants = [
ProjectType::Engram,
ProjectType::EngramLang,
ProjectType::ElUi,
ProjectType::ElIde,
ProjectType::NeuronRs,
ProjectType::NeuronAgent,
ProjectType::ElPublish,
];
for v in variants {
let json = serde_json::to_string(&v).unwrap();
let back: ProjectType = serde_json::from_str(&json).unwrap();
assert_eq!(back, v, "roundtrip failed for {:?}", v);
}
}
#[test]
fn project_type_display() {
assert_eq!(ProjectType::Engram.to_string(), "engram");
assert_eq!(ProjectType::EngramLang.to_string(), "engram_lang");
assert_eq!(ProjectType::NeuronAgent.to_string(), "neuron_agent");
}
#[test]
fn project_context_builder() {
let ctx = ProjectContext::new(ProjectType::ElUi)
.with_label("renderer-1")
.with_version("1.2.3");
assert_eq!(ctx.project_type, ProjectType::ElUi);
assert_eq!(ctx.label.as_deref(), Some("renderer-1"));
assert_eq!(ctx.version.as_deref(), Some("1.2.3"));
}
#[test]
fn project_context_serde_roundtrip() {
let ctx = ProjectContext::new(ProjectType::NeuronRs)
.with_label("runtime")
.with_version("0.1.0");
let json = serde_json::to_string(&ctx).unwrap();
let back: ProjectContext = serde_json::from_str(&json).unwrap();
assert_eq!(back.project_type, ProjectType::NeuronRs);
assert_eq!(back.label.as_deref(), Some("runtime"));
}
#[test]
fn project_type_all_variants_have_unique_display() {
use std::collections::HashSet;
let variants = [
ProjectType::Engram,
ProjectType::EngramLang,
ProjectType::ElUi,
ProjectType::ElIde,
ProjectType::NeuronRs,
ProjectType::NeuronAgent,
ProjectType::ElPublish,
];
let strings: HashSet<String> = variants.iter().map(|v| v.to_string()).collect();
assert_eq!(strings.len(), variants.len());
}
}
+169
View File
@@ -0,0 +1,169 @@
use serde::{Deserialize, Serialize};
/// Events emitted by the el-publish app-store pipeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PublishEvent {
PublishStarted(PublishStarted),
CertificateValidated(CertificateValidated),
CertificateExpiringSoon(CertificateExpiringSoon),
BuildUploaded(BuildUploaded),
ReviewSubmitted(ReviewSubmitted),
ReviewApproved(ReviewApproved),
ReviewRejected(ReviewRejected),
RolloutAdvanced(RolloutAdvanced),
RolloutHalted(RolloutHalted),
}
/// A publish pipeline started for a platform+version.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PublishStarted {
pub platform: String,
pub version: String,
}
/// A code-signing certificate was validated successfully.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CertificateValidated {
pub platform: String,
/// ISO-8601 expiry date string, e.g. `"2026-01-01"`.
pub expiry_date: String,
}
/// A code-signing certificate is expiring within `days_remaining` days.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CertificateExpiringSoon {
pub platform: String,
pub days_remaining: u32,
}
/// A build artifact was uploaded to the store's distribution service.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BuildUploaded {
pub platform: String,
pub build_number: String,
pub size_bytes: u64,
}
/// The build was submitted to store review.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReviewSubmitted {
pub platform: String,
pub version: String,
}
/// Store review approved the submission.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReviewApproved {
pub platform: String,
pub version: String,
}
/// Store review rejected the submission.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReviewRejected {
pub platform: String,
pub version: String,
pub reason: String,
}
/// A phased rollout percentage was advanced.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RolloutAdvanced {
pub platform: String,
pub version: String,
/// Percentage of users on this version (0100).
pub percentage: u8,
}
/// A phased rollout was halted (e.g. due to crash spike).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RolloutHalted {
pub platform: String,
pub version: String,
pub reason: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn publish_started_roundtrip() {
let evt = PublishStarted {
platform: "ios".into(),
version: "2.1.0".into(),
};
let env = AxonEnvelope::new(ProjectType::ElPublish, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<PublishStarted> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.platform, "ios");
assert_eq!(back.payload.version, "2.1.0");
assert_eq!(back.project_type, ProjectType::ElPublish);
}
#[test]
fn certificate_expiring_soon() {
let evt = CertificateExpiringSoon {
platform: "android".into(),
days_remaining: 14,
};
let json = serde_json::to_string(&evt).unwrap();
let back: CertificateExpiringSoon = serde_json::from_str(&json).unwrap();
assert_eq!(back.days_remaining, 14);
}
#[test]
fn review_rejected_with_reason() {
let evt = ReviewRejected {
platform: "ios".into(),
version: "1.0.0".into(),
reason: "privacy policy missing".into(),
};
let json = serde_json::to_string(&evt).unwrap();
let back: ReviewRejected = serde_json::from_str(&json).unwrap();
assert_eq!(back.reason, "privacy policy missing");
}
#[test]
fn rollout_advanced_percentage_bounds() {
let evt = RolloutAdvanced {
platform: "web".into(),
version: "3.0.0".into(),
percentage: 50,
};
let json = serde_json::to_string(&evt).unwrap();
let back: RolloutAdvanced = serde_json::from_str(&json).unwrap();
assert_eq!(back.percentage, 50);
}
#[test]
fn publish_event_enum_roundtrip() {
let variant = PublishEvent::RolloutHalted(RolloutHalted {
platform: "android".into(),
version: "1.5.0".into(),
reason: "crash rate spike".into(),
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("rollout_halted"));
let back: PublishEvent = serde_json::from_str(&json).unwrap();
if let PublishEvent::RolloutHalted(r) = back {
assert_eq!(r.reason, "crash rate spike");
} else {
panic!("wrong variant");
}
}
#[test]
fn build_uploaded_roundtrip() {
let evt = BuildUploaded {
platform: "ios".into(),
build_number: "1234".into(),
size_bytes: 50_000_000,
};
let env = AxonEnvelope::new(ProjectType::ElPublish, evt);
assert_eq!(env.payload.size_bytes, 50_000_000);
}
}
+323
View File
@@ -0,0 +1,323 @@
use std::any::TypeId;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
/// Error returned when routing or handler execution fails.
#[derive(Debug, thiserror::Error)]
pub enum AxonError {
#[error("no handler registered for event type")]
NoHandler,
#[error("failed to deserialize envelope: {0}")]
Deserialize(#[from] serde_json::Error),
#[error("handler error: {0}")]
Handler(String),
}
// ------------------------------------------------------------------
// Internal type aliases
// ------------------------------------------------------------------
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
type ErasedHandler = Arc<
dyn Fn(serde_json::Value) -> BoxFuture<'static, Result<(), AxonError>> + Send + Sync + 'static,
>;
// ------------------------------------------------------------------
// AxonRouter
// ------------------------------------------------------------------
/// Type-safe async event router.
///
/// # Registering handlers
///
/// ```rust,ignore
/// let mut router = AxonRouter::new();
/// router.on::<CompileSucceeded>(|env| async move {
/// println!("compiled {} in {}ms", env.payload.file, env.payload.duration_ms);
/// Ok(())
/// });
/// ```
///
/// # Dispatching
///
/// Pass a raw JSON string; the router deserialises and routes it to the
/// registered handler. If no handler is registered the call returns
/// `Err(AxonError::NoHandler)`.
pub struct AxonRouter {
/// Maps `TypeId` of the payload type → erased async handler.
handlers: HashMap<TypeId, ErasedHandler>,
}
impl AxonRouter {
pub fn new() -> Self {
Self {
handlers: HashMap::new(),
}
}
/// Register an async handler for events carrying payload `T`.
///
/// The handler receives the fully-typed `AxonEnvelope<T>`.
/// Only one handler per `T` is supported; registering a second one
/// replaces the first.
pub fn on<T, F, Fut>(&mut self, handler: F)
where
T: Serialize + for<'de> Deserialize<'de> + Send + 'static,
F: Fn(AxonEnvelope<T>) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), AxonError>> + Send + 'static,
{
let handler = Arc::new(handler);
let erased: ErasedHandler = Arc::new(move |raw: serde_json::Value| {
let handler = Arc::clone(&handler);
let fut: BoxFuture<'static, Result<(), AxonError>> = Box::pin(async move {
let envelope: AxonEnvelope<T> = serde_json::from_value(raw)
.map_err(AxonError::Deserialize)?;
handler(envelope).await
});
fut
});
self.handlers.insert(TypeId::of::<T>(), erased);
}
/// Dispatch a pre-typed envelope to its registered handler.
///
/// Returns `Err(AxonError::NoHandler)` when no handler is registered
/// for `T`.
pub async fn dispatch<T>(&self, envelope: AxonEnvelope<T>) -> Result<(), AxonError>
where
T: Serialize + for<'de> Deserialize<'de> + Send + 'static,
{
let type_id = TypeId::of::<T>();
let handler = self
.handlers
.get(&type_id)
.ok_or(AxonError::NoHandler)?;
let raw = serde_json::to_value(&envelope).map_err(AxonError::Deserialize)?;
handler(raw).await
}
/// Dispatch a raw JSON string by first deserialising the envelope into
/// `AxonEnvelope<T>` and then routing to the registered handler for `T`.
pub async fn dispatch_json<T>(&self, json: &str) -> Result<(), AxonError>
where
T: Serialize + for<'de> Deserialize<'de> + Send + 'static,
{
let envelope: AxonEnvelope<T> =
serde_json::from_str(json).map_err(AxonError::Deserialize)?;
self.dispatch(envelope).await
}
/// Returns how many handlers are currently registered.
pub fn handler_count(&self) -> usize {
self.handlers.len()
}
/// Returns `true` if a handler is registered for payload type `T`.
pub fn has_handler<T: 'static>(&self) -> bool {
self.handlers.contains_key(&TypeId::of::<T>())
}
}
impl Default for AxonRouter {
fn default() -> Self {
Self::new()
}
}
// ------------------------------------------------------------------
// AxonSubscription — filter spec for subscription negotiation
// ------------------------------------------------------------------
/// Client subscription request — which project types and event types to
/// receive. Empty `Vec`s mean "subscribe to all".
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AxonSubscription {
/// Subscribe to all events from these project types.
/// Empty = subscribe to all project types.
pub project_types: Vec<ProjectType>,
/// Subscribe only to events whose payload type name is in this list.
/// Empty = subscribe to all event types.
pub event_types: Vec<String>,
}
impl AxonSubscription {
pub fn new() -> Self {
Self::default()
}
/// Convenience: subscribe only to events from a single project.
pub fn for_project(project_type: ProjectType) -> Self {
Self {
project_types: vec![project_type],
event_types: vec![],
}
}
/// Returns `true` when the subscription matches the given envelope
/// metadata. The `event_type` string is compared against the
/// `event_types` filter list.
pub fn matches(&self, project_type: ProjectType, event_type: &str) -> bool {
let project_ok = self.project_types.is_empty()
|| self.project_types.contains(&project_type);
let event_ok = self.event_types.is_empty()
|| self.event_types.iter().any(|t| t == event_type);
project_ok && event_ok
}
}
// ------------------------------------------------------------------
// Tests
// ------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::compiler::CompileSucceeded;
use crate::runtime::SessionStarted;
use crate::project::ProjectType;
use std::sync::{Arc, Mutex};
#[tokio::test]
async fn router_dispatches_to_correct_handler() {
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
let cap = Arc::clone(&captured);
let mut router = AxonRouter::new();
router.on(move |env: AxonEnvelope<SessionStarted>| {
let cap = Arc::clone(&cap);
async move {
cap.lock().unwrap().push(env.payload.session_id.clone());
Ok(())
}
});
let env = AxonEnvelope::new(
ProjectType::NeuronRs,
SessionStarted { session_id: "s-1".into() },
);
router.dispatch(env).await.unwrap();
assert_eq!(*captured.lock().unwrap(), vec!["s-1"]);
}
#[tokio::test]
async fn router_returns_no_handler_error() {
let router = AxonRouter::new();
let env = AxonEnvelope::new(
ProjectType::EngramLang,
CompileSucceeded {
file: "main.el".into(),
..Default::default()
},
);
let result = router.dispatch(env).await;
assert!(matches!(result, Err(AxonError::NoHandler)));
}
#[tokio::test]
async fn router_dispatch_json_roundtrip() {
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let cap = Arc::clone(&captured);
let mut router = AxonRouter::new();
router.on(move |env: AxonEnvelope<CompileSucceeded>| {
let cap = Arc::clone(&cap);
async move {
*cap.lock().unwrap() = Some(env.payload.file.clone());
Ok(())
}
});
let env = AxonEnvelope::new(
ProjectType::EngramLang,
CompileSucceeded {
file: "app.el".into(),
artifact_path: "dist/app.js".into(),
duration_ms: 500,
..Default::default()
},
);
let json = env.to_json().unwrap();
router.dispatch_json::<CompileSucceeded>(&json).await.unwrap();
assert_eq!(captured.lock().unwrap().as_deref(), Some("app.el"));
}
#[tokio::test]
async fn router_replaces_handler_on_second_registration() {
let counter: Arc<Mutex<u32>> = Arc::new(Mutex::new(0));
let c1 = Arc::clone(&counter);
let c2 = Arc::clone(&counter);
let mut router = AxonRouter::new();
router.on(move |_: AxonEnvelope<SessionStarted>| {
let c = Arc::clone(&c1);
async move { *c.lock().unwrap() += 10; Ok(()) }
});
// replace with a handler that adds 1
router.on(move |_: AxonEnvelope<SessionStarted>| {
let c = Arc::clone(&c2);
async move { *c.lock().unwrap() += 1; Ok(()) }
});
let env = AxonEnvelope::new(ProjectType::NeuronRs, SessionStarted::default());
router.dispatch(env).await.unwrap();
assert_eq!(*counter.lock().unwrap(), 1, "second handler should win");
}
#[test]
fn router_has_handler_reflects_registration() {
let mut router = AxonRouter::new();
assert!(!router.has_handler::<SessionStarted>());
router.on(|_: AxonEnvelope<SessionStarted>| async { Ok(()) });
assert!(router.has_handler::<SessionStarted>());
assert_eq!(router.handler_count(), 1);
}
#[test]
fn subscription_matches_all_when_empty() {
let sub = AxonSubscription::new();
assert!(sub.matches(ProjectType::Engram, "node_created"));
assert!(sub.matches(ProjectType::ElUi, "build_started"));
}
#[test]
fn subscription_filters_by_project() {
let sub = AxonSubscription::for_project(ProjectType::EngramLang);
assert!(sub.matches(ProjectType::EngramLang, "compile_started"));
assert!(!sub.matches(ProjectType::Engram, "node_created"));
}
#[test]
fn subscription_filters_by_event_type() {
let sub = AxonSubscription {
project_types: vec![],
event_types: vec!["compile_succeeded".into()],
};
assert!(sub.matches(ProjectType::EngramLang, "compile_succeeded"));
assert!(!sub.matches(ProjectType::EngramLang, "compile_failed"));
}
#[test]
fn subscription_serde_roundtrip() {
let sub = AxonSubscription {
project_types: vec![ProjectType::ElIde, ProjectType::ElUi],
event_types: vec!["file_opened".into()],
};
let json = serde_json::to_string(&sub).unwrap();
let back: AxonSubscription = serde_json::from_str(&json).unwrap();
assert_eq!(back.project_types.len(), 2);
assert_eq!(back.event_types[0], "file_opened");
}
}
+195
View File
@@ -0,0 +1,195 @@
use serde::{Deserialize, Serialize};
/// Events emitted by the neuron-rs runtime / agent host.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RuntimeEvent {
SessionStarted(SessionStarted),
SessionEnded(SessionEnded),
ToolCallStarted(ToolCallStarted),
ToolCallCompleted(ToolCallCompleted),
ToolCallFailed(ToolCallFailed),
MemoryWritten(MemoryWritten),
KnowledgeQueried(KnowledgeQueried),
BacklogUpdated(BacklogUpdated),
WorkContextOpened(WorkContextOpened),
WorkContextClosed(WorkContextClosed),
}
/// The action taken on a backlog item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum BacklogAction {
#[default]
Created,
Started,
Completed,
Blocked,
}
/// An agent session started.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionStarted {
pub session_id: String,
}
/// An agent session ended.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionEnded {
pub session_id: String,
pub duration_ms: u64,
}
/// A tool call was dispatched.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolCallStarted {
pub tool_name: String,
pub session_id: String,
}
/// A tool call returned successfully.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolCallCompleted {
pub tool_name: String,
pub session_id: String,
pub duration_ms: u64,
}
/// A tool call returned an error.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolCallFailed {
pub tool_name: String,
pub session_id: String,
pub error: String,
}
/// A memory node was written to the store.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MemoryWritten {
pub node_id: String,
pub chain_name: String,
}
/// A knowledge query was executed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KnowledgeQueried {
pub query: String,
pub result_count: u64,
pub duration_ms: u64,
}
/// A backlog item changed state.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BacklogUpdated {
pub item_id: String,
pub action: BacklogAction,
}
/// A work context was opened.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkContextOpened {
pub context_id: String,
pub process_name: String,
}
/// A work context was closed / completed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkContextClosed {
pub context_id: String,
pub duration_ms: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn session_started_roundtrip() {
let evt = SessionStarted {
session_id: "sess-abc".into(),
};
let env = AxonEnvelope::new(ProjectType::NeuronRs, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<SessionStarted> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.session_id, "sess-abc");
assert_eq!(back.project_type, ProjectType::NeuronRs);
}
#[test]
fn tool_call_lifecycle() {
let start = ToolCallStarted {
tool_name: "remember".into(),
session_id: "sess-1".into(),
};
let complete = ToolCallCompleted {
tool_name: "remember".into(),
session_id: "sess-1".into(),
duration_ms: 12,
};
let failed = ToolCallFailed {
tool_name: "recall".into(),
session_id: "sess-1".into(),
error: "not found".into(),
};
for json in [
serde_json::to_string(&start).unwrap(),
serde_json::to_string(&complete).unwrap(),
serde_json::to_string(&failed).unwrap(),
] {
assert!(!json.is_empty());
}
}
#[test]
fn backlog_action_default_is_created() {
assert_eq!(BacklogAction::default(), BacklogAction::Created);
}
#[test]
fn backlog_updated_all_actions() {
for action in [
BacklogAction::Created,
BacklogAction::Started,
BacklogAction::Completed,
BacklogAction::Blocked,
] {
let evt = BacklogUpdated {
item_id: "bl-1".into(),
action,
};
let json = serde_json::to_string(&evt).unwrap();
let back: BacklogUpdated = serde_json::from_str(&json).unwrap();
assert_eq!(back.action, action);
}
}
#[test]
fn runtime_event_enum_roundtrip() {
let variant = RuntimeEvent::WorkContextOpened(WorkContextOpened {
context_id: "ctx-99".into(),
process_name: "implement-feature".into(),
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("work_context_opened"));
let back: RuntimeEvent = serde_json::from_str(&json).unwrap();
if let RuntimeEvent::WorkContextOpened(w) = back {
assert_eq!(w.context_id, "ctx-99");
} else {
panic!("wrong variant");
}
}
#[test]
fn knowledge_queried_envelope() {
let evt = KnowledgeQueried {
query: "VBD patterns".into(),
result_count: 5,
duration_ms: 30,
};
let env = AxonEnvelope::new(ProjectType::NeuronRs, evt);
assert_eq!(env.payload.result_count, 5);
}
}
+162
View File
@@ -0,0 +1,162 @@
use serde::{Deserialize, Serialize};
/// Events emitted by the el-ui frontend framework.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum UiEvent {
BuildStarted(BuildStarted),
BuildSucceeded(BuildSucceeded),
BuildFailed(BuildFailed),
ComponentRendered(ComponentRendered),
ServiceCallStarted(ServiceCallStarted),
ServiceCallCompleted(ServiceCallCompleted),
ServiceCallFailed(ServiceCallFailed),
HotReloadTriggered(HotReloadTriggered),
}
/// How a service binding communicates with its backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum BindingType {
#[default]
Rest,
Ws,
Grpc,
Direct,
}
/// A UI build has started for a given target platform.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BuildStarted {
pub target_platform: String,
}
/// A UI build finished and produced an artifact.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BuildSucceeded {
pub target_platform: String,
pub artifact_size_bytes: u64,
pub duration_ms: u64,
}
/// A UI build failed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BuildFailed {
pub target_platform: String,
pub errors: Vec<String>,
}
/// A component finished its render pass.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ComponentRendered {
pub component_name: String,
pub platform: String,
/// Render time in microseconds (sub-millisecond precision for hot paths).
pub duration_us: u64,
}
/// A service-binding call started.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceCallStarted {
pub binding_type: BindingType,
pub method: String,
}
/// A service-binding call completed successfully.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceCallCompleted {
pub binding_type: BindingType,
pub method: String,
pub status_code: u16,
pub duration_ms: u64,
}
/// A service-binding call failed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceCallFailed {
pub binding_type: BindingType,
pub method: String,
pub error: String,
}
/// A hot-reload was triggered by file changes.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HotReloadTriggered {
pub changed_files: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn build_started_roundtrip() {
let evt = BuildStarted {
target_platform: "web".into(),
};
let env = AxonEnvelope::new(ProjectType::ElUi, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<BuildStarted> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.target_platform, "web");
assert_eq!(back.project_type, ProjectType::ElUi);
}
#[test]
fn build_failed_with_errors() {
let evt = BuildFailed {
target_platform: "ios".into(),
errors: vec!["missing asset".into(), "lint error".into()],
};
let json = serde_json::to_string(&evt).unwrap();
let back: BuildFailed = serde_json::from_str(&json).unwrap();
assert_eq!(back.errors.len(), 2);
}
#[test]
fn binding_type_default_is_rest() {
assert_eq!(BindingType::default(), BindingType::Rest);
}
#[test]
fn service_call_completed_roundtrip() {
let evt = ServiceCallCompleted {
binding_type: BindingType::Grpc,
method: "ListNodes".into(),
status_code: 200,
duration_ms: 45,
};
let json = serde_json::to_string(&evt).unwrap();
let back: ServiceCallCompleted = serde_json::from_str(&json).unwrap();
assert_eq!(back.binding_type, BindingType::Grpc);
assert_eq!(back.status_code, 200);
}
#[test]
fn hot_reload_roundtrip() {
let evt = HotReloadTriggered {
changed_files: vec!["src/app.el".into(), "styles/main.css".into()],
};
let json = serde_json::to_string(&evt).unwrap();
let back: HotReloadTriggered = serde_json::from_str(&json).unwrap();
assert_eq!(back.changed_files.len(), 2);
}
#[test]
fn ui_event_enum_serde() {
let variant = UiEvent::ComponentRendered(ComponentRendered {
component_name: "Button".into(),
platform: "web".into(),
duration_us: 150,
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("component_rendered"));
let back: UiEvent = serde_json::from_str(&json).unwrap();
if let UiEvent::ComponentRendered(c) = back {
assert_eq!(c.component_name, "Button");
} else {
panic!("wrong variant");
}
}
}
+1
View File
@@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
neuron-domain = { path = "../neuron-domain" }
neuron-store = { path = "../neuron-store" }
axon-events = { path = "../axon-events" }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
+186 -11
View File
@@ -1,25 +1,118 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// An Axon protocol message — wraps a tool call or other method invocation.
// ------------------------------------------------------------------
// AxonMethod
// ------------------------------------------------------------------
/// Typed method discriminant for Axon protocol messages.
///
/// Replaces the raw `method: String` field — callers that need a string
/// representation can use [`AxonMethod::as_str`] or the `Display` impl.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AxonMethod {
/// Invoke a named tool.
ToolCall,
/// Return value from a tool invocation.
ToolResult,
/// Deliver a typed Axon event envelope.
Event,
/// Liveness check.
Ping,
/// Liveness reply.
Pong,
/// Subscribe to an event stream.
Subscribe,
/// Cancel a subscription.
Unsubscribe,
}
impl AxonMethod {
/// Return the canonical lowercase string for this method.
pub fn as_str(self) -> &'static str {
match self {
Self::ToolCall => "tool_call",
Self::ToolResult => "tool_result",
Self::Event => "event",
Self::Ping => "ping",
Self::Pong => "pong",
Self::Subscribe => "subscribe",
Self::Unsubscribe => "unsubscribe",
}
}
/// Parse a string into an `AxonMethod`, returning `None` on mismatch.
pub fn from_str(s: &str) -> Option<Self> {
match s {
"tool_call" => Some(Self::ToolCall),
"tool_result" => Some(Self::ToolResult),
"event" => Some(Self::Event),
"ping" => Some(Self::Ping),
"pong" => Some(Self::Pong),
"subscribe" => Some(Self::Subscribe),
"unsubscribe" => Some(Self::Unsubscribe),
_ => None,
}
}
}
impl std::fmt::Display for AxonMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ------------------------------------------------------------------
// AxonMessage
// ------------------------------------------------------------------
/// An Axon protocol message — wraps a tool call, event delivery, or
/// other method invocation.
///
/// The `method` field is now typed as [`AxonMethod`]. For backward
/// compatibility the raw string representation is available via
/// [`AxonMessage::method_str`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AxonMessage {
/// Unique message ID
/// Unique message ID.
pub id: String,
/// Method name: "tool_call", "tool_result", "ping", etc.
pub method: String,
/// Method-specific parameters
/// Typed method discriminant.
pub method: AxonMethod,
/// Method-specific parameters.
pub params: Value,
}
impl AxonMessage {
/// Return the canonical string representation of the method (e.g.
/// `"tool_call"`, `"ping"`). Useful when serialising to a transport
/// that expects a raw string rather than a tagged enum.
pub fn method_str(&self) -> &'static str {
self.method.as_str()
}
/// Construct a ping message.
pub fn ping(id: impl Into<String>) -> Self {
Self {
id: id.into(),
method: AxonMethod::Ping,
params: Value::Object(Default::default()),
}
}
}
// ------------------------------------------------------------------
// AxonResponse
// ------------------------------------------------------------------
/// An Axon protocol response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AxonResponse {
/// Echo of the request ID
/// Echo of the request ID.
pub id: String,
/// Whether the call succeeded
/// Whether the call succeeded.
pub success: bool,
/// The result payload (on success) or error message (on failure)
/// The result payload (on success) or error message (on failure).
pub result: Value,
}
@@ -41,6 +134,10 @@ impl AxonResponse {
}
}
// ------------------------------------------------------------------
// AxonEvent (legacy SSE event — kept for backward compat)
// ------------------------------------------------------------------
/// An Axon SSE event — sent over the streaming channel.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AxonEvent {
@@ -59,6 +156,10 @@ impl AxonEvent {
}
}
// ------------------------------------------------------------------
// Tests
// ------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
@@ -78,14 +179,88 @@ mod tests {
}
#[test]
fn axon_message_roundtrip() {
fn axon_message_roundtrip_with_method_enum() {
let msg = AxonMessage {
id: "m1".into(),
method: "tool_call".into(),
method: AxonMethod::ToolCall,
params: serde_json::json!({ "tool": "remember", "content": "hello" }),
};
let json = serde_json::to_string(&msg).unwrap();
let back: AxonMessage = serde_json::from_str(&json).unwrap();
assert_eq!(back.method, "tool_call");
assert_eq!(back.method, AxonMethod::ToolCall);
assert_eq!(back.method_str(), "tool_call");
}
#[test]
fn axon_method_as_str_all_variants() {
let cases = [
(AxonMethod::ToolCall, "tool_call"),
(AxonMethod::ToolResult, "tool_result"),
(AxonMethod::Event, "event"),
(AxonMethod::Ping, "ping"),
(AxonMethod::Pong, "pong"),
(AxonMethod::Subscribe, "subscribe"),
(AxonMethod::Unsubscribe, "unsubscribe"),
];
for (method, expected) in cases {
assert_eq!(method.as_str(), expected);
assert_eq!(method.to_string(), expected);
}
}
#[test]
fn axon_method_from_str_roundtrip() {
let variants = [
AxonMethod::ToolCall,
AxonMethod::ToolResult,
AxonMethod::Event,
AxonMethod::Ping,
AxonMethod::Pong,
AxonMethod::Subscribe,
AxonMethod::Unsubscribe,
];
for v in variants {
let s = v.as_str();
assert_eq!(AxonMethod::from_str(s), Some(v));
}
}
#[test]
fn axon_method_from_str_unknown() {
assert_eq!(AxonMethod::from_str("mystery"), None);
assert_eq!(AxonMethod::from_str(""), None);
}
#[test]
fn axon_method_serde_roundtrip() {
let m = AxonMethod::Subscribe;
let json = serde_json::to_string(&m).unwrap();
let back: AxonMethod = serde_json::from_str(&json).unwrap();
assert_eq!(back, AxonMethod::Subscribe);
}
#[test]
fn axon_message_ping_helper() {
let msg = AxonMessage::ping("p-1");
assert_eq!(msg.method, AxonMethod::Ping);
assert_eq!(msg.id, "p-1");
}
#[test]
fn axon_method_all_variants_have_unique_str() {
use std::collections::HashSet;
let strs: HashSet<&str> = [
AxonMethod::ToolCall,
AxonMethod::ToolResult,
AxonMethod::Event,
AxonMethod::Ping,
AxonMethod::Pong,
AxonMethod::Subscribe,
AxonMethod::Unsubscribe,
]
.iter()
.map(|m| m.as_str())
.collect();
assert_eq!(strs.len(), 7);
}
}
+14 -10
View File
@@ -1,4 +1,4 @@
use crate::axon::{AxonMessage, AxonResponse};
use crate::axon::{AxonMessage, AxonMethod, AxonResponse};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
@@ -30,8 +30,8 @@ impl AxonHandler {
/// Dispatch an AxonMessage to the appropriate handler.
pub fn dispatch(&self, msg: AxonMessage) -> AxonResponse {
match msg.method.as_str() {
"tool_call" => {
match msg.method {
AxonMethod::ToolCall => {
let tool_name = msg
.params
.get("tool")
@@ -50,8 +50,12 @@ impl AxonHandler {
AxonResponse::err(msg.id, format!("unknown tool: {}", tool_name))
}
}
"ping" => AxonResponse::ok(msg.id, serde_json::json!({ "pong": true })),
other => AxonResponse::err(msg.id, format!("unknown method: {}", other)),
AxonMethod::Ping => {
AxonResponse::ok(msg.id, serde_json::json!({ "pong": true }))
}
other => {
AxonResponse::err(msg.id, format!("unknown method: {}", other.as_str()))
}
}
}
@@ -78,7 +82,7 @@ mod tests {
let handler = AxonHandler::new();
let msg = AxonMessage {
id: "1".into(),
method: "ping".into(),
method: AxonMethod::Ping,
params: serde_json::json!({}),
};
let resp = handler.dispatch(msg);
@@ -95,7 +99,7 @@ mod tests {
let msg = AxonMessage {
id: "2".into(),
method: "tool_call".into(),
method: AxonMethod::ToolCall,
params: serde_json::json!({ "tool": "remember", "params": { "content": "hello" } }),
};
let resp = handler.dispatch(msg);
@@ -108,7 +112,7 @@ mod tests {
let handler = AxonHandler::new();
let msg = AxonMessage {
id: "3".into(),
method: "tool_call".into(),
method: AxonMethod::ToolCall,
params: serde_json::json!({ "tool": "nonexistent", "params": {} }),
};
let resp = handler.dispatch(msg);
@@ -116,11 +120,11 @@ mod tests {
}
#[test]
fn dispatch_unknown_method() {
fn dispatch_unhandled_method() {
let handler = AxonHandler::new();
let msg = AxonMessage {
id: "4".into(),
method: "mystery".into(),
method: AxonMethod::Subscribe,
params: serde_json::json!({}),
};
let resp = handler.dispatch(msg);
+2 -1
View File
@@ -3,5 +3,6 @@ pub mod handler;
pub mod sse;
pub mod tools;
pub use axon::{AxonMessage, AxonResponse};
pub use axon::{AxonEvent, AxonMessage, AxonMethod, AxonResponse};
pub use handler::AxonHandler;
pub use tools::ProjectType;
+182 -14
View File
@@ -1,45 +1,165 @@
/// Tool name constants — must match the existing MCP tool names so Claude Code
/// Tool name constants — must match the existing MCP tool names so services
/// can switch over without configuration changes.
// ------------------------------------------------------------------
// Memory tools
// ------------------------------------------------------------------
pub const REMEMBER: &str = "remember";
pub const RECALL: &str = "recall";
pub const SEARCH_ENTITIES: &str = "search_entities";
pub const INSPECT_MEMORIES: &str = "inspect_memories";
pub const EVOLVE_MEMORY: &str = "evolve_memory";
pub const FORGET: &str = "forget";
pub const PIN_NODE: &str = "pin_node";
pub const INSPECT_GRAPH: &str = "inspect_graph";
pub const SEARCH_GRAPH: &str = "search_graph";
pub const TRAVERSE_GRAPH: &str = "traverse_graph";
pub const REBUILD_GRAPH: &str = "rebuild_graph";
pub const LINK_ENTITIES: &str = "link_entities";
pub const LINK_CAUSAL: &str = "link_causal";
// ------------------------------------------------------------------
// Knowledge tools
// ------------------------------------------------------------------
pub const CAPTURE_KNOWLEDGE: &str = "capture_knowledge";
pub const SEARCH_KNOWLEDGE: &str = "search_knowledge";
pub const RETRIEVE_KNOWLEDGE: &str = "retrieve_knowledge";
pub const BROWSE_KNOWLEDGE: &str = "browse_knowledge";
pub const EVOLVE_KNOWLEDGE: &str = "evolve_knowledge";
pub const REMOVE_KNOWLEDGE: &str = "remove_knowledge";
// ------------------------------------------------------------------
// Backlog / work tools
// ------------------------------------------------------------------
pub const PLAN_WORK: &str = "plan_work";
pub const REVIEW_BACKLOG: &str = "review_backlog";
pub const TRACK_WORK: &str = "track_work";
pub const BEGIN_WORK: &str = "begin_work";
pub const PROGRESS_WORK: &str = "progress_work";
pub const CHECK_WORK: &str = "check_work";
pub const LIST_WORK: &str = "list_work";
// ------------------------------------------------------------------
// Session / context tools
// ------------------------------------------------------------------
pub const BEGIN_SESSION: &str = "begin_session";
pub const COMPILE_CTX: &str = "compile_ctx";
pub const CONSOLIDATE: &str = "consolidate";
pub const PROJECT_CONTEXT: &str = "project_context";
// ------------------------------------------------------------------
// Artifact tools
// ------------------------------------------------------------------
pub const DRAFT_ARTIFACT: &str = "draft_artifact";
pub const FIND_ARTIFACTS: &str = "find_artifacts";
pub const RETRIEVE_ARTIFACT: &str = "retrieve_artifact";
pub const REVISE_ARTIFACT: &str = "revise_artifact";
pub const MANAGE_ARTIFACT: &str = "manage_artifact";
// ------------------------------------------------------------------
// Process tools
// ------------------------------------------------------------------
pub const BROWSE_PROCESSES: &str = "browse_processes";
pub const DEFINE_PROCESS: &str = "define_process";
pub const RETRIEVE_PROCESS: &str = "retrieve_process";
pub const EXECUTE_PROCESS: &str = "execute_process";
pub const EXPORT_PROCESS: &str = "export_process";
pub const DELETE_PROCESS: &str = "delete_process";
pub const LIST_PROCESSES: &str = "list_processes";
pub const CATALOG_ROUTES: &str = "catalog_routes";
pub const REGISTER_ROUTE: &str = "register_route";
// ------------------------------------------------------------------
// Event / ISE tools
// ------------------------------------------------------------------
pub const LOG_INTERNAL_STATE_EVENT: &str = "log_internal_state_event";
pub const LIST_INTERNAL_STATE_EVENTS: &str = "list_internal_state_events";
pub const GET_INTERNAL_STATE_EVENT: &str = "get_internal_state_event";
pub const CHECK_EVENTS: &str = "check_events";
pub const ACKNOWLEDGE_EVENT: &str = "acknowledge_event";
pub const INSPECT_EVENT: &str = "inspect_event";
pub const PROCESS_EVENTS: &str = "process_events";
pub const SEND_NOTIFICATION: &str = "send_notification";
/// All registered tool names in canonical order.
// ------------------------------------------------------------------
// Config tools
// ------------------------------------------------------------------
pub const INSPECT_CONFIG: &str = "inspect_config";
pub const TUNE_CONFIG: &str = "tune_config";
pub const GET_INSTRUCTIONS: &str = "get_instructions";
// ------------------------------------------------------------------
// All tools in canonical alphabetical order
// ------------------------------------------------------------------
pub const ALL_TOOLS: &[&str] = &[
REMEMBER,
RECALL,
SEARCH_ENTITIES,
CAPTURE_KNOWLEDGE,
SEARCH_KNOWLEDGE,
RETRIEVE_KNOWLEDGE,
PLAN_WORK,
REVIEW_BACKLOG,
TRACK_WORK,
ACKNOWLEDGE_EVENT,
BEGIN_SESSION,
BEGIN_WORK,
PROGRESS_WORK,
BROWSE_KNOWLEDGE,
BROWSE_PROCESSES,
CAPTURE_KNOWLEDGE,
CATALOG_ROUTES,
CHECK_EVENTS,
CHECK_WORK,
LOG_INTERNAL_STATE_EVENT,
COMPILE_CTX,
CONSOLIDATE,
DEFINE_PROCESS,
DELETE_PROCESS,
DRAFT_ARTIFACT,
EVOLVE_KNOWLEDGE,
EVOLVE_MEMORY,
EXECUTE_PROCESS,
EXPORT_PROCESS,
FIND_ARTIFACTS,
FORGET,
GET_INSTRUCTIONS,
GET_INTERNAL_STATE_EVENT,
INSPECT_CONFIG,
INSPECT_EVENT,
INSPECT_GRAPH,
INSPECT_MEMORIES,
LINK_CAUSAL,
LINK_ENTITIES,
LIST_INTERNAL_STATE_EVENTS,
LIST_PROCESSES,
LIST_WORK,
LOG_INTERNAL_STATE_EVENT,
MANAGE_ARTIFACT,
PIN_NODE,
PLAN_WORK,
PROCESS_EVENTS,
PROGRESS_WORK,
PROJECT_CONTEXT,
REBUILD_GRAPH,
RECALL,
REGISTER_ROUTE,
REMEMBER,
REMOVE_KNOWLEDGE,
RETRIEVE_ARTIFACT,
RETRIEVE_KNOWLEDGE,
RETRIEVE_PROCESS,
REVIEW_BACKLOG,
REVISE_ARTIFACT,
SEARCH_ENTITIES,
SEARCH_GRAPH,
SEARCH_KNOWLEDGE,
SEND_NOTIFICATION,
TRACK_WORK,
TRAVERSE_GRAPH,
TUNE_CONFIG,
];
// Re-export ProjectType from axon-events for convenient protocol-level access.
pub use axon_events::ProjectType;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_tools_count() {
assert_eq!(ALL_TOOLS.len(), 14);
// Ensure the list has grown to cover the full Neuron MCP surface.
assert!(ALL_TOOLS.len() >= 50, "expected 50+ tools, got {}", ALL_TOOLS.len());
}
#[test]
@@ -48,4 +168,52 @@ mod tests {
assert!(!name.is_empty(), "tool name must not be empty");
}
}
#[test]
fn all_tools_are_unique() {
use std::collections::HashSet;
let set: HashSet<&&str> = ALL_TOOLS.iter().collect();
assert_eq!(set.len(), ALL_TOOLS.len(), "duplicate tool name detected");
}
#[test]
fn all_tools_are_sorted() {
let mut sorted = ALL_TOOLS.to_vec();
sorted.sort_unstable();
assert_eq!(ALL_TOOLS, sorted.as_slice(), "ALL_TOOLS must be in alphabetical order");
}
#[test]
fn original_14_tools_still_present() {
let original = [
REMEMBER,
RECALL,
SEARCH_ENTITIES,
CAPTURE_KNOWLEDGE,
SEARCH_KNOWLEDGE,
RETRIEVE_KNOWLEDGE,
PLAN_WORK,
REVIEW_BACKLOG,
TRACK_WORK,
BEGIN_WORK,
PROGRESS_WORK,
CHECK_WORK,
LOG_INTERNAL_STATE_EVENT,
LIST_INTERNAL_STATE_EVENTS,
];
for name in original {
assert!(
ALL_TOOLS.contains(&name),
"original tool '{}' missing from ALL_TOOLS",
name
);
}
}
#[test]
fn project_type_importable_from_tools() {
// Ensure the re-export compiles and is usable.
let _ = ProjectType::NeuronRs;
let _ = ProjectType::EngramLang;
}
}