add axon-events: typed event schema for the full neuron-technologies stack
This commit is contained in:
@@ -5,6 +5,7 @@ members = [
|
||||
"crates/neuron-store",
|
||||
"crates/neuron-api",
|
||||
"crates/neuron-protocol",
|
||||
"crates/axon-events",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -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 }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user