Add Rust typed Odin core
This commit is contained in:
parent
d40d92db03
commit
24b2e5c2bc
12 changed files with 2030 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,5 +2,6 @@ node_modules/
|
|||
scratch/
|
||||
bin/
|
||||
obj/
|
||||
target/
|
||||
*.log
|
||||
*.pid
|
||||
|
|
|
|||
1243
Cargo.lock
generated
Normal file
1243
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
11
Cargo.toml
Normal file
11
Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"crates/odin-core",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
11
README.md
11
README.md
|
|
@ -9,6 +9,17 @@ durable service state in CultCache `.cc`, local Verse visibility through
|
|||
CultMesh, meaningful dashboards as Eve GUI/TUI DSL, and renderers as lowerers
|
||||
only.
|
||||
|
||||
## Rust Spine
|
||||
|
||||
The target Odin body is Rust-first: ingest through narrow ports, normalize into
|
||||
typed Odin records, persist through CultCache `.cc`, expose through CultMesh /
|
||||
CultNet document registries, and lower interface state through Eve/CultUI.
|
||||
|
||||
The first Rust core lives in `crates/odin-core` and already separates typed
|
||||
documents, ingest ports, normalization, and repository persistence so unit tests
|
||||
can use mocked inputs and pipeline smokes can prove typed handoff without
|
||||
booting the whole daemon.
|
||||
|
||||
## Gjallar
|
||||
|
||||
Gjallar is Odin's herald organ: the daemon/persona package that should carry
|
||||
|
|
|
|||
21
crates/odin-core/Cargo.toml
Normal file
21
crates/odin-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "odin-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "odin_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
cultcache-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultcache-rs" }
|
||||
cultmesh-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultmesh-rs" }
|
||||
cultnet-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultnet-rs" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4"
|
||||
tempfile = "3"
|
||||
144
crates/odin-core/src/documents.rs
Normal file
144
crates/odin-core/src/documents.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
use cultcache_rs::DatabaseEntry;
|
||||
|
||||
pub const ODIN_SNAPSHOT_SCHEMA: &str = "odin.snapshot.v1";
|
||||
pub const ODIN_VERSE_SCHEMA: &str = "odin.verse.v1";
|
||||
pub const ODIN_SERVICE_SCHEMA: &str = "odin.service.v1";
|
||||
pub const ODIN_INTERFACE_SCHEMA: &str = "odin.interface.v1";
|
||||
pub const ODIN_OBSERVATION_STREAM_SCHEMA: &str = "odin.observation_stream.v1";
|
||||
pub const ODIN_TRANSLATION_ROUTE_SCHEMA: &str = "odin.translation_route.v1";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "odin.snapshot", schema = "odin.snapshot.v1")]
|
||||
pub struct OdinSnapshotRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub snapshot_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub observed_at: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub verse_count: u32,
|
||||
#[cultcache(key = 3)]
|
||||
pub service_count: u32,
|
||||
#[cultcache(key = 4)]
|
||||
pub interface_count: u32,
|
||||
#[cultcache(key = 5)]
|
||||
pub observation_stream_count: u32,
|
||||
#[cultcache(key = 6)]
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "odin.verse", schema = "odin.verse.v1")]
|
||||
pub struct OdinVerseRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub verse_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub name: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub role: String,
|
||||
#[cultcache(key = 3)]
|
||||
pub status: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub capabilities: Vec<String>,
|
||||
#[cultcache(key = 5)]
|
||||
pub observed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "odin.service", schema = "odin.service.v1")]
|
||||
pub struct OdinServiceRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub service_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub verse_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub name: String,
|
||||
#[cultcache(key = 3)]
|
||||
pub state: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub detail: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub authority: String,
|
||||
#[cultcache(key = 6)]
|
||||
pub observed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "odin.interface", schema = "odin.interface.v1")]
|
||||
pub struct OdinInterfaceRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub provider_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub title: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub state: String,
|
||||
#[cultcache(key = 3)]
|
||||
pub source: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub version: Option<String>,
|
||||
#[cultcache(key = 5)]
|
||||
pub updated_at: Option<String>,
|
||||
#[cultcache(key = 6)]
|
||||
pub observed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "odin.observation_stream",
|
||||
schema = "odin.observation_stream.v1"
|
||||
)]
|
||||
pub struct OdinObservationStreamRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub stream_key: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub device_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub stream_id: String,
|
||||
#[cultcache(key = 3)]
|
||||
pub kind: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub state: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub detail: String,
|
||||
#[cultcache(key = 6)]
|
||||
pub owner: String,
|
||||
#[cultcache(key = 7)]
|
||||
pub observed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "odin.translation_route", schema = "odin.translation_route.v1")]
|
||||
pub struct OdinTranslationRouteRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub route_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub source_schema: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub target_schema: String,
|
||||
#[cultcache(key = 3)]
|
||||
pub translation_kind: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub owner: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub version: String,
|
||||
#[cultcache(key = 6)]
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
cultmesh_rs::cultmesh_documents!(OdinDocuments {
|
||||
OdinSnapshotRecord => ODIN_SNAPSHOT_SCHEMA,
|
||||
OdinVerseRecord => ODIN_VERSE_SCHEMA,
|
||||
OdinServiceRecord => ODIN_SERVICE_SCHEMA,
|
||||
OdinInterfaceRecord => ODIN_INTERFACE_SCHEMA,
|
||||
OdinObservationStreamRecord => ODIN_OBSERVATION_STREAM_SCHEMA,
|
||||
OdinTranslationRouteRecord => ODIN_TRANSLATION_ROUTE_SCHEMA,
|
||||
});
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OdinRecords {
|
||||
pub snapshot: Option<OdinSnapshotRecord>,
|
||||
pub verses: Vec<OdinVerseRecord>,
|
||||
pub services: Vec<OdinServiceRecord>,
|
||||
pub interfaces: Vec<OdinInterfaceRecord>,
|
||||
pub observation_streams: Vec<OdinObservationStreamRecord>,
|
||||
pub translation_routes: Vec<OdinTranslationRouteRecord>,
|
||||
}
|
||||
9
crates/odin-core/src/lib.rs
Normal file
9
crates/odin-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub mod documents;
|
||||
pub mod pipeline;
|
||||
pub mod ports;
|
||||
pub mod repository;
|
||||
|
||||
pub use documents::*;
|
||||
pub use pipeline::*;
|
||||
pub use ports::*;
|
||||
pub use repository::*;
|
||||
260
crates/odin-core/src/pipeline.rs
Normal file
260
crates/odin-core/src/pipeline.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
use crate::documents::{
|
||||
OdinInterfaceRecord, OdinObservationStreamRecord, OdinRecords, OdinServiceRecord,
|
||||
OdinSnapshotRecord, OdinTranslationRouteRecord, OdinVerseRecord,
|
||||
};
|
||||
use crate::ports::{
|
||||
Clock, InterfaceIngest, ObservationStreamIngest, OdinInputBatch, ServiceIngest,
|
||||
TranslationRouteIngest, VerseIngest,
|
||||
};
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct OdinIngestPipeline<V, S, I, O, T, C> {
|
||||
verses: V,
|
||||
services: S,
|
||||
interfaces: I,
|
||||
streams: O,
|
||||
translations: T,
|
||||
clock: C,
|
||||
}
|
||||
|
||||
impl<V, S, I, O, T, C> OdinIngestPipeline<V, S, I, O, T, C>
|
||||
where
|
||||
V: VerseIngest,
|
||||
S: ServiceIngest,
|
||||
I: InterfaceIngest,
|
||||
O: ObservationStreamIngest,
|
||||
T: TranslationRouteIngest,
|
||||
C: Clock,
|
||||
{
|
||||
pub fn new(
|
||||
verses: V,
|
||||
services: S,
|
||||
interfaces: I,
|
||||
streams: O,
|
||||
translations: T,
|
||||
clock: C,
|
||||
) -> Self {
|
||||
Self {
|
||||
verses,
|
||||
services,
|
||||
interfaces,
|
||||
streams,
|
||||
translations,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn collect(&self) -> Result<OdinInputBatch> {
|
||||
Ok(OdinInputBatch {
|
||||
observed_at: self.clock.now(),
|
||||
source: "odin.rust.ingest".to_string(),
|
||||
verses: self.verses.observe_verses()?,
|
||||
services: self.services.observe_services()?,
|
||||
interfaces: self.interfaces.observe_interfaces()?,
|
||||
observation_streams: self.streams.observe_streams()?,
|
||||
translation_routes: self.translations.observe_translation_routes()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_odin_records(input: OdinInputBatch) -> OdinRecords {
|
||||
let verses = input
|
||||
.verses
|
||||
.into_iter()
|
||||
.map(|entry| OdinVerseRecord {
|
||||
verse_id: entry.verse_id,
|
||||
name: entry.name,
|
||||
role: entry.role,
|
||||
status: entry.status,
|
||||
capabilities: entry.capabilities,
|
||||
observed_at: input.observed_at.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let services = input
|
||||
.services
|
||||
.into_iter()
|
||||
.map(|entry| OdinServiceRecord {
|
||||
service_id: entry.service_id,
|
||||
verse_id: entry.verse_id,
|
||||
name: entry.name,
|
||||
state: entry.state,
|
||||
detail: entry.detail,
|
||||
authority: entry.authority,
|
||||
observed_at: input.observed_at.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let interfaces = input
|
||||
.interfaces
|
||||
.into_iter()
|
||||
.map(|entry| OdinInterfaceRecord {
|
||||
provider_id: entry.provider_id,
|
||||
title: entry.title,
|
||||
state: entry.state,
|
||||
source: entry.source,
|
||||
version: entry.version,
|
||||
updated_at: entry.updated_at,
|
||||
observed_at: input.observed_at.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let observation_streams = input
|
||||
.observation_streams
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let stream_key = format!("{}:{}:{}", entry.device_id, entry.stream_id, entry.kind);
|
||||
OdinObservationStreamRecord {
|
||||
stream_key,
|
||||
device_id: entry.device_id,
|
||||
stream_id: entry.stream_id,
|
||||
kind: entry.kind,
|
||||
state: entry.state,
|
||||
detail: entry.detail,
|
||||
owner: entry.owner,
|
||||
observed_at: input.observed_at.clone(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let translation_routes = input
|
||||
.translation_routes
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let route_id = format!("{}=>{}", entry.source_schema, entry.target_schema);
|
||||
OdinTranslationRouteRecord {
|
||||
route_id,
|
||||
source_schema: entry.source_schema,
|
||||
target_schema: entry.target_schema,
|
||||
translation_kind: entry.translation_kind,
|
||||
owner: entry.owner,
|
||||
version: entry.version,
|
||||
notes: entry.notes,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let snapshot = OdinSnapshotRecord {
|
||||
snapshot_id: "latest".to_string(),
|
||||
observed_at: input.observed_at,
|
||||
verse_count: verses.len() as u32,
|
||||
service_count: services.len() as u32,
|
||||
interface_count: interfaces.len() as u32,
|
||||
observation_stream_count: observation_streams.len() as u32,
|
||||
source: input.source,
|
||||
};
|
||||
|
||||
OdinRecords {
|
||||
snapshot: Some(snapshot),
|
||||
verses,
|
||||
services,
|
||||
interfaces,
|
||||
observation_streams,
|
||||
translation_routes,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ports::{
|
||||
FixedClock, InterfaceObservation, ObservationStreamObservation, ServiceObservation,
|
||||
TranslationRouteObservation, VerseObservation,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct MockIngest;
|
||||
|
||||
impl VerseIngest for MockIngest {
|
||||
fn observe_verses(&self) -> Result<Vec<VerseObservation>> {
|
||||
Ok(vec![VerseObservation {
|
||||
verse_id: "starfire.local".to_string(),
|
||||
name: "Starfire".to_string(),
|
||||
role: "coordinator".to_string(),
|
||||
status: "active".to_string(),
|
||||
capabilities: vec!["cultmesh".to_string()],
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
impl ServiceIngest for MockIngest {
|
||||
fn observe_services(&self) -> Result<Vec<ServiceObservation>> {
|
||||
Ok(vec![ServiceObservation {
|
||||
service_id: "odin".to_string(),
|
||||
verse_id: "starfire.local".to_string(),
|
||||
name: "Odin".to_string(),
|
||||
state: "active".to_string(),
|
||||
detail: "typed state".to_string(),
|
||||
authority: "odin".to_string(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceIngest for MockIngest {
|
||||
fn observe_interfaces(&self) -> Result<Vec<InterfaceObservation>> {
|
||||
Ok(vec![InterfaceObservation {
|
||||
provider_id: "odin.allseer".to_string(),
|
||||
title: "Odin".to_string(),
|
||||
state: "active".to_string(),
|
||||
source: "cultmesh".to_string(),
|
||||
version: Some("1".to_string()),
|
||||
updated_at: None,
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
impl ObservationStreamIngest for MockIngest {
|
||||
fn observe_streams(&self) -> Result<Vec<ObservationStreamObservation>> {
|
||||
Ok(vec![ObservationStreamObservation {
|
||||
device_id: "periwinkle".to_string(),
|
||||
stream_id: "motion".to_string(),
|
||||
kind: "sensor".to_string(),
|
||||
state: "active".to_string(),
|
||||
detail: "fresh".to_string(),
|
||||
owner: "mimir".to_string(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
impl TranslationRouteIngest for MockIngest {
|
||||
fn observe_translation_routes(&self) -> Result<Vec<TranslationRouteObservation>> {
|
||||
Ok(vec![TranslationRouteObservation {
|
||||
source_schema: "mimir.eve_sensor_observation.v1".to_string(),
|
||||
target_schema: "odin.observation_stream.v1".to_string(),
|
||||
translation_kind: "projection".to_string(),
|
||||
owner: "odin".to_string(),
|
||||
version: "v1".to_string(),
|
||||
notes: "dashboard projection".to_string(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_collects_from_injected_ports() -> Result<()> {
|
||||
let pipeline = OdinIngestPipeline::new(
|
||||
MockIngest,
|
||||
MockIngest,
|
||||
MockIngest,
|
||||
MockIngest,
|
||||
MockIngest,
|
||||
FixedClock::new("2026-06-03T00:00:00Z"),
|
||||
);
|
||||
|
||||
let records = normalize_odin_records(pipeline.collect()?);
|
||||
|
||||
assert_eq!(records.snapshot.unwrap().service_count, 1);
|
||||
assert_eq!(records.verses[0].verse_id, "starfire.local");
|
||||
assert_eq!(records.services[0].authority, "odin");
|
||||
assert_eq!(
|
||||
records.observation_streams[0].stream_key,
|
||||
"periwinkle:motion:sensor"
|
||||
);
|
||||
assert_eq!(
|
||||
records.translation_routes[0].route_id,
|
||||
"mimir.eve_sensor_observation.v1=>odin.observation_stream.v1"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
102
crates/odin-core/src/ports.rs
Normal file
102
crates/odin-core/src/ports.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use anyhow::Result;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VerseObservation {
|
||||
pub verse_id: String,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub status: String,
|
||||
pub capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ServiceObservation {
|
||||
pub service_id: String,
|
||||
pub verse_id: String,
|
||||
pub name: String,
|
||||
pub state: String,
|
||||
pub detail: String,
|
||||
pub authority: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InterfaceObservation {
|
||||
pub provider_id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub source: String,
|
||||
pub version: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ObservationStreamObservation {
|
||||
pub device_id: String,
|
||||
pub stream_id: String,
|
||||
pub kind: String,
|
||||
pub state: String,
|
||||
pub detail: String,
|
||||
pub owner: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TranslationRouteObservation {
|
||||
pub source_schema: String,
|
||||
pub target_schema: String,
|
||||
pub translation_kind: String,
|
||||
pub owner: String,
|
||||
pub version: String,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OdinInputBatch {
|
||||
pub observed_at: String,
|
||||
pub source: String,
|
||||
pub verses: Vec<VerseObservation>,
|
||||
pub services: Vec<ServiceObservation>,
|
||||
pub interfaces: Vec<InterfaceObservation>,
|
||||
pub observation_streams: Vec<ObservationStreamObservation>,
|
||||
pub translation_routes: Vec<TranslationRouteObservation>,
|
||||
}
|
||||
|
||||
pub trait VerseIngest {
|
||||
fn observe_verses(&self) -> Result<Vec<VerseObservation>>;
|
||||
}
|
||||
|
||||
pub trait ServiceIngest {
|
||||
fn observe_services(&self) -> Result<Vec<ServiceObservation>>;
|
||||
}
|
||||
|
||||
pub trait InterfaceIngest {
|
||||
fn observe_interfaces(&self) -> Result<Vec<InterfaceObservation>>;
|
||||
}
|
||||
|
||||
pub trait ObservationStreamIngest {
|
||||
fn observe_streams(&self) -> Result<Vec<ObservationStreamObservation>>;
|
||||
}
|
||||
|
||||
pub trait TranslationRouteIngest {
|
||||
fn observe_translation_routes(&self) -> Result<Vec<TranslationRouteObservation>>;
|
||||
}
|
||||
|
||||
pub trait Clock {
|
||||
fn now(&self) -> String;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FixedClock {
|
||||
now: String,
|
||||
}
|
||||
|
||||
impl FixedClock {
|
||||
pub fn new(now: impl Into<String>) -> Self {
|
||||
Self { now: now.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now(&self) -> String {
|
||||
self.now.clone()
|
||||
}
|
||||
}
|
||||
185
crates/odin-core/src/repository.rs
Normal file
185
crates/odin-core/src/repository.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
use crate::documents::{
|
||||
OdinDocuments, OdinRecords, OdinServiceRecord, OdinSnapshotRecord, OdinVerseRecord,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use cultmesh_rs::{CultMesh, CultMeshNode, CultMeshNodeOptions};
|
||||
use std::path::Path;
|
||||
|
||||
pub trait OdinRepository {
|
||||
fn persist_records(&mut self, records: &OdinRecords) -> Result<()>;
|
||||
}
|
||||
|
||||
pub struct CultMeshOdinRepository {
|
||||
node: CultMeshNode,
|
||||
}
|
||||
|
||||
impl CultMeshOdinRepository {
|
||||
pub fn open(store_path: impl AsRef<Path>, runtime_id: impl Into<String>) -> Result<Self> {
|
||||
let node = CultMesh::create_node(
|
||||
store_path,
|
||||
OdinDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: runtime_id.into(),
|
||||
pull_on_start: true,
|
||||
},
|
||||
)?;
|
||||
Ok(Self { node })
|
||||
}
|
||||
|
||||
pub fn get_snapshot(&self) -> Result<OdinSnapshotRecord> {
|
||||
self.node.get_required::<OdinSnapshotRecord>("latest")
|
||||
}
|
||||
|
||||
pub fn get_service(&self, service_id: &str) -> Result<Option<OdinServiceRecord>> {
|
||||
self.node.get::<OdinServiceRecord>(service_id)
|
||||
}
|
||||
|
||||
pub fn get_verse(&self, verse_id: &str) -> Result<Option<OdinVerseRecord>> {
|
||||
self.node.get::<OdinVerseRecord>(verse_id)
|
||||
}
|
||||
|
||||
pub fn document_binding_version(&self, document_type: &str) -> Option<String> {
|
||||
self.node
|
||||
.documents()
|
||||
.binding(document_type)
|
||||
.and_then(|binding| binding.payload_schema_version.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl OdinRepository for CultMeshOdinRepository {
|
||||
fn persist_records(&mut self, records: &OdinRecords) -> Result<()> {
|
||||
if let Some(snapshot) = &records.snapshot {
|
||||
self.node.put("latest", snapshot)?;
|
||||
}
|
||||
for verse in &records.verses {
|
||||
self.node.put(&verse.verse_id, verse)?;
|
||||
}
|
||||
for service in &records.services {
|
||||
self.node.put(&service.service_id, service)?;
|
||||
}
|
||||
for interface in &records.interfaces {
|
||||
self.node.put(&interface.provider_id, interface)?;
|
||||
}
|
||||
for stream in &records.observation_streams {
|
||||
self.node.put(&stream.stream_key, stream)?;
|
||||
}
|
||||
for route in &records.translation_routes {
|
||||
self.node.put(&route.route_id, route)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct InMemoryOdinRepository {
|
||||
pub records: OdinRecords,
|
||||
}
|
||||
|
||||
impl OdinRepository for InMemoryOdinRepository {
|
||||
fn persist_records(&mut self, records: &OdinRecords) -> Result<()> {
|
||||
self.records = records.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn persist_pipeline_records(
|
||||
repository: &mut impl OdinRepository,
|
||||
records: OdinRecords,
|
||||
) -> Result<OdinRecords> {
|
||||
repository.persist_records(&records)?;
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::documents::ODIN_SERVICE_SCHEMA;
|
||||
use crate::pipeline::normalize_odin_records;
|
||||
use crate::ports::{
|
||||
InterfaceObservation, ObservationStreamObservation, OdinInputBatch, ServiceObservation,
|
||||
TranslationRouteObservation, VerseObservation,
|
||||
};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn sample_records() -> OdinRecords {
|
||||
normalize_odin_records(OdinInputBatch {
|
||||
observed_at: "2026-06-03T00:00:00Z".to_string(),
|
||||
source: "unit-test".to_string(),
|
||||
verses: vec![VerseObservation {
|
||||
verse_id: "starfire.local".to_string(),
|
||||
name: "Starfire".to_string(),
|
||||
role: "coordinator".to_string(),
|
||||
status: "active".to_string(),
|
||||
capabilities: vec!["cultmesh".to_string()],
|
||||
}],
|
||||
services: vec![ServiceObservation {
|
||||
service_id: "odin".to_string(),
|
||||
verse_id: "starfire.local".to_string(),
|
||||
name: "Odin".to_string(),
|
||||
state: "active".to_string(),
|
||||
detail: "persisted".to_string(),
|
||||
authority: "odin".to_string(),
|
||||
}],
|
||||
interfaces: vec![InterfaceObservation {
|
||||
provider_id: "odin.allseer".to_string(),
|
||||
title: "Odin".to_string(),
|
||||
state: "active".to_string(),
|
||||
source: "cultmesh".to_string(),
|
||||
version: Some("1".to_string()),
|
||||
updated_at: None,
|
||||
}],
|
||||
observation_streams: vec![ObservationStreamObservation {
|
||||
device_id: "periwinkle".to_string(),
|
||||
stream_id: "camera".to_string(),
|
||||
kind: "media".to_string(),
|
||||
state: "active".to_string(),
|
||||
detail: "fresh".to_string(),
|
||||
owner: "mimir".to_string(),
|
||||
}],
|
||||
translation_routes: vec![TranslationRouteObservation {
|
||||
source_schema: "source.v1".to_string(),
|
||||
target_schema: "target.v1".to_string(),
|
||||
translation_kind: "projection".to_string(),
|
||||
owner: "odin".to_string(),
|
||||
version: "v1".to_string(),
|
||||
notes: "test".to_string(),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_repository_supports_fast_unit_tests() -> Result<()> {
|
||||
let records = sample_records();
|
||||
let mut repository = InMemoryOdinRepository::default();
|
||||
|
||||
repository.persist_records(&records)?;
|
||||
|
||||
assert_eq!(repository.records.services[0].service_id, "odin");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cultmesh_repository_round_trips_typed_records() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("odin.cc");
|
||||
let records = sample_records();
|
||||
|
||||
let mut repository = CultMeshOdinRepository::open(&store_path, "odin-test")?;
|
||||
repository.persist_records(&records)?;
|
||||
assert_eq!(
|
||||
repository
|
||||
.document_binding_version("odin.service")
|
||||
.as_deref(),
|
||||
Some(ODIN_SERVICE_SCHEMA)
|
||||
);
|
||||
|
||||
let reloaded = CultMeshOdinRepository::open(&store_path, "odin-test-reloaded")?;
|
||||
assert_eq!(reloaded.get_snapshot()?.service_count, 1);
|
||||
assert_eq!(reloaded.get_service("odin")?.unwrap().detail, "persisted");
|
||||
assert_eq!(
|
||||
reloaded.get_verse("starfire.local")?.unwrap().capabilities,
|
||||
vec!["cultmesh".to_string()]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -22,10 +22,39 @@ host / device / service probes
|
|||
|
||||
This first path proves the operator surface and persistent state. It does not yet pretend to be full peer exchange.
|
||||
|
||||
## Rust Target Spine
|
||||
|
||||
The target Odin machine is Rust-first and typed-state-first:
|
||||
|
||||
```text
|
||||
Verse / host / device / provider inputs
|
||||
-> ingest ports
|
||||
-> normalization
|
||||
-> typed Odin records
|
||||
-> CultMesh node
|
||||
-> CultCache .cc persistence
|
||||
-> CultNet/CultMesh document registry
|
||||
-> Eve/CultUI projection and agent affordance packets
|
||||
```
|
||||
|
||||
The first Rust core lives in `crates/odin-core`:
|
||||
|
||||
- `documents.rs`: typed Odin records and the CultMesh document set.
|
||||
- `ports.rs`: narrow ingest traits plus clock injection for deterministic tests.
|
||||
- `pipeline.rs`: collection and normalization from input observations to typed records.
|
||||
- `repository.rs`: `OdinRepository` abstraction, in-memory mock repository, and CultMesh-backed repository.
|
||||
|
||||
The Rust spine owns the future architecture. The CommonJS daemon remains the
|
||||
legacy operational body until each organ crosses this typed boundary.
|
||||
|
||||
## Runtime Body
|
||||
|
||||
Odin's executable body is split by ownership:
|
||||
|
||||
- `crates/odin-core`: Rust target core. Owns typed Odin documents, ingest
|
||||
ports, normalization, and CultMesh/CultCache repository boundaries. This is
|
||||
the replacement spine; JavaScript remains legacy runtime/probe scaffolding
|
||||
until each organ has crossed the typed boundary.
|
||||
- `src/odin-coordinator.cjs`: process lifecycle, refresh loop, persistence, health, and transport wiring.
|
||||
- `src/odin/config.cjs`: runtime paths, seed deck URLs, intervals, and CultLib module path setup.
|
||||
- `src/odin/documents.cjs`: CultCache/CultMesh document definitions accepted by Odin.
|
||||
|
|
@ -68,6 +97,19 @@ Verse announcement
|
|||
- Renderers lower surfaces only. If a renderer fixes network truth, the machine is split-brained.
|
||||
- CultCache is the durable state substrate; CultNet is the wire vocabulary; CultMesh is the Verse and peer-consensus layer.
|
||||
- The Eve surface carries explicit `verse`, `service`, and `observation-stream` nodes. Compact renderers may derive visual facets from those nodes, but may not invent observation truth.
|
||||
- Rust organs must accept mocked inputs through narrow traits. Unit tests prove
|
||||
local invariants; pipeline smokes prove adjacent typed handoff; full daemon
|
||||
boots are not the only test path.
|
||||
- JSON is not state authority. It is allowed only for schema publication,
|
||||
debugging, compatibility export, or external xenos boundaries.
|
||||
|
||||
## Test Surfaces
|
||||
|
||||
Current Rust verification:
|
||||
|
||||
- `pipeline_collects_from_injected_ports`: proves ingest ports and clock injection.
|
||||
- `memory_repository_supports_fast_unit_tests`: proves repository consumers can test without CultMesh.
|
||||
- `cultmesh_repository_round_trips_typed_records`: proves typed records persist through CultMesh/CultCache and reload from `.cc`.
|
||||
|
||||
## Service Architecture Contract
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"gjallar:persona": "node scripts/write-gjallar-persona-cc.mjs inspect",
|
||||
"gjallar:persona:write": "node scripts/write-gjallar-persona-cc.mjs write",
|
||||
"gjallar:build": "dotnet build src/Gjallar/Gjallar.csproj",
|
||||
"rust:test": "cargo test --workspace",
|
||||
"check": "node scripts/check-syntax.cjs",
|
||||
"start": "node src/odin-coordinator.cjs"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue