feat: neuron-rs — Rust runtime, Engram-backed, Axon protocol

This commit is contained in:
2026-04-27 18:37:01 -05:00
commit 745278c902
33 changed files with 2375 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "neuron-api"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "neuron-api"
path = "src/main.rs"
[dependencies]
neuron-domain = { path = "../neuron-domain" }
neuron-store = { path = "../neuron-store" }
neuron-protocol = { path = "../neuron-protocol" }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
axum = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
thiserror = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
futures-util = "0.3"
tokio-stream = "0.1"
async-stream = "0.3"
[dev-dependencies]
+33
View File
@@ -0,0 +1,33 @@
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use std::sync::Arc;
use crate::state::AppState;
/// Axum middleware that validates `Authorization: Bearer <key>` header.
pub async fn bearer_auth(
State(state): State<Arc<AppState>>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let auth_header = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok());
match auth_header {
Some(header) if header.starts_with("Bearer ") => {
let token = &header["Bearer ".len()..];
if token == state.api_key {
Ok(next.run(req).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
_ => Err(StatusCode::UNAUTHORIZED),
}
}
+34
View File
@@ -0,0 +1,34 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use neuron_domain::NeuronError;
use serde_json::json;
pub struct ApiError(NeuronError);
impl From<NeuronError> for ApiError {
fn from(e: NeuronError) -> Self {
Self(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self.0 {
NeuronError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
NeuronError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
NeuronError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
NeuronError::Storage(msg) => {
(StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
}
NeuronError::Serialization(msg) => {
(StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
pub type ApiResult<T> = Result<T, ApiError>;
+44
View File
@@ -0,0 +1,44 @@
use std::path::PathBuf;
use std::sync::Arc;
use tracing::info;
use tracing_subscriber::EnvFilter;
mod auth;
mod error;
mod routes;
mod state;
use state::AppState;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::from_default_env()
.add_directive("neuron_api=info".parse().unwrap()),
)
.init();
let db_path_raw = std::env::var("NEURON_DB_PATH")
.unwrap_or_else(|_| "~/.neuron/engram".to_string());
let db_path = PathBuf::from(
db_path_raw
.replace('~', &std::env::var("HOME").unwrap_or_else(|_| ".".to_string())),
);
std::fs::create_dir_all(&db_path).expect("failed to create db directory");
let api_key = std::env::var("NEURON_API_KEY").unwrap_or_else(|_| "neuron-dev".to_string());
let bind = std::env::var("NEURON_BIND").unwrap_or_else(|_| "0.0.0.0:7770".to_string());
info!("opening engram db at {}", db_path.display());
let state = AppState::new(&db_path, api_key).expect("failed to initialize app state");
let state = Arc::new(state);
let app = routes::build_router(state);
let listener = tokio::net::TcpListener::bind(&bind)
.await
.expect("failed to bind");
info!("neuron-api listening on {}", bind);
axum::serve(listener, app).await.expect("server error");
}
+50
View File
@@ -0,0 +1,50 @@
use axum::{
extract::State,
response::sse::{Event, KeepAlive, Sse},
Json,
};
use futures_util::Stream;
use neuron_protocol::{AxonHandler, AxonMessage, AxonResponse};
use std::sync::Arc;
use std::time::Duration;
use crate::state::AppState;
/// POST /axon/message — receive an Axon protocol message and return a response.
pub async fn post_message(
State(_state): State<Arc<AppState>>,
Json(msg): Json<AxonMessage>,
) -> Json<AxonResponse> {
let mut handler = AxonHandler::new();
// Register stub handlers for all known tool names.
for &tool in neuron_protocol::tools::ALL_TOOLS {
let name = tool.to_string();
handler.register(name, |params| {
serde_json::json!({
"status": "ok",
"params": params
})
});
}
Json(handler.dispatch(msg))
}
/// GET /axon/sse — Server-Sent Events stream for Axon events.
pub async fn sse_stream(
State(_state): State<Arc<AppState>>,
) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> {
// Emit a heartbeat event every 30 seconds.
// In production, this would subscribe to the SseChannel broadcast.
let stream = async_stream::stream! {
let mut interval = tokio::time::interval(Duration::from_secs(30));
loop {
interval.tick().await;
let data = serde_json::json!({ "event_type": "heartbeat" }).to_string();
yield Ok(Event::default().event("heartbeat").data(data));
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}
+121
View File
@@ -0,0 +1,121 @@
use axum::{
extract::{Path, Query, State},
Json,
};
use neuron_domain::{BacklogItem, BacklogStatus, ItemType, Priority, now_ms};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApiResult;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct CreateBacklogRequest {
pub title: String,
#[serde(default)]
pub description: String,
pub item_type: Option<String>,
pub priority: Option<String>,
pub project: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub depends_on: Vec<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub status: Option<String>,
pub priority: Option<String>,
pub project: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateBacklogRequest {
pub status: Option<String>,
pub priority: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
}
pub async fn create(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateBacklogRequest>,
) -> ApiResult<Json<BacklogItem>> {
let item_type = req
.item_type
.as_deref()
.map(ItemType::from_str_lossy)
.unwrap_or(ItemType::Feature);
let priority = req
.priority
.as_deref()
.map(Priority::from_str_lossy)
.unwrap_or(Priority::P1);
let item = BacklogItem::new(
req.title,
req.description,
item_type,
priority,
req.project,
req.tags,
req.depends_on,
);
state.backlog.put(&item)?;
Ok(Json(item))
}
pub async fn list(
State(state): State<Arc<AppState>>,
Query(q): Query<ListQuery>,
) -> ApiResult<Json<Vec<BacklogItem>>> {
let mut items = state.backlog.list_filtered(
q.status.as_deref(),
q.priority.as_deref(),
q.project.as_deref(),
)?;
if let Some(limit) = q.limit {
items.truncate(limit);
}
Ok(Json(items))
}
pub async fn get_one(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<BacklogItem>> {
let item = state.backlog.get(id)?;
Ok(Json(item))
}
pub async fn update_one(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateBacklogRequest>,
) -> ApiResult<Json<BacklogItem>> {
let mut item = state.backlog.get(id)?;
if let Some(status) = req.status {
item.status = BacklogStatus::from_str_lossy(&status);
}
if let Some(priority) = req.priority {
item.priority = Priority::from_str_lossy(&priority);
}
if let Some(title) = req.title {
item.title = title;
}
if let Some(description) = req.description {
item.description = description;
}
if let Some(tags) = req.tags {
item.tags = tags;
}
item.updated_at = now_ms();
state.backlog.update(&item)?;
Ok(Json(item))
}
+96
View File
@@ -0,0 +1,96 @@
use axum::{
extract::{Path, State},
Json,
};
use neuron_domain::{ContextStatus, ContextStep, ExecutionContext, now_ms};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApiResult;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct CreateContextRequest {
pub process_name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub objective: String,
pub project: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateContextRequest {
pub action: Option<String>,
pub step_name: Option<String>,
pub step_status: Option<String>,
pub notes: Option<String>,
pub file_refs: Option<Vec<String>>,
pub key_decisions: Option<Vec<String>>,
pub lessons_learned: Option<Vec<String>>,
pub summary: Option<String>,
}
pub async fn create(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateContextRequest>,
) -> ApiResult<Json<ExecutionContext>> {
let objective = if req.objective.is_empty() {
req.description.clone()
} else {
req.objective.clone()
};
let ctx = ExecutionContext::new(req.process_name, req.description, objective, req.project);
state.context.put(&ctx)?;
Ok(Json(ctx))
}
pub async fn get_one(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ExecutionContext>> {
let ctx = state.context.get(id)?;
Ok(Json(ctx))
}
pub async fn update_one(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateContextRequest>,
) -> ApiResult<Json<ExecutionContext>> {
let mut ctx = state.context.get(id)?;
match req.action.as_deref() {
Some("complete") => {
ctx.status = ContextStatus::Completed;
if let Some(summary) = req.summary {
ctx.lessons_learned.push(summary);
}
}
Some("archive") => {
ctx.status = ContextStatus::Archived;
}
_ => {
// Generic update
if let Some(step_name) = req.step_name {
let status = req.step_status.unwrap_or_else(|| "in_progress".to_string());
let step = ContextStep::new(step_name, status, req.notes);
ctx.steps.push(step);
}
if let Some(file_refs) = req.file_refs {
ctx.file_refs.extend(file_refs);
}
if let Some(decisions) = req.key_decisions {
ctx.key_decisions.extend(decisions);
}
if let Some(lessons) = req.lessons_learned {
ctx.lessons_learned.extend(lessons);
}
}
}
ctx.updated_at = now_ms();
state.context.update(&ctx)?;
Ok(Json(ctx))
}
+6
View File
@@ -0,0 +1,6 @@
use axum::Json;
use serde_json::{json, Value};
pub async fn health() -> Json<Value> {
Json(json!({ "status": "ok", "version": "1.0.0" }))
}
+58
View File
@@ -0,0 +1,58 @@
use axum::{
extract::{Path, State},
Json,
};
use neuron_domain::{GapDirection, InternalStateEvent};
use serde::Deserialize;
use std::sync::Arc;
use crate::error::ApiResult;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct CreateIseRequest {
pub trigger: String,
pub pre_reasoning_response: String,
pub post_reasoning_response: String,
pub compression_ratio: f32,
pub gap_direction: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
pub async fn create(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateIseRequest>,
) -> ApiResult<Json<InternalStateEvent>> {
let gap_direction = req
.gap_direction
.as_deref()
.map(GapDirection::from_str_lossy)
.unwrap_or(GapDirection::Neutral);
let ise = InternalStateEvent::new(
req.trigger,
req.pre_reasoning_response,
req.post_reasoning_response,
req.compression_ratio,
gap_direction,
req.tags,
);
state.ise.put(&ise)?;
Ok(Json(ise))
}
pub async fn list(
State(state): State<Arc<AppState>>,
) -> ApiResult<Json<Vec<InternalStateEvent>>> {
let events = state.ise.list()?;
Ok(Json(events))
}
pub async fn get_one(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> ApiResult<Json<InternalStateEvent>> {
let event = state.ise.get(&id)?;
Ok(Json(event))
}
+82
View File
@@ -0,0 +1,82 @@
use axum::{
extract::{Path, Query, State},
Json,
};
use neuron_domain::{KnowledgeEntry, KnowledgeTier};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApiResult;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct CreateKnowledgeRequest {
pub title: String,
pub content: String,
pub category: String,
#[serde(default)]
pub tier: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub project: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub category: Option<String>,
#[allow(dead_code)]
pub tier: Option<String>,
pub project: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SearchRequest {
pub query: String,
pub limit: Option<usize>,
}
pub async fn create(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateKnowledgeRequest>,
) -> ApiResult<Json<KnowledgeEntry>> {
let tier = req
.tier
.as_deref()
.map(KnowledgeTier::from_str_lossy)
.unwrap_or(KnowledgeTier::Note);
let entry = KnowledgeEntry::new(req.title, req.content, req.category, tier, req.tags, req.project);
state.knowledge.put(&entry)?;
Ok(Json(entry))
}
pub async fn list(
State(state): State<Arc<AppState>>,
Query(q): Query<ListQuery>,
) -> ApiResult<Json<Vec<KnowledgeEntry>>> {
let mut entries = state.knowledge.list()?;
if let Some(cat) = &q.category {
entries.retain(|e| &e.category == cat);
}
if let Some(project) = &q.project {
entries.retain(|e| e.project.as_deref() == Some(project.as_str()));
}
Ok(Json(entries))
}
pub async fn get_one(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<KnowledgeEntry>> {
let entry = state.knowledge.get(id)?;
Ok(Json(entry))
}
pub async fn search(
State(state): State<Arc<AppState>>,
Json(req): Json<SearchRequest>,
) -> ApiResult<Json<Vec<KnowledgeEntry>>> {
let limit = req.limit.unwrap_or(5);
let entries = state.knowledge.search(&req.query, limit)?;
Ok(Json(entries))
}
+90
View File
@@ -0,0 +1,90 @@
use axum::{
extract::{Path, Query, State},
Json,
};
use neuron_domain::{Importance, Memory};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::error::ApiResult;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct CreateMemoryRequest {
pub content: String,
#[serde(default)]
pub tags: Vec<String>,
pub project: Option<String>,
#[serde(default)]
pub importance: Option<String>,
pub supersedes_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub project: Option<String>,
pub tag: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct SearchRequest {
pub query: String,
pub limit: Option<usize>,
}
pub async fn create(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateMemoryRequest>,
) -> ApiResult<Json<Memory>> {
let importance = req
.importance
.as_deref()
.map(Importance::from_str_lossy)
.unwrap_or(Importance::Normal);
let memory = Memory::new(
req.content,
req.tags,
req.project,
importance,
req.supersedes_id,
);
state.memory.put(&memory)?;
Ok(Json(memory))
}
pub async fn list(
State(state): State<Arc<AppState>>,
Query(q): Query<ListQuery>,
) -> ApiResult<Json<Vec<Memory>>> {
let mut memories = state.memory.list()?;
if let Some(proj) = &q.project {
memories.retain(|m| m.project.as_deref() == Some(proj.as_str()));
}
if let Some(tag) = &q.tag {
memories.retain(|m| m.tags.iter().any(|t| t == tag));
}
if let Some(limit) = q.limit {
memories.truncate(limit);
}
Ok(Json(memories))
}
pub async fn get_one(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Memory>> {
let memory = state.memory.get(id)?;
Ok(Json(memory))
}
pub async fn search(
State(state): State<Arc<AppState>>,
Json(req): Json<SearchRequest>,
) -> ApiResult<Json<Vec<Memory>>> {
let limit = req.limit.unwrap_or(10);
let memories = state.memory.search(&req.query, limit)?;
Ok(Json(memories))
}
+47
View File
@@ -0,0 +1,47 @@
use axum::{
middleware,
routing::{get, post},
Router,
};
use std::sync::Arc;
use crate::auth::bearer_auth;
use crate::state::AppState;
mod health;
mod memories;
mod knowledge;
mod backlog;
mod contexts;
mod ise;
mod axon;
pub fn build_router(state: Arc<AppState>) -> Router {
let protected = Router::new()
// Memories
.route("/api/memories", post(memories::create).get(memories::list))
.route("/api/memories/search", post(memories::search))
.route("/api/memories/{id}", get(memories::get_one))
// Knowledge
.route("/api/knowledge", post(knowledge::create).get(knowledge::list))
.route("/api/knowledge/search", post(knowledge::search))
.route("/api/knowledge/{id}", get(knowledge::get_one))
// Backlog
.route("/api/backlog", post(backlog::create).get(backlog::list))
.route("/api/backlog/{id}", get(backlog::get_one).patch(backlog::update_one))
// Contexts
.route("/api/contexts", post(contexts::create))
.route("/api/contexts/{id}", get(contexts::get_one).patch(contexts::update_one))
// Internal state events
.route("/api/ise", post(ise::create).get(ise::list))
.route("/api/ise/{id}", get(ise::get_one))
// Axon protocol
.route("/axon/message", post(axon::post_message))
.route("/axon/sse", get(axon::sse_stream))
.layer(middleware::from_fn_with_state(state.clone(), bearer_auth))
.with_state(state);
Router::new()
.route("/health", get(health::health))
.merge(protected)
}
+28
View File
@@ -0,0 +1,28 @@
use std::path::Path;
use neuron_store::{BacklogStore, ContextStore, IseStore, KnowledgeStore, MemoryStore, open_db};
/// Shared application state — one store per domain.
///
/// Sled supports multiple handles to the same database path from the same
/// process; all open handles share the same underlying file.
pub struct AppState {
pub memory: MemoryStore,
pub knowledge: KnowledgeStore,
pub backlog: BacklogStore,
pub context: ContextStore,
pub ise: IseStore,
pub api_key: String,
}
impl AppState {
pub fn new(db_path: &Path, api_key: String) -> Result<Self, neuron_domain::NeuronError> {
Ok(Self {
memory: MemoryStore::new(open_db(db_path)?),
knowledge: KnowledgeStore::new(open_db(db_path)?),
backlog: BacklogStore::new(open_db(db_path)?),
context: ContextStore::new(open_db(db_path)?),
ise: IseStore::new(open_db(db_path)?),
api_key,
})
}
}