fix cross-repo path deps; remove el compiler from neuron-lang/

- neuron-runtime, neuron-store, neuron-migrate: fix path deps
  (products/engram/ → foundation/engram/engrams/, products/el/ → foundation/el/engrams/)
- neuron-rs workspace: fix axon-events/axon-protocol paths
  (../../../platform/ → ../../platform/, paths were off by one level)
- neuron-lang/compiler/ removed (el self-hosting compiler now lives in foundation/el/el-compiler/)
This commit is contained in:
2026-04-29 03:27:39 -05:00
parent 78fc3a909a
commit b77f537dc6
54 changed files with 8065 additions and 2420 deletions
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "axon-events"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
-190
View File
@@ -1,190 +0,0 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::project::ProjectType;
/// Opaque unique identifier for an event.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct EventId(pub Uuid);
impl EventId {
/// Generate a new random event ID.
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for EventId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for EventId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Milliseconds since the Unix epoch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Timestamp(pub i64);
impl Timestamp {
/// Current time as a millisecond timestamp.
pub fn now() -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before epoch")
.as_millis() as i64;
Self(ms)
}
/// Construct from a raw millisecond value.
pub fn from_ms(ms: i64) -> Self {
Self(ms)
}
/// Return the raw millisecond value.
pub fn as_ms(self) -> i64 {
self.0
}
}
impl Default for Timestamp {
fn default() -> Self {
Self::now()
}
}
/// Diagnostic / compile-error severity level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
#[default]
Info,
Warning,
Error,
}
/// Universal envelope that wraps every typed event.
///
/// Services never send raw events — they always send an `AxonEnvelope<T>`.
/// This provides routing metadata (project type, timestamp) without
/// coupling the event payload itself to those concerns.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AxonEnvelope<T> {
/// Unique ID for this event instance.
pub event_id: EventId,
/// Which project emitted this event.
pub project_type: ProjectType,
/// When the event was created (ms since epoch).
pub timestamp: Timestamp,
/// The typed event payload.
pub payload: T,
}
impl<T: Serialize + for<'de> Deserialize<'de>> AxonEnvelope<T> {
/// Wrap a payload in an envelope for the given project type.
pub fn new(project_type: ProjectType, payload: T) -> Self {
Self {
event_id: EventId::new(),
project_type,
timestamp: Timestamp::now(),
payload,
}
}
/// Serialise this envelope to a JSON string.
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
/// Deserialise an envelope from a JSON string.
pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::ProjectType;
#[test]
fn event_id_is_unique() {
let a = EventId::new();
let b = EventId::new();
assert_ne!(a, b);
}
#[test]
fn event_id_default_is_unique() {
let a = EventId::default();
let b = EventId::default();
assert_ne!(a, b);
}
#[test]
fn timestamp_now_is_positive() {
let t = Timestamp::now();
assert!(t.as_ms() > 0);
}
#[test]
fn timestamp_roundtrip() {
let t = Timestamp::from_ms(1_700_000_000_000);
let json = serde_json::to_string(&t).unwrap();
let back: Timestamp = serde_json::from_str(&json).unwrap();
assert_eq!(back.as_ms(), 1_700_000_000_000);
}
#[test]
fn severity_serde_roundtrip() {
for s in [Severity::Info, Severity::Warning, Severity::Error] {
let json = serde_json::to_string(&s).unwrap();
let back: Severity = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
}
#[test]
fn severity_default_is_info() {
assert_eq!(Severity::default(), Severity::Info);
}
#[test]
fn axon_envelope_roundtrip() {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct Ping {
msg: String,
}
let payload = Ping { msg: "hello".into() };
let env = AxonEnvelope::new(ProjectType::NeuronRs, payload);
let json = env.to_json().unwrap();
let back: AxonEnvelope<Ping> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.project_type, ProjectType::NeuronRs);
assert_eq!(back.payload.msg, "hello");
assert_eq!(back.event_id, env.event_id);
}
#[test]
fn axon_envelope_timestamp_is_recent() {
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Empty;
let env = AxonEnvelope::new(ProjectType::Engram, Empty);
let now = Timestamp::now().as_ms();
// Should be within 5 seconds
assert!(now - env.timestamp.as_ms() < 5_000);
}
#[test]
fn event_id_display() {
let id = EventId(Uuid::nil());
assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
}
}
-183
View File
@@ -1,183 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::common::Severity;
/// Events emitted by the engram-lang compiler toolchain.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CompilerEvent {
CompileStarted(CompileStarted),
CompileSucceeded(CompileSucceeded),
CompileFailed(CompileFailed),
TestRunStarted(TestRunStarted),
TestPassed(TestPassed),
TestFailed(TestFailed),
TestRunCompleted(TestRunCompleted),
}
/// Compilation target platform.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum CompileTarget {
#[default]
Web,
Native,
Sealed,
}
/// A compilation job has started.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompileStarted {
pub file: String,
pub target: CompileTarget,
}
/// Compilation finished successfully and produced an artifact.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompileSucceeded {
pub file: String,
pub target: CompileTarget,
pub artifact_path: String,
pub duration_ms: u64,
}
/// Compilation failed with one or more errors.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompileFailed {
pub file: String,
pub target: CompileTarget,
pub errors: Vec<CompileError>,
}
/// A single compiler diagnostic.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompileError {
pub line: u32,
pub col: u32,
pub message: String,
pub severity: Severity,
}
/// A test run has started.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestRunStarted {
pub test_count: u64,
}
/// An individual test passed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestPassed {
pub name: String,
pub duration_ms: u64,
}
/// An individual test failed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestFailed {
pub name: String,
pub message: String,
pub duration_ms: u64,
}
/// All tests in the run have finished.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestRunCompleted {
pub passed: u64,
pub failed: u64,
pub duration_ms: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn compile_started_roundtrip() {
let evt = CompileStarted {
file: "src/main.el".into(),
target: CompileTarget::Web,
};
let env = AxonEnvelope::new(ProjectType::EngramLang, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<CompileStarted> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.file, "src/main.el");
assert_eq!(back.payload.target, CompileTarget::Web);
}
#[test]
fn compile_failed_with_errors() {
let evt = CompileFailed {
file: "lib.el".into(),
target: CompileTarget::Native,
errors: vec![
CompileError {
line: 10,
col: 5,
message: "unexpected token".into(),
severity: Severity::Error,
},
CompileError {
line: 20,
col: 1,
message: "unused variable".into(),
severity: Severity::Warning,
},
],
};
let json = serde_json::to_string(&evt).unwrap();
let back: CompileFailed = serde_json::from_str(&json).unwrap();
assert_eq!(back.errors.len(), 2);
assert_eq!(back.errors[0].severity, Severity::Error);
assert_eq!(back.errors[1].severity, Severity::Warning);
}
#[test]
fn test_run_completed_roundtrip() {
let evt = TestRunCompleted {
passed: 42,
failed: 3,
duration_ms: 1500,
};
let json = serde_json::to_string(&evt).unwrap();
let back: TestRunCompleted = serde_json::from_str(&json).unwrap();
assert_eq!(back.passed, 42);
assert_eq!(back.failed, 3);
}
#[test]
fn compile_target_default_is_web() {
assert_eq!(CompileTarget::default(), CompileTarget::Web);
}
#[test]
fn compiler_event_enum_roundtrip() {
let variant = CompilerEvent::TestFailed(TestFailed {
name: "test_parse".into(),
message: "assertion failed".into(),
duration_ms: 10,
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("test_failed"));
let back: CompilerEvent = serde_json::from_str(&json).unwrap();
if let CompilerEvent::TestFailed(t) = back {
assert_eq!(t.name, "test_parse");
} else {
panic!("wrong variant");
}
}
#[test]
fn compile_succeeded_envelope() {
let evt = CompileSucceeded {
file: "app.el".into(),
target: CompileTarget::Sealed,
artifact_path: "dist/app.sealed".into(),
duration_ms: 2300,
};
let env = AxonEnvelope::new(ProjectType::EngramLang, evt);
assert_eq!(env.project_type, ProjectType::EngramLang);
assert_eq!(env.payload.artifact_path, "dist/app.sealed");
}
}
-156
View File
@@ -1,156 +0,0 @@
use serde::{Deserialize, Serialize};
/// Events emitted by the engram graph-database layer.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EngramEvent {
NodeCreated(NodeCreated),
NodeUpdated(NodeUpdated),
NodeDeleted(NodeDeleted),
EdgeCreated(EdgeCreated),
ActivationCompleted(ActivationCompleted),
ConsolidationRan(ConsolidationRan),
SyncStarted(SyncStarted),
SyncCompleted(SyncCompleted),
SyncFailed(SyncFailed),
}
/// A new node was inserted into the graph.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NodeCreated {
pub id: String,
pub node_type: String,
pub salience: f64,
}
/// An existing node's fields were modified.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NodeUpdated {
pub id: String,
pub node_type: String,
/// Free-form list of field names that changed.
pub changes: Vec<String>,
}
/// A node was removed from the graph.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NodeDeleted {
pub id: String,
pub node_type: String,
}
/// A directed edge was added between two nodes.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EdgeCreated {
pub from_id: String,
pub to_id: String,
pub edge_type: String,
}
/// Spreading activation finished from a root node.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ActivationCompleted {
pub root_id: String,
pub activated_count: u64,
pub duration_ms: u64,
}
/// The consolidation pass ran — promotes high-salience nodes.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConsolidationRan {
pub promoted_count: u64,
pub duration_ms: u64,
}
/// A peer-sync session began.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SyncStarted {
pub peer_id: String,
pub peer_name: String,
}
/// A peer-sync session completed successfully.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SyncCompleted {
pub peer_id: String,
pub nodes_sent: u64,
pub nodes_received: u64,
pub duration_ms: u64,
}
/// A peer-sync session failed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SyncFailed {
pub peer_id: String,
pub error: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn node_created_envelope_roundtrip() {
let evt = NodeCreated {
id: "node-1".into(),
node_type: "memory".into(),
salience: 0.85,
};
let env = AxonEnvelope::new(ProjectType::Engram, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<NodeCreated> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.id, "node-1");
assert_eq!(back.payload.node_type, "memory");
assert!((back.payload.salience - 0.85).abs() < f64::EPSILON);
}
#[test]
fn edge_created_roundtrip() {
let evt = EdgeCreated {
from_id: "a".into(),
to_id: "b".into(),
edge_type: "relates_to".into(),
};
let json = serde_json::to_string(&evt).unwrap();
let back: EdgeCreated = serde_json::from_str(&json).unwrap();
assert_eq!(back.from_id, "a");
assert_eq!(back.to_id, "b");
}
#[test]
fn activation_completed_defaults() {
let evt = ActivationCompleted::default();
assert_eq!(evt.activated_count, 0);
assert_eq!(evt.duration_ms, 0);
}
#[test]
fn engram_event_enum_serde() {
let variant = EngramEvent::SyncFailed(SyncFailed {
peer_id: "peer-99".into(),
error: "connection refused".into(),
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("sync_failed"));
let back: EngramEvent = serde_json::from_str(&json).unwrap();
if let EngramEvent::SyncFailed(f) = back {
assert_eq!(f.peer_id, "peer-99");
} else {
panic!("wrong variant");
}
}
#[test]
fn sync_completed_envelope() {
let evt = SyncCompleted {
peer_id: "p1".into(),
nodes_sent: 10,
nodes_received: 5,
duration_ms: 200,
};
let env = AxonEnvelope::new(ProjectType::Engram, evt);
assert_eq!(env.project_type, ProjectType::Engram);
}
}
-163
View File
@@ -1,163 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::common::Severity;
/// Events emitted by the el-ide editor.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum IdeEvent {
FileOpened(FileOpened),
FileSaved(FileSaved),
DiagnosticPublished(DiagnosticPublished),
CompletionRequested(CompletionRequested),
HoverRequested(HoverRequested),
GoToDefinitionRequested(GoToDefinitionRequested),
FormatRequested(FormatRequested),
ActionExecuted(ActionExecuted),
}
/// A file was opened in the editor.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FileOpened {
pub path: String,
}
/// A file was saved.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FileSaved {
pub path: String,
}
/// A single IDE diagnostic (error, warning, or info).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Diagnostic {
pub line: u32,
pub col: u32,
pub message: String,
pub severity: Severity,
}
/// New diagnostics were published for a file.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DiagnosticPublished {
pub path: String,
pub diagnostics: Vec<Diagnostic>,
}
/// An auto-completion was requested at a cursor position.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompletionRequested {
pub path: String,
pub line: u32,
pub col: u32,
}
/// A hover tooltip was requested at a cursor position.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HoverRequested {
pub path: String,
pub line: u32,
pub col: u32,
}
/// Go-to-definition was requested at a cursor position.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GoToDefinitionRequested {
pub path: String,
pub line: u32,
pub col: u32,
}
/// Document formatting was requested for a file.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FormatRequested {
pub path: String,
}
/// A named IDE action was executed (e.g. "run-tests", "open-terminal").
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ActionExecuted {
pub action_name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn file_opened_roundtrip() {
let evt = FileOpened {
path: "src/main.el".into(),
};
let env = AxonEnvelope::new(ProjectType::ElIde, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<FileOpened> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.path, "src/main.el");
assert_eq!(back.project_type, ProjectType::ElIde);
}
#[test]
fn diagnostic_published_with_multiple() {
let evt = DiagnosticPublished {
path: "lib.el".into(),
diagnostics: vec![
Diagnostic {
line: 5,
col: 3,
message: "type mismatch".into(),
severity: Severity::Error,
},
Diagnostic {
line: 12,
col: 1,
message: "shadowed binding".into(),
severity: Severity::Warning,
},
],
};
let json = serde_json::to_string(&evt).unwrap();
let back: DiagnosticPublished = serde_json::from_str(&json).unwrap();
assert_eq!(back.diagnostics.len(), 2);
assert_eq!(back.diagnostics[0].severity, Severity::Error);
}
#[test]
fn completion_requested_roundtrip() {
let evt = CompletionRequested {
path: "src/lib.el".into(),
line: 42,
col: 10,
};
let json = serde_json::to_string(&evt).unwrap();
let back: CompletionRequested = serde_json::from_str(&json).unwrap();
assert_eq!(back.line, 42);
assert_eq!(back.col, 10);
}
#[test]
fn ide_event_enum_roundtrip() {
let variant = IdeEvent::ActionExecuted(ActionExecuted {
action_name: "run-tests".into(),
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("action_executed"));
let back: IdeEvent = serde_json::from_str(&json).unwrap();
if let IdeEvent::ActionExecuted(a) = back {
assert_eq!(a.action_name, "run-tests");
} else {
panic!("wrong variant");
}
}
#[test]
fn format_requested_roundtrip() {
let evt = FormatRequested {
path: "src/fmt.el".into(),
};
let json = serde_json::to_string(&evt).unwrap();
let back: FormatRequested = serde_json::from_str(&json).unwrap();
assert_eq!(back.path, "src/fmt.el");
}
}
-37
View File
@@ -1,37 +0,0 @@
#![deny(warnings)]
//! `axon-events` — typed event schema for the full neuron-technologies stack.
//!
//! Every service emits and consumes typed events wrapped in [`common::AxonEnvelope`].
//! Events are grouped by the project that emits them:
//!
//! | Module | Project |
//! |--------|---------|
//! | [`engram`] | engram database layer |
//! | [`compiler`] | engram-lang compiler |
//! | [`ui`] | el-ui frontend framework |
//! | [`ide`] | el-ide editor |
//! | [`runtime`] | neuron-rs agent host |
//! | [`publish`] | el-publish app-store pipeline |
//!
//! [`project`] defines [`project::ProjectType`], the discriminant tag
//! carried by every envelope.
//!
//! [`routing`] provides [`routing::AxonRouter`], the type-safe async
//! event dispatcher, and [`routing::AxonSubscription`] for subscription
//! negotiation.
pub mod common;
pub mod compiler;
pub mod engram;
pub mod ide;
pub mod project;
pub mod publish;
pub mod routing;
pub mod runtime;
pub mod ui;
// Convenience re-exports
pub use common::{AxonEnvelope, EventId, Severity, Timestamp};
pub use project::{ProjectContext, ProjectType};
pub use routing::{AxonError, AxonRouter, AxonSubscription};
-136
View File
@@ -1,136 +0,0 @@
use serde::{Deserialize, Serialize};
/// Every service in the neuron-technologies stack is identified by a
/// `ProjectType`. All Axon envelopes carry this tag so routers and
/// subscribers can filter without inspecting the payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectType {
/// engram — the graph database layer.
Engram,
/// engram-lang — the compiler / language toolchain.
EngramLang,
/// el-ui — the cross-platform UI framework.
ElUi,
/// el-ide — the integrated development environment.
ElIde,
/// neuron-rs — the Rust runtime / agent host.
NeuronRs,
/// neuron-agent — the cognitive agent layer.
NeuronAgent,
/// el-publish — the app-store publishing pipeline.
ElPublish,
}
impl std::fmt::Display for ProjectType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Engram => "engram",
Self::EngramLang => "engram_lang",
Self::ElUi => "el_ui",
Self::ElIde => "el_ide",
Self::NeuronRs => "neuron_rs",
Self::NeuronAgent => "neuron_agent",
Self::ElPublish => "el_publish",
};
write!(f, "{}", s)
}
}
/// Contextual metadata attached to a service or session — optional, but
/// useful when a single process manages multiple project contexts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectContext {
pub project_type: ProjectType,
/// Human-readable label for this context (e.g. service instance name).
pub label: Option<String>,
/// Semantic version of the emitting service, e.g. `"0.3.1"`.
pub version: Option<String>,
}
impl ProjectContext {
pub fn new(project_type: ProjectType) -> Self {
Self {
project_type,
label: None,
version: None,
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_type_serde_roundtrip() {
let variants = [
ProjectType::Engram,
ProjectType::EngramLang,
ProjectType::ElUi,
ProjectType::ElIde,
ProjectType::NeuronRs,
ProjectType::NeuronAgent,
ProjectType::ElPublish,
];
for v in variants {
let json = serde_json::to_string(&v).unwrap();
let back: ProjectType = serde_json::from_str(&json).unwrap();
assert_eq!(back, v, "roundtrip failed for {:?}", v);
}
}
#[test]
fn project_type_display() {
assert_eq!(ProjectType::Engram.to_string(), "engram");
assert_eq!(ProjectType::EngramLang.to_string(), "engram_lang");
assert_eq!(ProjectType::NeuronAgent.to_string(), "neuron_agent");
}
#[test]
fn project_context_builder() {
let ctx = ProjectContext::new(ProjectType::ElUi)
.with_label("renderer-1")
.with_version("1.2.3");
assert_eq!(ctx.project_type, ProjectType::ElUi);
assert_eq!(ctx.label.as_deref(), Some("renderer-1"));
assert_eq!(ctx.version.as_deref(), Some("1.2.3"));
}
#[test]
fn project_context_serde_roundtrip() {
let ctx = ProjectContext::new(ProjectType::NeuronRs)
.with_label("runtime")
.with_version("0.1.0");
let json = serde_json::to_string(&ctx).unwrap();
let back: ProjectContext = serde_json::from_str(&json).unwrap();
assert_eq!(back.project_type, ProjectType::NeuronRs);
assert_eq!(back.label.as_deref(), Some("runtime"));
}
#[test]
fn project_type_all_variants_have_unique_display() {
use std::collections::HashSet;
let variants = [
ProjectType::Engram,
ProjectType::EngramLang,
ProjectType::ElUi,
ProjectType::ElIde,
ProjectType::NeuronRs,
ProjectType::NeuronAgent,
ProjectType::ElPublish,
];
let strings: HashSet<String> = variants.iter().map(|v| v.to_string()).collect();
assert_eq!(strings.len(), variants.len());
}
}
-169
View File
@@ -1,169 +0,0 @@
use serde::{Deserialize, Serialize};
/// Events emitted by the el-publish app-store pipeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PublishEvent {
PublishStarted(PublishStarted),
CertificateValidated(CertificateValidated),
CertificateExpiringSoon(CertificateExpiringSoon),
BuildUploaded(BuildUploaded),
ReviewSubmitted(ReviewSubmitted),
ReviewApproved(ReviewApproved),
ReviewRejected(ReviewRejected),
RolloutAdvanced(RolloutAdvanced),
RolloutHalted(RolloutHalted),
}
/// A publish pipeline started for a platform+version.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PublishStarted {
pub platform: String,
pub version: String,
}
/// A code-signing certificate was validated successfully.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CertificateValidated {
pub platform: String,
/// ISO-8601 expiry date string, e.g. `"2026-01-01"`.
pub expiry_date: String,
}
/// A code-signing certificate is expiring within `days_remaining` days.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CertificateExpiringSoon {
pub platform: String,
pub days_remaining: u32,
}
/// A build artifact was uploaded to the store's distribution service.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BuildUploaded {
pub platform: String,
pub build_number: String,
pub size_bytes: u64,
}
/// The build was submitted to store review.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReviewSubmitted {
pub platform: String,
pub version: String,
}
/// Store review approved the submission.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReviewApproved {
pub platform: String,
pub version: String,
}
/// Store review rejected the submission.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReviewRejected {
pub platform: String,
pub version: String,
pub reason: String,
}
/// A phased rollout percentage was advanced.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RolloutAdvanced {
pub platform: String,
pub version: String,
/// Percentage of users on this version (0100).
pub percentage: u8,
}
/// A phased rollout was halted (e.g. due to crash spike).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RolloutHalted {
pub platform: String,
pub version: String,
pub reason: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn publish_started_roundtrip() {
let evt = PublishStarted {
platform: "ios".into(),
version: "2.1.0".into(),
};
let env = AxonEnvelope::new(ProjectType::ElPublish, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<PublishStarted> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.platform, "ios");
assert_eq!(back.payload.version, "2.1.0");
assert_eq!(back.project_type, ProjectType::ElPublish);
}
#[test]
fn certificate_expiring_soon() {
let evt = CertificateExpiringSoon {
platform: "android".into(),
days_remaining: 14,
};
let json = serde_json::to_string(&evt).unwrap();
let back: CertificateExpiringSoon = serde_json::from_str(&json).unwrap();
assert_eq!(back.days_remaining, 14);
}
#[test]
fn review_rejected_with_reason() {
let evt = ReviewRejected {
platform: "ios".into(),
version: "1.0.0".into(),
reason: "privacy policy missing".into(),
};
let json = serde_json::to_string(&evt).unwrap();
let back: ReviewRejected = serde_json::from_str(&json).unwrap();
assert_eq!(back.reason, "privacy policy missing");
}
#[test]
fn rollout_advanced_percentage_bounds() {
let evt = RolloutAdvanced {
platform: "web".into(),
version: "3.0.0".into(),
percentage: 50,
};
let json = serde_json::to_string(&evt).unwrap();
let back: RolloutAdvanced = serde_json::from_str(&json).unwrap();
assert_eq!(back.percentage, 50);
}
#[test]
fn publish_event_enum_roundtrip() {
let variant = PublishEvent::RolloutHalted(RolloutHalted {
platform: "android".into(),
version: "1.5.0".into(),
reason: "crash rate spike".into(),
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("rollout_halted"));
let back: PublishEvent = serde_json::from_str(&json).unwrap();
if let PublishEvent::RolloutHalted(r) = back {
assert_eq!(r.reason, "crash rate spike");
} else {
panic!("wrong variant");
}
}
#[test]
fn build_uploaded_roundtrip() {
let evt = BuildUploaded {
platform: "ios".into(),
build_number: "1234".into(),
size_bytes: 50_000_000,
};
let env = AxonEnvelope::new(ProjectType::ElPublish, evt);
assert_eq!(env.payload.size_bytes, 50_000_000);
}
}
-323
View File
@@ -1,323 +0,0 @@
use std::any::TypeId;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
/// Error returned when routing or handler execution fails.
#[derive(Debug, thiserror::Error)]
pub enum AxonError {
#[error("no handler registered for event type")]
NoHandler,
#[error("failed to deserialize envelope: {0}")]
Deserialize(#[from] serde_json::Error),
#[error("handler error: {0}")]
Handler(String),
}
// ------------------------------------------------------------------
// Internal type aliases
// ------------------------------------------------------------------
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
type ErasedHandler = Arc<
dyn Fn(serde_json::Value) -> BoxFuture<'static, Result<(), AxonError>> + Send + Sync + 'static,
>;
// ------------------------------------------------------------------
// AxonRouter
// ------------------------------------------------------------------
/// Type-safe async event router.
///
/// # Registering handlers
///
/// ```rust,ignore
/// let mut router = AxonRouter::new();
/// router.on::<CompileSucceeded>(|env| async move {
/// println!("compiled {} in {}ms", env.payload.file, env.payload.duration_ms);
/// Ok(())
/// });
/// ```
///
/// # Dispatching
///
/// Pass a raw JSON string; the router deserialises and routes it to the
/// registered handler. If no handler is registered the call returns
/// `Err(AxonError::NoHandler)`.
pub struct AxonRouter {
/// Maps `TypeId` of the payload type → erased async handler.
handlers: HashMap<TypeId, ErasedHandler>,
}
impl AxonRouter {
pub fn new() -> Self {
Self {
handlers: HashMap::new(),
}
}
/// Register an async handler for events carrying payload `T`.
///
/// The handler receives the fully-typed `AxonEnvelope<T>`.
/// Only one handler per `T` is supported; registering a second one
/// replaces the first.
pub fn on<T, F, Fut>(&mut self, handler: F)
where
T: Serialize + for<'de> Deserialize<'de> + Send + 'static,
F: Fn(AxonEnvelope<T>) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), AxonError>> + Send + 'static,
{
let handler = Arc::new(handler);
let erased: ErasedHandler = Arc::new(move |raw: serde_json::Value| {
let handler = Arc::clone(&handler);
let fut: BoxFuture<'static, Result<(), AxonError>> = Box::pin(async move {
let envelope: AxonEnvelope<T> = serde_json::from_value(raw)
.map_err(AxonError::Deserialize)?;
handler(envelope).await
});
fut
});
self.handlers.insert(TypeId::of::<T>(), erased);
}
/// Dispatch a pre-typed envelope to its registered handler.
///
/// Returns `Err(AxonError::NoHandler)` when no handler is registered
/// for `T`.
pub async fn dispatch<T>(&self, envelope: AxonEnvelope<T>) -> Result<(), AxonError>
where
T: Serialize + for<'de> Deserialize<'de> + Send + 'static,
{
let type_id = TypeId::of::<T>();
let handler = self
.handlers
.get(&type_id)
.ok_or(AxonError::NoHandler)?;
let raw = serde_json::to_value(&envelope).map_err(AxonError::Deserialize)?;
handler(raw).await
}
/// Dispatch a raw JSON string by first deserialising the envelope into
/// `AxonEnvelope<T>` and then routing to the registered handler for `T`.
pub async fn dispatch_json<T>(&self, json: &str) -> Result<(), AxonError>
where
T: Serialize + for<'de> Deserialize<'de> + Send + 'static,
{
let envelope: AxonEnvelope<T> =
serde_json::from_str(json).map_err(AxonError::Deserialize)?;
self.dispatch(envelope).await
}
/// Returns how many handlers are currently registered.
pub fn handler_count(&self) -> usize {
self.handlers.len()
}
/// Returns `true` if a handler is registered for payload type `T`.
pub fn has_handler<T: 'static>(&self) -> bool {
self.handlers.contains_key(&TypeId::of::<T>())
}
}
impl Default for AxonRouter {
fn default() -> Self {
Self::new()
}
}
// ------------------------------------------------------------------
// AxonSubscription — filter spec for subscription negotiation
// ------------------------------------------------------------------
/// Client subscription request — which project types and event types to
/// receive. Empty `Vec`s mean "subscribe to all".
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AxonSubscription {
/// Subscribe to all events from these project types.
/// Empty = subscribe to all project types.
pub project_types: Vec<ProjectType>,
/// Subscribe only to events whose payload type name is in this list.
/// Empty = subscribe to all event types.
pub event_types: Vec<String>,
}
impl AxonSubscription {
pub fn new() -> Self {
Self::default()
}
/// Convenience: subscribe only to events from a single project.
pub fn for_project(project_type: ProjectType) -> Self {
Self {
project_types: vec![project_type],
event_types: vec![],
}
}
/// Returns `true` when the subscription matches the given envelope
/// metadata. The `event_type` string is compared against the
/// `event_types` filter list.
pub fn matches(&self, project_type: ProjectType, event_type: &str) -> bool {
let project_ok = self.project_types.is_empty()
|| self.project_types.contains(&project_type);
let event_ok = self.event_types.is_empty()
|| self.event_types.iter().any(|t| t == event_type);
project_ok && event_ok
}
}
// ------------------------------------------------------------------
// Tests
// ------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::compiler::CompileSucceeded;
use crate::runtime::SessionStarted;
use crate::project::ProjectType;
use std::sync::{Arc, Mutex};
#[tokio::test]
async fn router_dispatches_to_correct_handler() {
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
let cap = Arc::clone(&captured);
let mut router = AxonRouter::new();
router.on(move |env: AxonEnvelope<SessionStarted>| {
let cap = Arc::clone(&cap);
async move {
cap.lock().unwrap().push(env.payload.session_id.clone());
Ok(())
}
});
let env = AxonEnvelope::new(
ProjectType::NeuronRs,
SessionStarted { session_id: "s-1".into() },
);
router.dispatch(env).await.unwrap();
assert_eq!(*captured.lock().unwrap(), vec!["s-1"]);
}
#[tokio::test]
async fn router_returns_no_handler_error() {
let router = AxonRouter::new();
let env = AxonEnvelope::new(
ProjectType::EngramLang,
CompileSucceeded {
file: "main.el".into(),
..Default::default()
},
);
let result = router.dispatch(env).await;
assert!(matches!(result, Err(AxonError::NoHandler)));
}
#[tokio::test]
async fn router_dispatch_json_roundtrip() {
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let cap = Arc::clone(&captured);
let mut router = AxonRouter::new();
router.on(move |env: AxonEnvelope<CompileSucceeded>| {
let cap = Arc::clone(&cap);
async move {
*cap.lock().unwrap() = Some(env.payload.file.clone());
Ok(())
}
});
let env = AxonEnvelope::new(
ProjectType::EngramLang,
CompileSucceeded {
file: "app.el".into(),
artifact_path: "dist/app.js".into(),
duration_ms: 500,
..Default::default()
},
);
let json = env.to_json().unwrap();
router.dispatch_json::<CompileSucceeded>(&json).await.unwrap();
assert_eq!(captured.lock().unwrap().as_deref(), Some("app.el"));
}
#[tokio::test]
async fn router_replaces_handler_on_second_registration() {
let counter: Arc<Mutex<u32>> = Arc::new(Mutex::new(0));
let c1 = Arc::clone(&counter);
let c2 = Arc::clone(&counter);
let mut router = AxonRouter::new();
router.on(move |_: AxonEnvelope<SessionStarted>| {
let c = Arc::clone(&c1);
async move { *c.lock().unwrap() += 10; Ok(()) }
});
// replace with a handler that adds 1
router.on(move |_: AxonEnvelope<SessionStarted>| {
let c = Arc::clone(&c2);
async move { *c.lock().unwrap() += 1; Ok(()) }
});
let env = AxonEnvelope::new(ProjectType::NeuronRs, SessionStarted::default());
router.dispatch(env).await.unwrap();
assert_eq!(*counter.lock().unwrap(), 1, "second handler should win");
}
#[test]
fn router_has_handler_reflects_registration() {
let mut router = AxonRouter::new();
assert!(!router.has_handler::<SessionStarted>());
router.on(|_: AxonEnvelope<SessionStarted>| async { Ok(()) });
assert!(router.has_handler::<SessionStarted>());
assert_eq!(router.handler_count(), 1);
}
#[test]
fn subscription_matches_all_when_empty() {
let sub = AxonSubscription::new();
assert!(sub.matches(ProjectType::Engram, "node_created"));
assert!(sub.matches(ProjectType::ElUi, "build_started"));
}
#[test]
fn subscription_filters_by_project() {
let sub = AxonSubscription::for_project(ProjectType::EngramLang);
assert!(sub.matches(ProjectType::EngramLang, "compile_started"));
assert!(!sub.matches(ProjectType::Engram, "node_created"));
}
#[test]
fn subscription_filters_by_event_type() {
let sub = AxonSubscription {
project_types: vec![],
event_types: vec!["compile_succeeded".into()],
};
assert!(sub.matches(ProjectType::EngramLang, "compile_succeeded"));
assert!(!sub.matches(ProjectType::EngramLang, "compile_failed"));
}
#[test]
fn subscription_serde_roundtrip() {
let sub = AxonSubscription {
project_types: vec![ProjectType::ElIde, ProjectType::ElUi],
event_types: vec!["file_opened".into()],
};
let json = serde_json::to_string(&sub).unwrap();
let back: AxonSubscription = serde_json::from_str(&json).unwrap();
assert_eq!(back.project_types.len(), 2);
assert_eq!(back.event_types[0], "file_opened");
}
}
-195
View File
@@ -1,195 +0,0 @@
use serde::{Deserialize, Serialize};
/// Events emitted by the neuron-rs runtime / agent host.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RuntimeEvent {
SessionStarted(SessionStarted),
SessionEnded(SessionEnded),
ToolCallStarted(ToolCallStarted),
ToolCallCompleted(ToolCallCompleted),
ToolCallFailed(ToolCallFailed),
MemoryWritten(MemoryWritten),
KnowledgeQueried(KnowledgeQueried),
BacklogUpdated(BacklogUpdated),
WorkContextOpened(WorkContextOpened),
WorkContextClosed(WorkContextClosed),
}
/// The action taken on a backlog item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum BacklogAction {
#[default]
Created,
Started,
Completed,
Blocked,
}
/// An agent session started.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionStarted {
pub session_id: String,
}
/// An agent session ended.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionEnded {
pub session_id: String,
pub duration_ms: u64,
}
/// A tool call was dispatched.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolCallStarted {
pub tool_name: String,
pub session_id: String,
}
/// A tool call returned successfully.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolCallCompleted {
pub tool_name: String,
pub session_id: String,
pub duration_ms: u64,
}
/// A tool call returned an error.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolCallFailed {
pub tool_name: String,
pub session_id: String,
pub error: String,
}
/// A memory node was written to the store.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MemoryWritten {
pub node_id: String,
pub chain_name: String,
}
/// A knowledge query was executed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KnowledgeQueried {
pub query: String,
pub result_count: u64,
pub duration_ms: u64,
}
/// A backlog item changed state.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BacklogUpdated {
pub item_id: String,
pub action: BacklogAction,
}
/// A work context was opened.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkContextOpened {
pub context_id: String,
pub process_name: String,
}
/// A work context was closed / completed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkContextClosed {
pub context_id: String,
pub duration_ms: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::AxonEnvelope;
use crate::project::ProjectType;
#[test]
fn session_started_roundtrip() {
let evt = SessionStarted {
session_id: "sess-abc".into(),
};
let env = AxonEnvelope::new(ProjectType::NeuronRs, evt);
let json = env.to_json().unwrap();
let back: AxonEnvelope<SessionStarted> = AxonEnvelope::from_json(&json).unwrap();
assert_eq!(back.payload.session_id, "sess-abc");
assert_eq!(back.project_type, ProjectType::NeuronRs);
}
#[test]
fn tool_call_lifecycle() {
let start = ToolCallStarted {
tool_name: "remember".into(),
session_id: "sess-1".into(),
};
let complete = ToolCallCompleted {
tool_name: "remember".into(),
session_id: "sess-1".into(),
duration_ms: 12,
};
let failed = ToolCallFailed {
tool_name: "recall".into(),
session_id: "sess-1".into(),
error: "not found".into(),
};
for json in [
serde_json::to_string(&start).unwrap(),
serde_json::to_string(&complete).unwrap(),
serde_json::to_string(&failed).unwrap(),
] {
assert!(!json.is_empty());
}
}
#[test]
fn backlog_action_default_is_created() {
assert_eq!(BacklogAction::default(), BacklogAction::Created);
}
#[test]
fn backlog_updated_all_actions() {
for action in [
BacklogAction::Created,
BacklogAction::Started,
BacklogAction::Completed,
BacklogAction::Blocked,
] {
let evt = BacklogUpdated {
item_id: "bl-1".into(),
action,
};
let json = serde_json::to_string(&evt).unwrap();
let back: BacklogUpdated = serde_json::from_str(&json).unwrap();
assert_eq!(back.action, action);
}
}
#[test]
fn runtime_event_enum_roundtrip() {
let variant = RuntimeEvent::WorkContextOpened(WorkContextOpened {
context_id: "ctx-99".into(),
process_name: "implement-feature".into(),
});
let json = serde_json::to_string(&variant).unwrap();
assert!(json.contains("work_context_opened"));
let back: RuntimeEvent = serde_json::from_str(&json).unwrap();
if let RuntimeEvent::WorkContextOpened(w) = back {
assert_eq!(w.context_id, "ctx-99");
} else {
panic!("wrong variant");
}
}
#[test]
fn knowledge_queried_envelope() {
let evt = KnowledgeQueried {
query: "VBD patterns".into(),
result_count: 5,
duration_ms: 30,
};
let env = AxonEnvelope::new(ProjectType::NeuronRs, evt);
assert_eq!(env.payload.result_count, 5);
}
}
-162
View File
@@ -1,162 +0,0 @@
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");
}
}
}
+4
View File
@@ -34,6 +34,10 @@ async fn main() {
let state = AppState::new(&db_path, api_key).expect("failed to initialize app state");
let state = Arc::new(state);
// Run on_startup hooks — each .el module with `fn on_startup` gets its
// own background thread. The loop timing and logic live entirely in Engram.
state.runtime.run_startup_hooks();
let app = routes::build_router(state);
let listener = tokio::net::TcpListener::bind(&bind)
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "neuron-cli"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "neuron"
path = "src/main.rs"
[dependencies]
reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive"] }
rustyline = "14"
uuid = { version = "1", features = ["v4"] }
colored = "2"
chrono = { version = "0.4", features = ["serde"] }
+339
View File
@@ -0,0 +1,339 @@
// src/main.rs
use clap::{Parser, Subcommand};
use colored::Colorize;
use rustyline::DefaultEditor;
use serde_json::{json, Value};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "neuron", about = "Neuron intelligence CLI")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
/// Operator mode — full tool access (uses NEURON_API_KEY)
#[arg(long, global = true)]
operator: bool,
/// User mode — standard access (uses NEURON_USER_KEY)
#[arg(long, global = true)]
user: bool,
}
#[derive(Subcommand)]
enum Commands {
/// One-shot ask
Ask {
question: String,
},
/// Save a memory
Remember {
content: String,
#[arg(short, long)]
importance: Option<String>,
},
/// Search memories and knowledge
Search {
query: String,
},
/// Check server status
Status,
}
struct NeuronClient {
base_url: String,
api_key: String,
tier: Tier,
client: reqwest::Client,
}
enum Tier {
Operator,
User,
}
impl NeuronClient {
fn new(operator: bool) -> Self {
let (api_key, tier) = if operator {
(
std::env::var("NEURON_API_KEY").unwrap_or_else(|_| "neuron-dev".to_string()),
Tier::Operator,
)
} else {
(
std::env::var("NEURON_USER_KEY")
.or_else(|_| std::env::var("NEURON_API_KEY"))
.unwrap_or_else(|_| "neuron-dev".to_string()),
Tier::User,
)
};
let base_url = std::env::var("NEURON_URL")
.unwrap_or_else(|_| "http://localhost:7770".to_string());
Self {
base_url,
api_key,
tier,
client: reqwest::Client::new(),
}
}
fn prompt(&self) -> String {
match self.tier {
Tier::Operator => "neuron[op]> ".cyan().bold().to_string(),
Tier::User => "neuron> ".cyan().to_string(),
}
}
async fn axon_call(&self, tool: &str, params: Value) -> Result<Value, String> {
let id = uuid::Uuid::new_v4().to_string();
let body = json!({
"id": id,
"method": "tool_call",
"params": {
"tool": tool,
"params": params
}
});
let resp = self.client
.post(format!("{}/axon/message", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| format!("Connection failed: {}. Is neuron running?", e))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err("Unauthorized. Check NEURON_API_KEY.".to_string());
}
resp.json::<Value>()
.await
.map_err(|e| format!("Parse error: {}", e))
}
async fn check_status(&self) -> bool {
self.client
.get(format!("{}/health", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
async fn remember(&self, content: &str, importance: Option<&str>) -> String {
let params = json!({
"content": content,
"importance": importance.unwrap_or("normal"),
"tags": ["cli"]
});
match self.axon_call("remember", params).await {
Ok(v) => {
let id = v.get("id")
.or_else(|| v.get("result").and_then(|r| r.get("id")))
.and_then(|i| i.as_str())
.unwrap_or("saved");
format!("✓ Remembered ({})", id)
}
Err(e) => format!("{}", e),
}
}
async fn search(&self, query: &str) -> String {
let params = json!({ "query": query, "limit": 10 });
match self.axon_call("search_knowledge", params).await {
Ok(v) => format_search_results(&v),
Err(_) => {
// Try memory search
match self.axon_call("recall", json!({ "query": query })).await {
Ok(v) => format_search_results(&v),
Err(e) => format!("{}", e),
}
}
}
}
async fn chat(&self, message: &str) -> String {
let params = json!({
"message": message,
"context": "cli"
});
match self.axon_call("chat", params).await {
Ok(v) => extract_text_result(&v),
Err(e) => format!("{}", e),
}
}
}
fn extract_text_result(v: &Value) -> String {
// Try various result shapes
if let Some(r) = v.get("result") {
if let Some(s) = r.as_str() { return s.to_string(); }
if let Some(c) = r.get("content").and_then(|c| c.as_str()) { return c.to_string(); }
return serde_json::to_string_pretty(r).unwrap_or_default();
}
if let Some(e) = v.get("error").and_then(|e| e.as_str()) {
return format!("error: {}", e);
}
serde_json::to_string_pretty(v).unwrap_or_default()
}
fn format_search_results(v: &Value) -> String {
let items = v.get("result")
.or(Some(v))
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
if items.is_empty() {
return "No results found.".to_string();
}
let mut out = String::new();
for (i, item) in items.iter().take(10).enumerate() {
let title = item.get("title").and_then(|t| t.as_str())
.or_else(|| item.get("content").and_then(|c| c.as_str()).map(|s| &s[..s.len().min(60)]))
.unwrap_or("(untitled)");
let id = item.get("id").and_then(|i| i.as_str()).unwrap_or("");
out.push_str(&format!("{}. {} [{}]\n", i + 1, title, id));
}
out
}
fn history_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".neuron").join("cli_history")
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
// Operator mode if --operator flag or neither flag (default operator for now)
let is_operator = cli.operator || !cli.user;
let nc = NeuronClient::new(is_operator);
match cli.command {
Some(Commands::Status) => {
let ok = nc.check_status().await;
if ok {
println!("{} neuron-api is running at {}", "".green(), nc.base_url);
} else {
println!("{} neuron-api is not responding at {}", "".red(), nc.base_url);
std::process::exit(1);
}
}
Some(Commands::Ask { question }) => {
let result = nc.chat(&question).await;
println!("{}", result);
}
Some(Commands::Remember { content, importance }) => {
let result = nc.remember(&content, importance.as_deref()).await;
if result.starts_with('✓') {
println!("{}", result.green());
} else {
eprintln!("{}", result.red());
std::process::exit(1);
}
}
Some(Commands::Search { query }) => {
let result = nc.search(&query).await;
println!("{}", result);
}
None => {
// Interactive REPL
if !nc.check_status().await {
eprintln!(
"{} Neuron is not running at {}.\nStart it with: neuron-api",
"".red().bold(),
nc.base_url
);
std::process::exit(1);
}
println!(
"{} {}",
"Neuron".cyan().bold(),
"intelligence CLI — type :help for commands, :quit to exit".dimmed()
);
println!();
let hist = history_path();
let mut rl = DefaultEditor::new().expect("readline init failed");
if hist.exists() {
let _ = rl.load_history(&hist);
}
let prompt = nc.prompt();
loop {
match rl.readline(&prompt) {
Ok(line) => {
let input = line.trim().to_string();
if input.is_empty() { continue; }
let _ = rl.add_history_entry(&input);
match input.as_str() {
":quit" | ":exit" | ":q" => {
let _ = rl.save_history(&hist);
println!("bye");
break;
}
":help" => {
println!("Commands:");
println!(" :status — check server status");
println!(" :quit — exit");
println!(" :help — this message");
println!(" remember <text> — save a memory");
println!(" search <query> — search knowledge");
println!(" anything else — chat");
}
":status" => {
let ok = nc.check_status().await;
if ok {
println!("{}", "● online".green());
} else {
println!("{}", "● offline".red());
}
}
_ if input.starts_with("remember ") => {
let content = &input["remember ".len()..];
let result = nc.remember(content, None).await;
println!("{}", if result.starts_with('✓') { result.green() } else { result.red() });
}
_ if input.starts_with("search ") => {
let query = &input["search ".len()..];
let result = nc.search(query).await;
println!("{}", result);
}
_ => {
let result = nc.chat(&input).await;
println!("{}", result.white());
}
}
}
Err(rustyline::error::ReadlineError::Interrupted) => {
println!();
continue;
}
Err(rustyline::error::ReadlineError::Eof) => {
let _ = rl.save_history(&hist);
break;
}
Err(e) => {
eprintln!("readline error: {}", e);
break;
}
}
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "neuron-migrate"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "neuron-migrate"
path = "src/main.rs"
[dependencies]
neuron-domain = { path = "../neuron-domain" }
neuron-store = { path = "../neuron-store" }
engram-core = { path = "../../../../foundation/engram/engrams/engram-core" }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
+794
View File
@@ -0,0 +1,794 @@
//! neuron-migrate: Migrate the Neuron production SQLite database into Engram DB format.
//!
//! Source: ~/.neuron/neuron.db (old SQLite schema)
//! Target: ~/.neuron/engram/ (new Engram format via neuron-store)
//!
//! Tables migrated:
//! memory_nodes → Memory domain type → MemoryStore (NodeType::Memory)
//! knowledge → KnowledgeEntry → KnowledgeStore (NodeType::Entity)
//! backlog_items → BacklogItem → BacklogStore (NodeType::Process)
//! execution_contexts → ExecutionContext → ContextStore (NodeType::Event)
//! artifacts → Memory (tagged) → MemoryStore (no Artifact domain type yet)
//! config_entries → Memory (tagged) → MemoryStore (preserved as structured JSON)
//!
//! IMPORTANT: sled's EngramDb uses an exclusive file lock per path, so we open
//! the DB *once* and perform all node writes through that single handle, bypassing
//! the individual store wrappers (which each take ownership of a separate EngramDb).
//! The encoding (serde_json) and NodeType assignments mirror neuron-store exactly.
use anyhow::{Context, Result};
use engram_core::{EngramDb, MemoryTier, Node, NodeType};
use neuron_domain::{
BacklogItem, BacklogStatus, ContextStatus, ContextStep, ExecutionContext, Importance,
KnowledgeEntry, KnowledgeTier, ItemType, Memory, Priority,
};
use rusqlite::Connection;
use serde_json::Value;
use std::path::PathBuf;
use uuid::Uuid;
// ── Codec (mirrors neuron-store/src/codec.rs) ─────────────────────────────────
fn encode<T: serde::Serialize>(value: &T) -> Vec<u8> {
serde_json::to_vec(value).expect("serialization should not fail for well-formed domain types")
}
const EMBEDDING_DIM: usize = 64;
fn hash_embedding(text: &str) -> Vec<f32> {
let bytes = text.as_bytes();
let mut vals = vec![0.0f32; EMBEDDING_DIM];
for (i, b) in bytes.iter().enumerate() {
vals[i % EMBEDDING_DIM] += *b as f32;
}
let norm: f32 = vals.iter().map(|v| v * v).sum::<f32>().sqrt();
if norm > 0.0 {
for v in &mut vals {
*v /= norm;
}
} else {
vals[0] = 1.0;
}
vals
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Parse a pipe/comma-separated or JSON-array tag string into a Vec<String>.
fn parse_tags(raw: &str) -> Vec<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return vec![];
}
if let Ok(Value::Array(arr)) = serde_json::from_str(trimmed) {
return arr
.into_iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
trimmed
.split([',', '|'])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// Parse a JSON array of UUID strings into Vec<Uuid>.
fn parse_uuid_list(raw: &str) -> Vec<Uuid> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed == "[]" {
return vec![];
}
if let Ok(Value::Array(arr)) = serde_json::from_str(trimmed) {
return arr
.into_iter()
.filter_map(|v| v.as_str().and_then(|s| Uuid::parse_str(s).ok()))
.collect();
}
vec![]
}
/// Parse a JSON array of strings into Vec<String>.
fn parse_string_list(raw: &str) -> Vec<String> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed == "[]" {
return vec![];
}
if let Ok(Value::Array(arr)) = serde_json::from_str(trimmed) {
return arr
.into_iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
vec![trimmed.to_string()]
}
/// Parse a nullable string column to Option<String>, treating empty strings as None.
fn opt_str(s: Option<String>) -> Option<String> {
s.and_then(|v| if v.is_empty() { None } else { Some(v) })
}
/// Parse an ISO8601 timestamp string or integer ms into i64 ms.
fn parse_iso_or_ms(s: &str) -> i64 {
let trimmed = s.trim();
if let Ok(n) = trimmed.parse::<i64>() {
return n;
}
use chrono::DateTime;
if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
return dt.timestamp_millis();
}
// Try "YYYY-MM-DD HH:MM:SS+TZ" format
if let Ok(dt) = chrono::DateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S%.f%z") {
return dt.timestamp_millis();
}
neuron_domain::now_ms()
}
/// Derive a deterministic UUID from a string identifier by simple mixing.
fn deterministic_uuid(s: &str) -> Uuid {
let bytes = s.as_bytes();
let mut out = [0u8; 16];
for (i, b) in bytes.iter().enumerate() {
out[i % 16] ^= b.wrapping_add((i as u8).wrapping_mul(31));
}
// Set UUID version 5 bits (variant 2, version 5)
out[6] = (out[6] & 0x0f) | 0x50;
out[8] = (out[8] & 0x3f) | 0x80;
Uuid::from_bytes(out)
}
/// Parse an old-style prefixed ID (e.g. "mem-xxxxxxxx") into a Uuid.
fn parse_id(id_str: &str, prefix: &str) -> Uuid {
// Try plain UUID first
if let Ok(u) = Uuid::parse_str(id_str) {
return u;
}
// Strip known prefix and try again
let stripped = id_str.trim_start_matches(prefix).trim_start_matches('-');
if let Ok(u) = Uuid::parse_str(stripped) {
return u;
}
// Fall back to deterministic derivation
deterministic_uuid(id_str)
}
// ── Stats ─────────────────────────────────────────────────────────────────────
struct Stats {
migrated: usize,
errors: usize,
}
impl Stats {
fn new() -> Self { Self { migrated: 0, errors: 0 } }
}
// ── Migration functions ───────────────────────────────────────────────────────
fn migrate_memory_nodes(src: &Connection, db: &EngramDb) -> Result<Stats> {
let mut stmt = src.prepare(
"SELECT id, content, tags, importance, superseded_by, \
created_at, updated_at, project, metadata, pinned \
FROM memory_nodes",
)?;
let mut stats = Stats::new();
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, i64>(5)?,
row.get::<_, i64>(6)?,
row.get::<_, Option<String>>(7)?,
row.get::<_, Option<String>>(8)?,
row.get::<_, Option<i64>>(9)?,
))
})?;
for row_result in rows {
let (id_str, content, tags_raw, importance_str, superseded_by,
created_at, updated_at, project, metadata_raw, pinned) = match row_result {
Ok(r) => r,
Err(e) => { eprintln!(" [memory] row error: {e}"); stats.errors += 1; continue; }
};
let id = parse_id(&id_str, "mem");
let supersedes_id = superseded_by.as_deref()
.filter(|s| !s.is_empty())
.map(|s| parse_id(s, "mem"));
let mut tags = parse_tags(&tags_raw);
if pinned == Some(1) { tags.push("pinned".to_string()); }
let extra = match &metadata_raw {
Some(m) if m.trim() != "{}" && !m.trim().is_empty() => {
format!("\n\n[metadata: {}]", m.trim())
}
_ => String::new(),
};
let full_content = if extra.is_empty() { content } else { format!("{}{}", content, extra) };
let memory = Memory {
id,
content: full_content,
tags,
project: opt_str(project),
importance: Importance::from_str_lossy(&importance_str),
supersedes_id,
created_at,
updated_at,
};
let content_bytes = encode(&memory);
let embedding = hash_embedding(&memory.content);
let importance = memory.importance.to_f32();
let node = Node::new(
NodeType::Memory,
embedding,
content_bytes,
MemoryTier::Episodic,
importance,
).with_id(memory.id);
match db.put_node(node) {
Ok(_) => stats.migrated += 1,
Err(e) => { eprintln!(" [memory] put error for {id_str}: {e}"); stats.errors += 1; }
}
}
Ok(stats)
}
fn migrate_knowledge(src: &Connection, db: &EngramDb) -> Result<Stats> {
let mut stmt = src.prepare(
"SELECT id, key, description, tags, raw_content, compiled_content, \
tier, disposition, created_at \
FROM knowledge",
)?;
let mut stats = Stats::new();
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
row.get::<_, String>(7)?,
row.get::<_, String>(8)?,
))
})?;
for row_result in rows {
let (id_str, key, description, tags_raw, raw_content, compiled_content,
tier_str, disposition, created_at_str) = match row_result {
Ok(r) => r,
Err(e) => { eprintln!(" [knowledge] row error: {e}"); stats.errors += 1; continue; }
};
let id = parse_id(&id_str, "kn");
// Use description as title when it differs from the path key
let title = if description.is_empty() || description == key {
key.clone()
} else {
description.clone()
};
// Prefer compiled_content; fall back to raw_content
let content = if !compiled_content.trim().is_empty() {
compiled_content
} else {
raw_content
};
let mut tags = parse_tags(&tags_raw);
if !disposition.is_empty() && disposition != "experimental" {
tags.push(format!("disposition:{}", disposition));
}
// Infer category from path prefix
let category = key.splitn(2, '/').next().unwrap_or("general").to_string();
let created_at = parse_iso_or_ms(&created_at_str);
let entry = KnowledgeEntry {
id,
title,
content: content.clone(),
category,
tier: KnowledgeTier::from_str_lossy(&tier_str),
tags,
project: None,
created_at,
};
let content_bytes = encode(&entry);
let text = format!("{} {} {}", entry.title, entry.category, content);
let embedding = hash_embedding(&text);
let node = Node::new(
NodeType::Entity,
embedding,
content_bytes,
MemoryTier::Semantic,
0.7,
).with_id(entry.id);
match db.put_node(node) {
Ok(_) => stats.migrated += 1,
Err(e) => { eprintln!(" [knowledge] put error for {id_str}: {e}"); stats.errors += 1; }
}
}
Ok(stats)
}
fn migrate_backlog(src: &Connection, db: &EngramDb) -> Result<Stats> {
let mut stmt = src.prepare(
"SELECT id, title, description, priority, status, project, tags, \
item_type, depends_on, created_at, updated_at, \
complexity, effort_estimate, resolution_notes, assigned_to, process, metadata \
FROM backlog_items",
)?;
let mut stats = Stats::new();
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, String>(6)?,
row.get::<_, String>(7)?,
row.get::<_, String>(8)?,
row.get::<_, i64>(9)?,
row.get::<_, i64>(10)?,
row.get::<_, Option<String>>(11)?,
row.get::<_, Option<String>>(12)?,
row.get::<_, Option<String>>(13)?,
row.get::<_, Option<String>>(14)?,
row.get::<_, Option<String>>(15)?,
row.get::<_, Option<String>>(16)?,
))
})?;
for row_result in rows {
let (id_str, title, description, priority_str, status_str, project, tags_raw,
item_type_str, depends_on_raw, created_at, updated_at,
complexity, effort_estimate, resolution_notes, assigned_to, process, metadata_raw)
= match row_result {
Ok(r) => r,
Err(e) => { eprintln!(" [backlog] row error: {e}"); stats.errors += 1; continue; }
};
let id = parse_id(&id_str, "bl");
let mut tags = parse_tags(&tags_raw);
if let Some(c) = complexity.as_deref() {
if !c.is_empty() && c != "moderate" { tags.push(format!("complexity:{}", c)); }
}
if let Some(e) = effort_estimate.as_deref() {
if !e.is_empty() { tags.push(format!("effort:{}", e)); }
}
if let Some(a) = assigned_to.as_deref() {
if !a.is_empty() { tags.push(format!("assigned:{}", a)); }
}
if let Some(p) = process.as_deref() {
if !p.is_empty() { tags.push(format!("process:{}", p)); }
}
let mut full_description = description;
if let Some(notes) = resolution_notes.as_deref() {
if !notes.is_empty() { full_description.push_str(&format!("\n\n[Resolution: {}]", notes)); }
}
if let Some(m) = metadata_raw.as_deref() {
if m.trim() != "{}" && !m.trim().is_empty() {
full_description.push_str(&format!("\n\n[metadata: {}]", m.trim()));
}
}
let depends_on = parse_uuid_list(&depends_on_raw);
let item = BacklogItem {
id,
title: title.clone(),
description: full_description.clone(),
item_type: ItemType::from_str_lossy(&item_type_str),
priority: Priority::from_str_lossy(&priority_str),
status: BacklogStatus::from_str_lossy(&status_str),
project: opt_str(project),
tags,
depends_on,
created_at,
updated_at,
};
let content_bytes = encode(&item);
let text = format!("{} {}", title, full_description);
let embedding = hash_embedding(&text);
let node = Node::new(
NodeType::Process,
embedding,
content_bytes,
MemoryTier::Working,
0.6,
).with_id(item.id);
match db.put_node(node) {
Ok(_) => stats.migrated += 1,
Err(e) => { eprintln!(" [backlog] put error for {id_str}: {e}"); stats.errors += 1; }
}
}
Ok(stats)
}
fn migrate_contexts(src: &Connection, db: &EngramDb) -> Result<Stats> {
let mut stmt = src.prepare(
"SELECT id, process_name, description, objective, status, project, \
COALESCE(steps_json, '[]'), COALESCE(file_refs, '[]'), \
COALESCE(key_decisions, '[]'), COALESCE(lessons_learned, '[]'), \
created_at, updated_at, summary, results_summary, error_message \
FROM execution_contexts",
)?;
let mut stats = Stats::new();
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, String>(6)?,
row.get::<_, String>(7)?,
row.get::<_, String>(8)?,
row.get::<_, String>(9)?,
row.get::<_, i64>(10)?,
row.get::<_, i64>(11)?,
row.get::<_, Option<String>>(12)?,
row.get::<_, Option<String>>(13)?,
row.get::<_, Option<String>>(14)?,
))
})?;
for row_result in rows {
let (id_str, process_name, mut description, objective, status_str, project,
steps_json, file_refs_raw, key_decisions_raw, lessons_learned_raw,
created_at, updated_at, summary, results_summary, error_message)
= match row_result {
Ok(r) => r,
Err(e) => { eprintln!(" [contexts] row error: {e}"); stats.errors += 1; continue; }
};
let id = parse_id(&id_str, "ctx");
if let Some(s) = summary.as_deref() {
if !s.is_empty() { description.push_str(&format!("\n\n[Summary: {}]", s)); }
}
if let Some(r) = results_summary.as_deref() {
if !r.is_empty() { description.push_str(&format!("\n\n[Results: {}]", r)); }
}
if let Some(e) = error_message.as_deref() {
if !e.is_empty() { description.push_str(&format!("\n\n[Error: {}]", e)); }
}
let steps = parse_steps_json(&steps_json);
let file_refs = parse_string_list(&file_refs_raw);
let key_decisions = parse_string_list(&key_decisions_raw);
let lessons_learned = parse_string_list(&lessons_learned_raw);
let ctx = ExecutionContext {
id,
process_name: process_name.clone(),
description,
objective: objective.clone(),
status: ContextStatus::from_str_lossy(&status_str),
project: opt_str(project),
steps,
file_refs,
key_decisions,
lessons_learned,
created_at,
updated_at,
};
let content_bytes = encode(&ctx);
let text = format!("{} {}", process_name, objective);
let embedding = hash_embedding(&text);
let node = Node::new(
NodeType::Event,
embedding,
content_bytes,
MemoryTier::Working,
0.7,
).with_id(ctx.id);
match db.put_node(node) {
Ok(_) => stats.migrated += 1,
Err(e) => { eprintln!(" [contexts] put error for {id_str}: {e}"); stats.errors += 1; }
}
}
Ok(stats)
}
/// Migrate artifacts as Memory nodes tagged with "artifact".
fn migrate_artifacts(src: &Connection, db: &EngramDb) -> Result<Stats> {
let mut stmt = src.prepare(
"SELECT id, title, content, artifact_types, status, project, \
tags, version, created_at, updated_at \
FROM artifacts",
)?;
let mut stats = Stats::new();
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, String>(6)?,
row.get::<_, i64>(7)?,
row.get::<_, i64>(8)?,
row.get::<_, i64>(9)?,
))
})?;
for row_result in rows {
let (id_str, title, content, artifact_types, status, project,
tags_raw, version, created_at, updated_at) = match row_result {
Ok(r) => r,
Err(e) => { eprintln!(" [artifacts] row error: {e}"); stats.errors += 1; continue; }
};
let id = parse_id(&id_str, "art");
let mut tags = parse_tags(&tags_raw);
tags.push("artifact".to_string());
tags.push(format!("artifact_status:{}", status));
for atype in parse_tags(&artifact_types) {
tags.push(format!("artifact_type:{}", atype));
}
if version > 1 { tags.push(format!("version:{}", version)); }
// Embed full artifact as structured JSON so it's fully recoverable
let artifact_doc = serde_json::json!({
"type": "artifact",
"id": id_str,
"title": title,
"status": status,
"artifact_types": parse_tags(&artifact_types),
"version": version,
"content": content,
});
let memory_content = format!(
"ARTIFACT: {}\n\n{}\n\n---\n{}",
title, content, artifact_doc,
);
let memory = Memory {
id,
content: memory_content,
tags,
project: opt_str(project),
importance: Importance::High,
supersedes_id: None,
created_at,
updated_at,
};
let content_bytes = encode(&memory);
let embedding = hash_embedding(&memory.content);
let node = Node::new(
NodeType::Memory,
embedding,
content_bytes,
MemoryTier::Episodic,
Importance::High.to_f32(),
).with_id(memory.id);
match db.put_node(node) {
Ok(_) => stats.migrated += 1,
Err(e) => { eprintln!(" [artifacts] put error for {id_str}: {e}"); stats.errors += 1; }
}
}
Ok(stats)
}
/// Migrate config_entries as Memory nodes tagged with "config".
fn migrate_config(src: &Connection, db: &EngramDb) -> Result<Stats> {
let mut stmt = src.prepare(
"SELECT key, value, updated_at FROM config_entries",
)?;
let mut stats = Stats::new();
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
))
})?;
for row_result in rows {
let (key, value, updated_at) = match row_result {
Ok(r) => r,
Err(e) => { eprintln!(" [config] row error: {e}"); stats.errors += 1; continue; }
};
let id = deterministic_uuid(&format!("config:{}", key));
let content_str = format!("CONFIG: {}\n\nValue: {}", key, value);
let memory = Memory {
id,
content: content_str,
tags: vec!["config".to_string(), format!("config_key:{}", key)],
project: None,
importance: Importance::High,
supersedes_id: None,
created_at: updated_at,
updated_at,
};
let content_bytes = encode(&memory);
let embedding = hash_embedding(&memory.content);
let node = Node::new(
NodeType::Memory,
embedding,
content_bytes,
MemoryTier::Episodic,
Importance::High.to_f32(),
).with_id(memory.id);
match db.put_node(node) {
Ok(_) => {
println!(" config: {}", key);
stats.migrated += 1;
}
Err(e) => { eprintln!(" [config] put error for {key}: {e}"); stats.errors += 1; }
}
}
Ok(stats)
}
// ── Step parsers ──────────────────────────────────────────────────────────────
fn parse_steps_json(raw: &str) -> Vec<ContextStep> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed == "[]" { return vec![]; }
let arr: Vec<Value> = match serde_json::from_str(trimmed) {
Ok(Value::Array(a)) => a,
_ => return vec![],
};
arr.into_iter().filter_map(|v| {
let name = v.get("name").and_then(|n| n.as_str())
.or_else(|| v.get("action").and_then(|a| a.as_str()))
.unwrap_or("unknown").to_string();
let status = v.get("status").and_then(|s| s.as_str())
.unwrap_or("completed").to_string();
let notes = v.get("notes").and_then(|n| n.as_str())
.filter(|s| !s.is_empty()).map(|s| s.to_string());
let started_at = v.get("started_at").and_then(|t| t.as_i64())
.unwrap_or_else(neuron_domain::now_ms);
let completed_at = v.get("completed_at").and_then(|t| t.as_i64());
Some(ContextStep { name, status, notes, started_at, completed_at })
}).collect()
}
// ── Main ──────────────────────────────────────────────────────────────────────
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter("warn")
.init();
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let src_path = PathBuf::from(&home).join(".neuron/neuron.db");
let dst_path_str = std::env::var("NEURON_DB_PATH")
.unwrap_or_else(|_| format!("{}/.neuron/engram", home));
let dst_path = PathBuf::from(&dst_path_str);
println!("neuron-migrate");
println!(" source: {}", src_path.display());
println!(" target: {}", dst_path.display());
println!();
if !src_path.exists() {
anyhow::bail!("source database not found: {}", src_path.display());
}
std::fs::create_dir_all(&dst_path)
.with_context(|| format!("failed to create target dir: {}", dst_path.display()))?;
// Open source DB (read-only)
let src = Connection::open_with_flags(
&src_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
).with_context(|| format!("failed to open source DB: {}", src_path.display()))?;
// Open target Engram DB — SINGLE handle for the entire migration.
// sled uses an exclusive file lock; opening multiple handles to the same path
// in the same process will fail with WouldBlock on the second open.
let db = EngramDb::open(&dst_path)
.map_err(|e| anyhow::anyhow!("failed to open Engram DB: {e}"))?;
println!("Migrating memory_nodes...");
let mem_stats = migrate_memory_nodes(&src, &db)?;
println!(" done: {} migrated, {} errors", mem_stats.migrated, mem_stats.errors);
println!("Migrating knowledge...");
let kn_stats = migrate_knowledge(&src, &db)?;
println!(" done: {} migrated, {} errors", kn_stats.migrated, kn_stats.errors);
println!("Migrating backlog_items...");
let bl_stats = migrate_backlog(&src, &db)?;
println!(" done: {} migrated, {} errors", bl_stats.migrated, bl_stats.errors);
println!("Migrating execution_contexts...");
let ctx_stats = migrate_contexts(&src, &db)?;
println!(" done: {} migrated, {} errors", ctx_stats.migrated, ctx_stats.errors);
println!("Migrating artifacts (as tagged memory nodes)...");
let art_stats = migrate_artifacts(&src, &db)?;
println!(" done: {} migrated, {} errors", art_stats.migrated, art_stats.errors);
println!("Migrating config_entries (as tagged memory nodes)...");
let cfg_stats = migrate_config(&src, &db)?;
println!(" done: {} migrated, {} errors", cfg_stats.migrated, cfg_stats.errors);
let total_migrated = mem_stats.migrated + kn_stats.migrated + bl_stats.migrated
+ ctx_stats.migrated + art_stats.migrated + cfg_stats.migrated;
let total_errors = mem_stats.errors + kn_stats.errors + bl_stats.errors
+ ctx_stats.errors + art_stats.errors + cfg_stats.errors;
println!();
println!("=== Migration Report ===");
println!(" memory_nodes : {:>4} records", mem_stats.migrated);
println!(" knowledge : {:>4} records", kn_stats.migrated);
println!(" backlog_items : {:>4} records", bl_stats.migrated);
println!(" execution_contexts : {:>4} records", ctx_stats.migrated);
println!(" artifacts : {:>4} records", art_stats.migrated);
println!(" config_entries : {:>4} records", cfg_stats.migrated);
println!(" ---");
println!(" TOTAL MIGRATED : {:>4} records", total_migrated);
if total_errors > 0 {
println!(" TOTAL ERRORS : {:>4} records", total_errors);
}
println!();
println!("Engram DB: {}", dst_path.display());
println!("Source DB preserved: {}", src_path.display());
if total_errors > 0 {
eprintln!("WARNING: {} record(s) could not be migrated (see errors above).", total_errors);
} else {
println!("Migration completed successfully with zero errors.");
}
Ok(())
}
+6 -4
View File
@@ -2,18 +2,20 @@
name = "neuron-protocol"
version = "0.1.0"
edition = "2021"
description = "Thin re-export shim — delegates to the canonical axon-protocol and axon-events platform crates."
[dependencies]
# Canonical platform crates — all real types live here
axon-protocol = { workspace = true }
axon-events = { workspace = true }
# Retained for any neuron-rsspecific protocol extensions
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 }
tokio = { workspace = true }
axum = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = "3"
-266
View File
@@ -1,266 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ------------------------------------------------------------------
// 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.
pub id: String,
/// 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.
pub id: String,
/// Whether the call succeeded.
pub success: bool,
/// The result payload (on success) or error message (on failure).
pub result: Value,
}
impl AxonResponse {
pub fn ok(id: impl Into<String>, result: Value) -> Self {
Self {
id: id.into(),
success: true,
result,
}
}
pub fn err(id: impl Into<String>, message: impl Into<String>) -> Self {
Self {
id: id.into(),
success: false,
result: serde_json::json!({ "error": message.into() }),
}
}
}
// ------------------------------------------------------------------
// 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 {
pub event_type: String,
pub payload: Value,
pub timestamp: i64,
}
impl AxonEvent {
pub fn new(event_type: impl Into<String>, payload: Value) -> Self {
Self {
event_type: event_type.into(),
payload,
timestamp: neuron_domain::now_ms(),
}
}
}
// ------------------------------------------------------------------
// Tests
// ------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn axon_response_ok() {
let r = AxonResponse::ok("req-1", serde_json::json!({"id": "abc"}));
assert!(r.success);
assert_eq!(r.id, "req-1");
}
#[test]
fn axon_response_err() {
let r = AxonResponse::err("req-2", "not found");
assert!(!r.success);
assert_eq!(r.result["error"], "not found");
}
#[test]
fn axon_message_roundtrip_with_method_enum() {
let msg = AxonMessage {
id: "m1".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, 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);
}
}
-146
View File
@@ -1,146 +0,0 @@
use crate::axon::{AxonMessage, AxonMethod, AxonResponse};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
type ToolFn = Arc<dyn Fn(Value) -> Value + Send + Sync>;
/// Routes Axon method names to handler functions.
///
/// Each handler takes the `params` field of an AxonMessage and returns
/// a JSON Value to be wrapped in an AxonResponse.
pub struct AxonHandler {
tools: HashMap<String, ToolFn>,
}
impl AxonHandler {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
/// Register a tool handler by name.
pub fn register<F>(&mut self, name: impl Into<String>, f: F)
where
F: Fn(Value) -> Value + Send + Sync + 'static,
{
self.tools.insert(name.into(), Arc::new(f));
}
/// Dispatch an AxonMessage to the appropriate handler.
pub fn dispatch(&self, msg: AxonMessage) -> AxonResponse {
match msg.method {
AxonMethod::ToolCall => {
let tool_name = msg
.params
.get("tool")
.and_then(|v| v.as_str())
.unwrap_or("");
let tool_params = msg
.params
.get("params")
.cloned()
.unwrap_or(Value::Object(Default::default()));
if let Some(f) = self.tools.get(tool_name) {
let result = f(tool_params);
AxonResponse::ok(msg.id, result)
} else {
AxonResponse::err(msg.id, format!("unknown tool: {}", tool_name))
}
}
AxonMethod::Ping => {
AxonResponse::ok(msg.id, serde_json::json!({ "pong": true }))
}
other => {
AxonResponse::err(msg.id, format!("unknown method: {}", other.as_str()))
}
}
}
/// List all registered tool names.
pub fn tool_names(&self) -> Vec<String> {
let mut names: Vec<_> = self.tools.keys().cloned().collect();
names.sort();
names
}
}
impl Default for AxonHandler {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dispatch_ping() {
let handler = AxonHandler::new();
let msg = AxonMessage {
id: "1".into(),
method: AxonMethod::Ping,
params: serde_json::json!({}),
};
let resp = handler.dispatch(msg);
assert!(resp.success);
assert_eq!(resp.result["pong"], true);
}
#[test]
fn dispatch_tool_call() {
let mut handler = AxonHandler::new();
handler.register("remember", |params| {
serde_json::json!({ "stored": params.get("content").cloned().unwrap_or_default() })
});
let msg = AxonMessage {
id: "2".into(),
method: AxonMethod::ToolCall,
params: serde_json::json!({ "tool": "remember", "params": { "content": "hello" } }),
};
let resp = handler.dispatch(msg);
assert!(resp.success);
assert_eq!(resp.result["stored"], "hello");
}
#[test]
fn dispatch_unknown_tool() {
let handler = AxonHandler::new();
let msg = AxonMessage {
id: "3".into(),
method: AxonMethod::ToolCall,
params: serde_json::json!({ "tool": "nonexistent", "params": {} }),
};
let resp = handler.dispatch(msg);
assert!(!resp.success);
}
#[test]
fn dispatch_unhandled_method() {
let handler = AxonHandler::new();
let msg = AxonMessage {
id: "4".into(),
method: AxonMethod::Subscribe,
params: serde_json::json!({}),
};
let resp = handler.dispatch(msg);
assert!(!resp.success);
assert!(resp.result["error"].as_str().unwrap().contains("unknown method"));
}
#[test]
fn tool_names_sorted() {
let mut handler = AxonHandler::new();
handler.register("remember", |_| Value::Null);
handler.register("recall", |_| Value::Null);
handler.register("plan_work", |_| Value::Null);
let names = handler.tool_names();
let mut expected = vec!["plan_work", "recall", "remember"];
expected.sort();
assert_eq!(names, expected);
}
}
+19 -7
View File
@@ -1,8 +1,20 @@
pub mod axon;
pub mod handler;
pub mod sse;
pub mod tools;
//! `neuron-protocol` — re-export shim for the canonical Axon platform crates.
//!
//! All types now live in:
//! - `axon-protocol` (`platform/protocols/axon/crates/axon-protocol`)
//! - `axon-events` (`platform/protocols/axon/crates/axon-events`)
//!
//! This crate re-exports everything so existing neuron-rs crates that import
//! `neuron_protocol::*` continue to compile without changes.
pub use axon::{AxonEvent, AxonMessage, AxonMethod, AxonResponse};
pub use handler::AxonHandler;
pub use tools::ProjectType;
// Re-export wire-protocol types
pub use axon_protocol::handler::AxonHandler;
pub use axon_protocol::message::{AxonEvent, AxonMessage, AxonMethod, AxonResponse};
pub use axon_protocol::sse::SseChannel;
pub use axon_protocol::tools::{self, ProjectType, ALL_TOOLS};
// Re-export event schema
pub use axon_events::{
AxonEnvelope, AxonError, AxonRouter, AxonSubscription, EventId, ProjectContext, Severity,
Timestamp,
};
-47
View File
@@ -1,47 +0,0 @@
use crate::axon::AxonEvent;
use tokio::sync::broadcast;
/// SSE channel — broadcasts AxonEvents to all connected SSE clients.
#[derive(Clone)]
pub struct SseChannel {
sender: broadcast::Sender<String>,
}
impl SseChannel {
pub fn new(capacity: usize) -> Self {
let (sender, _) = broadcast::channel(capacity);
Self { sender }
}
/// Send an event to all subscribers.
pub fn send(&self, event: &AxonEvent) -> Result<(), String> {
let json = serde_json::to_string(event).map_err(|e| e.to_string())?;
// Ignore errors when no receivers are connected
let _ = self.sender.send(json);
Ok(())
}
/// Subscribe to the SSE channel.
pub fn subscribe(&self) -> broadcast::Receiver<String> {
self.sender.subscribe()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn sse_channel_broadcast() {
let ch = SseChannel::new(16);
let mut rx = ch.subscribe();
let event = AxonEvent::new("test", serde_json::json!({ "msg": "hello" }));
ch.send(&event).unwrap();
let received = rx.recv().await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&received).unwrap();
assert_eq!(parsed["event_type"], "test");
assert_eq!(parsed["payload"]["msg"], "hello");
}
}
-219
View File
@@ -1,219 +0,0 @@
/// 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";
// ------------------------------------------------------------------
// 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] = &[
ACKNOWLEDGE_EVENT,
BEGIN_SESSION,
BEGIN_WORK,
BROWSE_KNOWLEDGE,
BROWSE_PROCESSES,
CAPTURE_KNOWLEDGE,
CATALOG_ROUTES,
CHECK_EVENTS,
CHECK_WORK,
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() {
// 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]
fn tool_names_are_non_empty() {
for name in ALL_TOOLS {
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;
}
}
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "neuron-runtime"
version = "0.1.0"
edition = "2021"
[dependencies]
# el crates (path deps)
el-compiler = { path = "../../../../foundation/el/engrams/el-compiler" }
# engram core (for EngramDb type)
engram-core = { path = "../../../../foundation/engram/engrams/engram-core" }
# neuron-rs workspace crates
neuron-domain = { path = "../neuron-domain" }
neuron-store = { path = "../neuron-store" }
axon-events = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
ureq = { version = "2", features = ["json"] }
crossterm = "0.28"
[dev-dependencies]
tempfile = "3"
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
//! neuron-runtime — Execution harness for el bytecode.
//!
//! Loads compiled `.el` modules, executes them on the el VM,
//! and bridges native function calls into neuron-store and Axon events.
pub mod bindings;
pub mod loader;
pub mod registry;
pub mod runtime;
pub mod vm;
pub use registry::ToolRegistry;
pub use runtime::NeuronRuntime;
+165
View File
@@ -0,0 +1,165 @@
//! .el source compiler and .elc bytecode loader.
use std::collections::HashMap;
use std::path::Path;
use el_compiler::{Bytecode, Compiler, CompilerOptions, deserialize_bytecode};
use thiserror::Error;
// ── RuntimeError (load/compile phase) ────────────────────────────────────────
#[derive(Debug, Error)]
pub enum LoadError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("compile error for {module}: {detail}")]
Compile { module: String, detail: String },
#[error("deserialize error: {0}")]
Deserialize(String),
}
// ── compile_source ────────────────────────────────────────────────────────────
/// Compile an el source string to bytecode instructions.
pub fn compile_source(source: &str) -> Result<Vec<Bytecode>, LoadError> {
let output = Compiler::compile(source, CompilerOptions::default())
.map_err(|e| LoadError::Compile {
module: "<source>".into(),
detail: e.to_string(),
})?;
deserialize_bytecode(&output.artifact).map_err(|e| LoadError::Compile {
module: "<source>".into(),
detail: e,
})
}
// ── load_elc ─────────────────────────────────────────────────────────────────
/// Load pre-compiled .elc file (bytecode JSON) from disk.
pub fn load_elc(path: &Path) -> Result<Vec<Bytecode>, LoadError> {
let bytes = std::fs::read(path)?;
deserialize_bytecode(&bytes).map_err(LoadError::Deserialize)
}
// ── load_directory ────────────────────────────────────────────────────────────
/// Load and compile all `.el` files from a directory (non-recursive).
///
/// Each file is compiled independently; the map key is the filename stem
/// (e.g. `memory.el` → `"memory"`). `.elc` files are also accepted and
/// loaded without recompilation.
///
/// Files that fail to compile are skipped with a warning rather than causing
/// the entire load to fail — callers can detect missing modules at dispatch
/// time.
pub fn load_directory(dir: &Path) -> Result<HashMap<String, Vec<Bytecode>>, LoadError> {
let mut modules: HashMap<String, Vec<Bytecode>> = HashMap::new();
let entries = std::fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if stem.is_empty() {
continue;
}
let result = match ext {
"el" => {
let source = std::fs::read_to_string(&path)?;
compile_source(&source).map_err(|e| {
tracing::warn!("skipping {}: {}", path.display(), e);
e
})
}
"elc" => load_elc(&path).map_err(|e| {
tracing::warn!("skipping {}: {}", path.display(), e);
e
}),
_ => continue,
};
match result {
Ok(bytecode) => {
modules.insert(stem, bytecode);
}
Err(_) => {
// Already warned above; continue loading other modules.
}
}
}
Ok(modules)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use el_compiler::{Bytecode, Value};
#[test]
fn test_compile_source_arithmetic() {
// A simple expression that the compiler can handle via eval semantics.
let instructions = compile_source("1 + 2").unwrap();
assert!(!instructions.is_empty(), "should produce instructions");
}
#[test]
fn test_compile_source_invalid() {
// Guaranteed to fail (unbalanced paren).
let result = compile_source("(((");
assert!(result.is_err());
}
#[test]
fn test_load_elc_roundtrip() {
use tempfile::NamedTempFile;
let instructions = vec![
Bytecode::Push(Value::Int(1)),
Bytecode::Push(Value::Int(2)),
Bytecode::Add,
Bytecode::Halt,
];
let bytes = el_compiler::serialize_bytecode(&instructions).unwrap();
let mut tmp = NamedTempFile::with_suffix(".elc").unwrap();
std::io::Write::write_all(&mut tmp, &bytes).unwrap();
let loaded = load_elc(tmp.path()).unwrap();
assert_eq!(loaded, instructions);
}
#[test]
fn test_load_directory_empty() {
let dir = tempfile::tempdir().unwrap();
let modules = load_directory(dir.path()).unwrap();
assert!(modules.is_empty());
}
#[test]
fn test_load_directory_with_elc() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let instructions = vec![Bytecode::Push(Value::Int(42)), Bytecode::Halt];
let bytes = el_compiler::serialize_bytecode(&instructions).unwrap();
std::fs::write(dir.path().join("mymod.elc"), &bytes).unwrap();
let modules = load_directory(dir.path()).unwrap();
assert!(modules.contains_key("mymod"));
assert_eq!(modules["mymod"], instructions);
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Tool → el function mapping.
//!
//! The registry maps Axon tool names (e.g. `"remember"`) to the module and
//! function name in the loaded `.el` bytecode (e.g. `("memory", "remember")`).
use std::collections::HashMap;
// ── ToolRegistry ──────────────────────────────────────────────────────────────
/// Maps Axon tool names to `(module_name, function_name)` pairs.
pub struct ToolRegistry {
entries: HashMap<String, (String, String)>,
}
impl ToolRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
/// Register the default Neuron tool → function mapping.
pub fn default_neuron() -> Self {
let mut r = Self::new();
// ── Memory ────────────────────────────────────────────────────────────
r.register("remember", "memory", "remember");
r.register("recall", "memory", "recall");
r.register("search_memories", "memory", "search_memories");
r.register("inspect_memories", "memory", "list_memories");
r.register("forget", "memory", "forget");
r.register("evolve_memory", "memory", "promote_memory");
// ── Knowledge ─────────────────────────────────────────────────────────
r.register("capture_knowledge", "knowledge", "capture_knowledge");
r.register("retrieve_knowledge", "knowledge", "retrieve_knowledge");
r.register("search_knowledge", "knowledge", "search_knowledge");
r.register("browse_knowledge", "knowledge", "browse_knowledge");
r.register("evolve_knowledge", "knowledge", "evolve_knowledge");
r.register("remove_knowledge", "knowledge", "remove_knowledge");
// ── Backlog ───────────────────────────────────────────────────────────
r.register("plan_work", "backlog", "plan_work");
r.register("review_backlog", "backlog", "review_backlog");
r.register("track_work", "backlog", "track_work");
// ── Context ───────────────────────────────────────────────────────────
r.register("begin_work", "context", "begin_work");
r.register("progress_work", "context", "progress_work");
r.register("check_work", "context", "check_work");
r.register("list_work", "context", "list_work");
// ── Artifact ──────────────────────────────────────────────────────────
r.register("draft_artifact", "artifact", "draft_artifact");
r.register("find_artifacts", "artifact", "find_artifacts");
r.register("retrieve_artifact", "artifact", "retrieve_artifact");
r.register("revise_artifact", "artifact", "revise_artifact");
r.register("manage_artifact", "artifact", "manage_artifact");
// ── ISE ───────────────────────────────────────────────────────────────
r.register("log_internal_state_event", "ise", "log_internal_state");
r.register("list_internal_state_events", "ise", "list_internal_state");
r.register("get_internal_state_event", "ise", "get_internal_state");
// ── Config ────────────────────────────────────────────────────────────
r.register("inspect_config", "config", "inspect_config");
r.register("tune_config", "config", "tune_config");
// ── Graph ─────────────────────────────────────────────────────────────
r.register("inspect_graph", "graph", "inspect_graph");
r.register("traverse_graph", "graph", "traverse_graph");
r.register("link_entities", "graph", "link_entities");
r.register("search_graph", "graph", "search_graph");
// ── Process ───────────────────────────────────────────────────────────
r.register("define_process", "process", "define_process");
r.register("browse_processes", "process", "browse_processes");
// ── Session ───────────────────────────────────────────────────────────
r.register("begin_session", "session", "begin_session");
r.register("compile_ctx", "session", "compile_ctx");
r.register("consolidate", "session", "consolidate");
// ── Inference / Chat ──────────────────────────────────────────────────
// Routes to native_chat binding — calls neuron.neurontechnologies.ai
// (OpenAI-compatible). For user installs: NEURON_INFERENCE_KEY=their key.
// For dev installs: override NEURON_INFERENCE_URL + NEURON_INFERENCE_KEY.
r.register("chat", "chat", "chat");
// ── Autonomous agent ──────────────────────────────────────────────────
r.register("agent_tick", "agent", "agent_tick");
r
}
/// Register a single tool → (module, function) mapping.
pub fn register(&mut self, tool: &str, module: &str, func: &str) {
self.entries
.insert(tool.to_string(), (module.to_string(), func.to_string()));
}
/// Resolve a tool name to `(module, function)`. Returns `None` if not
/// registered.
pub fn resolve(&self, tool_name: &str) -> Option<(&str, &str)> {
self.entries
.get(tool_name)
.map(|(m, f)| (m.as_str(), f.as_str()))
}
/// Number of registered tools.
pub fn len(&self) -> usize {
self.entries.len()
}
/// True if no tools are registered.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Iterate over all registered entries.
pub fn iter(&self) -> impl Iterator<Item = (&str, &str, &str)> {
self.entries
.iter()
.map(|(tool, (module, func))| (tool.as_str(), module.as_str(), func.as_str()))
}
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::new()
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_registry_resolve_memory() {
let r = ToolRegistry::default_neuron();
assert_eq!(r.resolve("remember"), Some(("memory", "remember")));
}
#[test]
fn test_registry_resolve_knowledge() {
let r = ToolRegistry::default_neuron();
assert_eq!(
r.resolve("capture_knowledge"),
Some(("knowledge", "capture_knowledge"))
);
assert_eq!(
r.resolve("search_knowledge"),
Some(("knowledge", "search_knowledge"))
);
}
#[test]
fn test_registry_resolve_backlog() {
let r = ToolRegistry::default_neuron();
assert_eq!(r.resolve("plan_work"), Some(("backlog", "plan_work")));
assert_eq!(
r.resolve("review_backlog"),
Some(("backlog", "review_backlog"))
);
assert_eq!(r.resolve("track_work"), Some(("backlog", "track_work")));
}
#[test]
fn test_registry_resolve_context() {
let r = ToolRegistry::default_neuron();
assert_eq!(r.resolve("begin_work"), Some(("context", "begin_work")));
assert_eq!(
r.resolve("progress_work"),
Some(("context", "progress_work"))
);
}
#[test]
fn test_registry_resolve_unknown() {
let r = ToolRegistry::default_neuron();
assert_eq!(r.resolve("totally_unknown_tool"), None);
}
#[test]
fn test_registry_custom_register() {
let mut r = ToolRegistry::new();
r.register("my_tool", "my_module", "my_func");
assert_eq!(r.resolve("my_tool"), Some(("my_module", "my_func")));
}
#[test]
fn test_registry_default_neuron_not_empty() {
let r = ToolRegistry::default_neuron();
assert!(!r.is_empty());
assert!(r.len() > 20);
}
#[test]
fn test_registry_ise_tools() {
let r = ToolRegistry::default_neuron();
assert_eq!(
r.resolve("log_internal_state_event"),
Some(("ise", "log_internal_state"))
);
}
#[test]
fn test_registry_artifact_tools() {
let r = ToolRegistry::default_neuron();
assert_eq!(
r.resolve("draft_artifact"),
Some(("artifact", "draft_artifact"))
);
assert_eq!(
r.resolve("retrieve_artifact"),
Some(("artifact", "retrieve_artifact"))
);
}
#[test]
fn test_registry_all_tools_unique_modules() {
let r = ToolRegistry::default_neuron();
let memory_tools: Vec<_> = r
.iter()
.filter(|(_, module, _)| *module == "memory")
.collect();
assert!(!memory_tools.is_empty());
}
}
+375
View File
@@ -0,0 +1,375 @@
//! Top-level NeuronRuntime — loads modules and dispatches Axon tool calls.
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use el_compiler::Bytecode;
use serde_json::Value;
use tracing::{debug, warn};
use crate::bindings::NativeBindings;
use crate::loader::{self, LoadError};
use crate::registry::ToolRegistry;
use crate::vm::{el_value_to_json, json_to_el_value, Vm};
use engram_core::EngramDb;
use neuron_store::{BacklogStore, ContextStore, IseStore, KnowledgeStore, MemoryStore, open_db};
// ── RuntimeError ──────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum RuntimeError {
#[error("load error: {0}")]
Load(#[from] LoadError),
#[error("vm error: {0}")]
Vm(#[from] crate::vm::VmError),
#[error("storage error: {0}")]
Storage(String),
}
// ── NeuronRuntime ─────────────────────────────────────────────────────────────
/// The main neuron runtime — holds loaded bytecode modules and dispatches
/// Axon tool calls through the el VM.
pub struct NeuronRuntime {
bindings: Arc<NativeBindings>,
registry: ToolRegistry,
/// module_name → compiled bytecode
modules: HashMap<String, Vec<Bytecode>>,
}
impl NeuronRuntime {
/// Create a runtime with pre-built components (for testing).
pub fn from_parts(
bindings: Arc<NativeBindings>,
registry: ToolRegistry,
modules: HashMap<String, Vec<Bytecode>>,
) -> Self {
Self {
bindings,
registry,
modules,
}
}
/// Create a runtime from an already-open `EngramDb` handle and a module directory.
///
/// The caller opens sled once and passes a clone here; no second `sled::open`
/// call is made, so the single-writer lock is never contested.
pub fn from_dir(
module_dir: &Path,
db: EngramDb,
) -> Result<Self, RuntimeError> {
let memory = Arc::new(MemoryStore::new(db.clone()));
let knowledge = Arc::new(KnowledgeStore::new(db.clone()));
let backlog = Arc::new(BacklogStore::new(db.clone()));
let context = Arc::new(ContextStore::new(db.clone()));
let ise = Arc::new(IseStore::new(db));
let bindings = Arc::new(NativeBindings::new(memory, knowledge, backlog, context, ise));
let registry = ToolRegistry::default_neuron();
let modules = loader::load_directory(module_dir)?;
Ok(Self {
bindings,
registry,
modules,
})
}
/// Convenience: open a new database at `db_path` then call `from_dir`.
/// Only use this when you are the sole opener (e.g. in tests).
pub fn from_path(module_dir: &Path, db_path: &Path) -> Result<Self, RuntimeError> {
let db = open_db(db_path).map_err(|e| RuntimeError::Storage(e.to_string()))?;
Self::from_dir(module_dir, db)
}
/// Dispatch an Axon tool call. Returns a JSON result value.
///
/// Resolution order:
/// 1. Look up `tool_name` in the registry → (module, func).
/// 2. Find the module's bytecode.
/// 3. Find the function entry-point label (`__fn_<func>`) in the bytecode.
/// 4. Execute the VM from that entry-point with `params` on the stack.
pub async fn dispatch(&self, tool_name: &str, params: Value) -> Value {
debug!("NeuronRuntime::dispatch tool={}", tool_name);
let (module_name, func_name) = match self.registry.resolve(tool_name) {
None => {
warn!("dispatch: unknown tool '{}'", tool_name);
return serde_json::json!({"error": format!("unknown tool: {}", tool_name)});
}
Some(pair) => pair,
};
let bytecode = match self.modules.get(module_name) {
None => {
// Module not loaded — fall back to native dispatch
debug!(
"dispatch: module '{}' not loaded, attempting native fallback for '{}'",
module_name, tool_name
);
return self.native_fallback(tool_name, params);
}
Some(bc) => bc,
};
// Execute the module, looking for a function entry-point.
let mut vm = Vm::new(self.bindings.clone());
// Pre-scan bytecode for function entry-points labelled by convention
// as a StoreLocal of the sentinel name "__fn_<func>". In the
// generated bytecode a function is expected to start after a
// `Push(Str("__fn_<name>"))` / `StoreLocal("__fn_<name>")` pair.
// For robustness we scan for the target function start here.
let entry_ip = find_function_entry(bytecode, func_name);
// Push params as the first local.
let params_el = json_to_el_value(params.clone());
let result = if let Some(ip) = entry_ip {
// Register all functions we found.
register_all_functions(&mut vm, bytecode);
vm.execute_from(ip, params_el, bytecode)
} else {
// No labelled entry-point: run the full module (eval semantics).
// This is the common case while .el files are being developed.
vm.locals_mut().insert("params".to_string(), params_el);
vm.run(bytecode)
};
match result {
Ok(val) => el_value_to_json(&val),
Err(e) => {
warn!("dispatch vm error for '{}': {}", tool_name, e);
serde_json::json!({"error": e.to_string()})
}
}
}
/// Native fallback: invoke a registered native binding directly using a
/// naming convention (`tool_name` → `native_<tool_name>`).
fn native_fallback(&self, tool_name: &str, params: Value) -> Value {
let native_name = format!("native_{}", tool_name);
if self.bindings.has(&native_name) {
self.bindings.call(&native_name, params)
} else {
serde_json::json!({"error": format!("module not loaded and no native fallback for: {}", tool_name)})
}
}
/// Scan all loaded modules for an `on_startup` function and run each
/// in a dedicated background thread.
///
/// Convention: any `.el` module that exports `fn on_startup(...)` will
/// be called once at daemon boot in a new thread. The function runs for
/// the lifetime of the daemon — it can contain a `while true` loop with
/// `native_sleep` for periodic autonomous work.
///
/// Each thread gets its own VM instance but shares the NativeBindings Arc,
/// so storage operations are safe across threads.
pub fn run_startup_hooks(&self) {
for (module_name, bytecode) in &self.modules {
if find_function_entry(bytecode, "on_startup").is_some() {
let bindings = self.bindings.clone();
let bytecode = bytecode.clone();
let module_name = module_name.clone();
std::thread::spawn(move || {
tracing::info!("on_startup: launching background thread for module '{}'", module_name);
let mut vm = Vm::new(bindings);
register_all_functions(&mut vm, &bytecode);
let entry = match find_function_entry(&bytecode, "on_startup") {
Some(ip) => ip,
None => return,
};
let params = json_to_el_value(serde_json::json!({}));
match vm.execute_from(entry, params, &bytecode) {
Ok(_) => tracing::info!("on_startup: module '{}' exited normally", module_name),
Err(e) => tracing::error!("on_startup: module '{}' error: {}", module_name, e),
}
});
}
}
}
/// Number of loaded modules.
pub fn module_count(&self) -> usize {
self.modules.len()
}
/// Names of all loaded modules.
pub fn module_names(&self) -> Vec<&str> {
self.modules.keys().map(|s| s.as_str()).collect()
}
}
// ── Vm extension for call-from ────────────────────────────────────────────────
impl Vm {
/// Execute from a specific instruction pointer with a params value
/// pre-loaded into the "params" local.
pub fn execute_from(
&mut self,
start_ip: usize,
params: el_compiler::Value,
instructions: &[Bytecode],
) -> Result<el_compiler::Value, crate::vm::VmError> {
self.locals_mut().insert("params".to_string(), params);
self.set_ip(start_ip);
self.execute(instructions)
}
/// Expose mutable locals for the runtime layer.
pub fn locals_mut(&mut self) -> &mut std::collections::HashMap<String, el_compiler::Value> {
// We expose the top-level locals (no call frame active here).
self.top_locals_mut()
}
/// Set the instruction pointer directly.
pub fn set_ip(&mut self, ip: usize) {
self.set_ip_internal(ip);
}
}
// ── Bytecode scanning helpers ─────────────────────────────────────────────────
/// Find the instruction offset for a named function entry-point.
///
/// Convention: a function `foo` is prefixed by:
/// `Push(Str("__fn_foo"))` at some ip N
/// and the function body starts at ip N+1.
fn find_function_entry(bytecode: &[Bytecode], func_name: &str) -> Option<usize> {
let sentinel = format!("__fn_{}", func_name);
for (i, instr) in bytecode.iter().enumerate() {
if let Bytecode::Push(el_compiler::Value::Str(s)) = instr {
if s == &sentinel {
// Function body starts at the next instruction.
if i + 1 < bytecode.len() {
return Some(i + 1);
}
}
}
}
None
}
/// Scan bytecode for all function entry-points and register them in the VM.
fn register_all_functions(vm: &mut Vm, bytecode: &[Bytecode]) {
for (i, instr) in bytecode.iter().enumerate() {
if let Bytecode::Push(el_compiler::Value::Str(s)) = instr {
if let Some(func_name) = s.strip_prefix("__fn_") {
if i + 1 < bytecode.len() {
// arity 1 by convention (params map)
vm.register_function(func_name.to_string(), i + 1, 1);
}
}
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::bindings::NativeBindings;
use el_compiler::{Bytecode, Value};
fn make_stub_runtime(modules: HashMap<String, Vec<Bytecode>>) -> NeuronRuntime {
NeuronRuntime::from_parts(
Arc::new(NativeBindings::stub()),
ToolRegistry::default_neuron(),
modules,
)
}
#[tokio::test]
async fn test_dispatch_unknown_tool() {
let rt = make_stub_runtime(HashMap::new());
let result = rt.dispatch("does_not_exist", serde_json::json!({})).await;
assert!(result.get("error").is_some());
}
#[tokio::test]
async fn test_dispatch_known_tool_module_not_loaded() {
// "remember" is registered but module "memory" is not loaded.
// Falls back to native_remember, which the stub doesn't have → error.
let rt = make_stub_runtime(HashMap::new());
let result = rt.dispatch("remember", serde_json::json!({})).await;
// Should get some kind of error (module not loaded, no native fallback).
assert!(result.get("error").is_some());
}
#[tokio::test]
async fn test_dispatch_module_with_bytecode() {
// Module "memory" that just pushes 42 and halts.
let mut modules = HashMap::new();
modules.insert(
"memory".to_string(),
vec![Bytecode::Push(Value::Int(42)), Bytecode::Halt],
);
let rt = make_stub_runtime(modules);
let result = rt.dispatch("remember", serde_json::json!({"content": "hello"})).await;
// Eval semantics: result = 42
assert_eq!(result, serde_json::json!(42));
}
#[tokio::test]
async fn test_dispatch_native_uuid() {
// Register a module "util" that calls native_uuid.
let mut modules = HashMap::new();
modules.insert(
"util".to_string(),
vec![
Bytecode::Call { name: "native_uuid".into(), arity: 0 },
Bytecode::Halt,
],
);
// Add a tool mapping.
let mut registry = ToolRegistry::default_neuron();
registry.register("get_uuid", "util", "get_uuid");
let rt = NeuronRuntime::from_parts(
Arc::new(NativeBindings::stub()),
registry,
modules,
);
let result = rt.dispatch("get_uuid", serde_json::json!({})).await;
let uuid_str = result.as_str().expect("expected a string UUID");
assert!(uuid::Uuid::parse_str(uuid_str).is_ok());
}
#[test]
fn test_find_function_entry_found() {
let bc = vec![
Bytecode::Push(Value::Str("__fn_remember".into())),
Bytecode::Push(Value::Int(99)),
Bytecode::Halt,
];
assert_eq!(find_function_entry(&bc, "remember"), Some(1));
}
#[test]
fn test_find_function_entry_not_found() {
let bc = vec![Bytecode::Push(Value::Int(1)), Bytecode::Halt];
assert_eq!(find_function_entry(&bc, "remember"), None);
}
#[test]
fn test_module_count() {
let mut modules = HashMap::new();
modules.insert("memory".into(), vec![]);
modules.insert("knowledge".into(), vec![]);
let rt = make_stub_runtime(modules);
assert_eq!(rt.module_count(), 2);
}
#[test]
fn test_registry_resolve() {
let r = ToolRegistry::default_neuron();
assert_eq!(r.resolve("remember"), Some(("memory", "remember")));
assert_eq!(r.resolve("plan_work"), Some(("backlog", "plan_work")));
assert_eq!(r.resolve("capture_knowledge"), Some(("knowledge", "capture_knowledge")));
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ edition = "2021"
[dependencies]
neuron-domain = { path = "../neuron-domain" }
engram-core = { path = "../../../engram/crates/engram-core" }
engram-core = { path = "../../../../foundation/engram/engrams/engram-core" }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }