diff --git a/Cargo.toml b/Cargo.toml index cf90a23..902ac92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/neuron-store", "crates/neuron-api", "crates/neuron-protocol", + "crates/axon-events", ] [workspace.dependencies] diff --git a/crates/axon-events/Cargo.toml b/crates/axon-events/Cargo.toml new file mode 100644 index 0000000..eb0220e --- /dev/null +++ b/crates/axon-events/Cargo.toml @@ -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 } diff --git a/crates/axon-events/src/common.rs b/crates/axon-events/src/common.rs new file mode 100644 index 0000000..7115eb3 --- /dev/null +++ b/crates/axon-events/src/common.rs @@ -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`. +/// This provides routing metadata (project type, timestamp) without +/// coupling the event payload itself to those concerns. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AxonEnvelope { + /// 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 Deserialize<'de>> AxonEnvelope { + /// 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 { + serde_json::to_string(self) + } + + /// Deserialise an envelope from a JSON string. + pub fn from_json(s: &str) -> Result { + 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 = 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"); + } +} diff --git a/crates/axon-events/src/compiler.rs b/crates/axon-events/src/compiler.rs new file mode 100644 index 0000000..fd324a7 --- /dev/null +++ b/crates/axon-events/src/compiler.rs @@ -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, +} + +/// 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 = 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"); + } +} diff --git a/crates/axon-events/src/engram.rs b/crates/axon-events/src/engram.rs new file mode 100644 index 0000000..a250228 --- /dev/null +++ b/crates/axon-events/src/engram.rs @@ -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, +} + +/// 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 = 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); + } +} diff --git a/crates/axon-events/src/ide.rs b/crates/axon-events/src/ide.rs new file mode 100644 index 0000000..2fd1505 --- /dev/null +++ b/crates/axon-events/src/ide.rs @@ -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, +} + +/// 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 = 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"); + } +} diff --git a/crates/axon-events/src/lib.rs b/crates/axon-events/src/lib.rs new file mode 100644 index 0000000..c6e7c14 --- /dev/null +++ b/crates/axon-events/src/lib.rs @@ -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}; diff --git a/crates/axon-events/src/project.rs b/crates/axon-events/src/project.rs new file mode 100644 index 0000000..5690ccf --- /dev/null +++ b/crates/axon-events/src/project.rs @@ -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, + /// Semantic version of the emitting service, e.g. `"0.3.1"`. + pub version: Option, +} + +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) -> Self { + self.label = Some(label.into()); + self + } + + pub fn with_version(mut self, version: impl Into) -> 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 = variants.iter().map(|v| v.to_string()).collect(); + assert_eq!(strings.len(), variants.len()); + } +} diff --git a/crates/axon-events/src/publish.rs b/crates/axon-events/src/publish.rs new file mode 100644 index 0000000..541f8aa --- /dev/null +++ b/crates/axon-events/src/publish.rs @@ -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 (0–100). + 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 = 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); + } +} diff --git a/crates/axon-events/src/routing.rs b/crates/axon-events/src/routing.rs new file mode 100644 index 0000000..ba214a9 --- /dev/null +++ b/crates/axon-events/src/routing.rs @@ -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 + 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::(|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, +} + +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`. + /// Only one handler per `T` is supported; registering a second one + /// replaces the first. + pub fn on(&mut self, handler: F) + where + T: Serialize + for<'de> Deserialize<'de> + Send + 'static, + F: Fn(AxonEnvelope) -> Fut + Send + Sync + 'static, + Fut: Future> + 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 = serde_json::from_value(raw) + .map_err(AxonError::Deserialize)?; + handler(envelope).await + }); + fut + }); + self.handlers.insert(TypeId::of::(), 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(&self, envelope: AxonEnvelope) -> Result<(), AxonError> + where + T: Serialize + for<'de> Deserialize<'de> + Send + 'static, + { + let type_id = TypeId::of::(); + 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` and then routing to the registered handler for `T`. + pub async fn dispatch_json(&self, json: &str) -> Result<(), AxonError> + where + T: Serialize + for<'de> Deserialize<'de> + Send + 'static, + { + let envelope: AxonEnvelope = + 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(&self) -> bool { + self.handlers.contains_key(&TypeId::of::()) + } +} + +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, + /// Subscribe only to events whose payload type name is in this list. + /// Empty = subscribe to all event types. + pub event_types: Vec, +} + +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>> = Arc::new(Mutex::new(vec![])); + let cap = Arc::clone(&captured); + + let mut router = AxonRouter::new(); + router.on(move |env: AxonEnvelope| { + 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>> = Arc::new(Mutex::new(None)); + let cap = Arc::clone(&captured); + + let mut router = AxonRouter::new(); + router.on(move |env: AxonEnvelope| { + 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::(&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> = Arc::new(Mutex::new(0)); + let c1 = Arc::clone(&counter); + let c2 = Arc::clone(&counter); + + let mut router = AxonRouter::new(); + router.on(move |_: AxonEnvelope| { + let c = Arc::clone(&c1); + async move { *c.lock().unwrap() += 10; Ok(()) } + }); + // replace with a handler that adds 1 + router.on(move |_: AxonEnvelope| { + 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::()); + router.on(|_: AxonEnvelope| async { Ok(()) }); + assert!(router.has_handler::()); + 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"); + } +} diff --git a/crates/axon-events/src/runtime.rs b/crates/axon-events/src/runtime.rs new file mode 100644 index 0000000..2205f28 --- /dev/null +++ b/crates/axon-events/src/runtime.rs @@ -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 = 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); + } +} diff --git a/crates/axon-events/src/ui.rs b/crates/axon-events/src/ui.rs new file mode 100644 index 0000000..2f323d8 --- /dev/null +++ b/crates/axon-events/src/ui.rs @@ -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, +} + +/// 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, +} + +#[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 = 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"); + } + } +} diff --git a/crates/neuron-protocol/Cargo.toml b/crates/neuron-protocol/Cargo.toml index 63a716f..4c296a9 100644 --- a/crates/neuron-protocol/Cargo.toml +++ b/crates/neuron-protocol/Cargo.toml @@ -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 } diff --git a/crates/neuron-protocol/src/axon.rs b/crates/neuron-protocol/src/axon.rs index 36661fe..d498f4d 100644 --- a/crates/neuron-protocol/src/axon.rs +++ b/crates/neuron-protocol/src/axon.rs @@ -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 { + 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) -> 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); } } diff --git a/crates/neuron-protocol/src/handler.rs b/crates/neuron-protocol/src/handler.rs index 9f13aaf..e68c837 100644 --- a/crates/neuron-protocol/src/handler.rs +++ b/crates/neuron-protocol/src/handler.rs @@ -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); diff --git a/crates/neuron-protocol/src/lib.rs b/crates/neuron-protocol/src/lib.rs index 17c7f67..0ec4efa 100644 --- a/crates/neuron-protocol/src/lib.rs +++ b/crates/neuron-protocol/src/lib.rs @@ -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; diff --git a/crates/neuron-protocol/src/tools.rs b/crates/neuron-protocol/src/tools.rs index 09d8858..87f303f 100644 --- a/crates/neuron-protocol/src/tools.rs +++ b/crates/neuron-protocol/src/tools.rs @@ -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; + } }