Make Gjallar the Nightwing overview compositor
This commit is contained in:
parent
bbb3b9a8e1
commit
c87a9f8a4d
13 changed files with 677 additions and 300 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -361,8 +361,11 @@ name = "gjallar-daemon"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cultcache-rs",
|
||||
"cultmesh-rs",
|
||||
"odin-core",
|
||||
"rmp-serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -522,6 +525,7 @@ dependencies = [
|
|||
"cultnet-rs",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -18,21 +18,21 @@ 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 is part of that spine as a typed
|
||||
`gjallar.affordance.v1` lane: it transmits Odin-owned sight into agent-ready
|
||||
affordance records without owning discovery truth.
|
||||
booting the whole daemon. Gjallar is part of that spine as the typed overview
|
||||
composition lane: it reads Odin-owned sight and publishes the compact feed that
|
||||
Nightwing lowers into terminal cells.
|
||||
|
||||
## Gjallar
|
||||
|
||||
Gjallar is Odin's herald organ: the daemon/persona package that should carry
|
||||
Odin's gathered sight into agent context. Odin sees the Verses; Gjallar
|
||||
transmits the usable tapestry of affordances without becoming the owner of
|
||||
discovery, probing, rendering, or schema truth.
|
||||
Gjallar is Odin's herald display daemon: the package that turns Odin's gathered
|
||||
sight into the overview Nightwing displays. Odin sees the Verses; Gjallar
|
||||
decides how that sight is grouped, prioritized, and packed into a feed.
|
||||
Nightwing stays a thin fast terminal lowerer.
|
||||
|
||||
Local package surfaces:
|
||||
|
||||
- Organ contract: `docs/gjallar.md`
|
||||
- Canonical Persona state: `personas/gjallar.persona_state.cc`
|
||||
- Branding Persona state: `personas/gjallar.persona_state.cc`
|
||||
- Rust CultMesh organ: `crates/gjallar-daemon`
|
||||
- Avatar asset: `assets/personas/gjallar-avatar.png`
|
||||
- Pixel avatar: `assets/personas/gjallar-avatar-pixel-256.png`
|
||||
|
|
|
|||
|
|
@ -11,5 +11,8 @@ path = "src/main.rs"
|
|||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
cultcache-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultcache-rs" }
|
||||
cultmesh-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultmesh-rs" }
|
||||
odin-core = { path = "../odin-core" }
|
||||
rmp-serde = "1"
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
use anyhow::{Context, Result, anyhow};
|
||||
use cultmesh_rs::{CultMesh, CultMeshNodeOptions};
|
||||
use odin_core::{GjallarAffordanceRecord, OdinDocuments};
|
||||
use cultmesh_rs::{CultMesh, CultMeshNode, CultMeshNodeOptions};
|
||||
use odin_core::{
|
||||
EveSurfaceStateRecord, GjallarComposition, GjallarCompositionInput, GjallarOverviewRecord,
|
||||
GjallarOverviewTileRecord, OdinDocuments, OdinInterfaceRecord, OdinObservationStreamRecord,
|
||||
OdinServiceRecord, OdinSnapshotRecord, OdinTranslationRouteRecord, OdinVerseRecord,
|
||||
compose_gjallar_overview,
|
||||
};
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
|
|
@ -9,17 +14,23 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Options {
|
||||
store_path: PathBuf,
|
||||
persona_store_path: PathBuf,
|
||||
persona_key: String,
|
||||
verse_id: Option<String>,
|
||||
odin_store_path: PathBuf,
|
||||
gjallar_store_path: PathBuf,
|
||||
overview_id: String,
|
||||
surface_key: String,
|
||||
target_columns: u32,
|
||||
verse_keys: Vec<String>,
|
||||
service_keys: Vec<String>,
|
||||
interface_keys: Vec<String>,
|
||||
observation_stream_keys: Vec<String>,
|
||||
translation_route_keys: Vec<String>,
|
||||
interval_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let options = Options::parse(env::args().skip(1))?;
|
||||
|
||||
if let Some(parent) = options.store_path.parent() {
|
||||
if let Some(parent) = options.gjallar_store_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
|
|
@ -35,29 +46,24 @@ fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
fn run_cycle(options: &Options) -> Result<()> {
|
||||
if !options.persona_store_path.exists() {
|
||||
if !options.odin_store_path.exists() {
|
||||
return Err(anyhow!(
|
||||
"Gjallar Persona CultCache store not found: {}",
|
||||
options.persona_store_path.display()
|
||||
"Odin CultMesh store not found: {}",
|
||||
options.odin_store_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let now = timestamp()?;
|
||||
let source_record = format!("gamecult.persona_state:{}", options.persona_key);
|
||||
let affordance = GjallarAffordanceRecord {
|
||||
affordance_id: format!("gjallar:{}:transmit-context", options.persona_key),
|
||||
source_record,
|
||||
verse_id: options.verse_id.clone(),
|
||||
surface_kind: "persona".to_string(),
|
||||
action: "transmit-context".to_string(),
|
||||
authority: "gjallar".to_string(),
|
||||
status: "available".to_string(),
|
||||
provenance: options.persona_store_path.display().to_string(),
|
||||
observed_at: now,
|
||||
};
|
||||
let odin = CultMesh::create_node(
|
||||
&options.odin_store_path,
|
||||
OdinDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: "gjallar-odin-reader".to_string(),
|
||||
pull_on_start: true,
|
||||
},
|
||||
)?;
|
||||
|
||||
let mut node = CultMesh::create_node(
|
||||
&options.store_path,
|
||||
let mut gjallar = CultMesh::create_node(
|
||||
&options.gjallar_store_path,
|
||||
OdinDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: "gjallar-daemon".to_string(),
|
||||
|
|
@ -65,39 +71,177 @@ fn run_cycle(options: &Options) -> Result<()> {
|
|||
},
|
||||
)?;
|
||||
|
||||
node.put(&affordance.affordance_id, &affordance)?;
|
||||
let composition = match odin.get::<OdinSnapshotRecord>("latest")? {
|
||||
Some(snapshot) => compose_gjallar_overview(GjallarCompositionInput {
|
||||
overview_id: options.overview_id.clone(),
|
||||
composed_at: timestamp()?,
|
||||
target_columns: options.target_columns,
|
||||
snapshot,
|
||||
verses: read_many::<OdinVerseRecord>(&odin, &options.verse_keys)?,
|
||||
services: read_many::<OdinServiceRecord>(&odin, &options.service_keys)?,
|
||||
interfaces: read_many::<OdinInterfaceRecord>(&odin, &options.interface_keys)?,
|
||||
observation_streams: read_many::<OdinObservationStreamRecord>(
|
||||
&odin,
|
||||
&options.observation_stream_keys,
|
||||
)?,
|
||||
translation_routes: read_many::<OdinTranslationRouteRecord>(
|
||||
&odin,
|
||||
&options.translation_route_keys,
|
||||
)?,
|
||||
}),
|
||||
None => compose_from_compat_surface(
|
||||
&options.overview_id,
|
||||
options.target_columns,
|
||||
timestamp()?,
|
||||
read_compat_surface(&odin, &options.surface_key)?,
|
||||
),
|
||||
};
|
||||
|
||||
println!("Gjallar published {}", affordance.affordance_id);
|
||||
println!("CultMesh store: {}", options.store_path.display());
|
||||
println!("Persona source: {}", options.persona_store_path.display());
|
||||
gjallar.put(&composition.overview.overview_id, &composition.overview)?;
|
||||
for tile in &composition.tiles {
|
||||
gjallar.put(&tile.tile_id, tile)?;
|
||||
}
|
||||
|
||||
println!(
|
||||
"Gjallar composed {} tiles for {}",
|
||||
composition.overview.tile_count, composition.overview.overview_id
|
||||
);
|
||||
println!("Odin source: {}", options.odin_store_path.display());
|
||||
println!("Gjallar feed: {}", options.gjallar_store_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_many<T>(node: &CultMeshNode, keys: &[String]) -> Result<Vec<T>>
|
||||
where
|
||||
T: cultcache_rs::DatabaseEntry,
|
||||
{
|
||||
let mut records = Vec::new();
|
||||
for key in keys {
|
||||
if let Some(record) = node.get::<T>(key)? {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn read_compat_surface(node: &CultMeshNode, key: &str) -> Result<serde_json::Value> {
|
||||
let envelope = node
|
||||
.cache()
|
||||
.get_required_envelope::<EveSurfaceStateRecord>(key)?;
|
||||
rmp_serde::from_slice(&envelope.payload).with_context(|| {
|
||||
format!("decoding CommonJS Odin surface payload at {key:?} as MessagePack map")
|
||||
})
|
||||
}
|
||||
|
||||
fn compose_from_compat_surface(
|
||||
overview_id: &str,
|
||||
target_columns: u32,
|
||||
composed_at: String,
|
||||
surface: serde_json::Value,
|
||||
) -> GjallarComposition {
|
||||
let provider_id = string_field(&surface, "providerId", "surface");
|
||||
let detail = surface
|
||||
.get("surface")
|
||||
.and_then(|surface| surface.get("root"))
|
||||
.or_else(|| surface.get("root"))
|
||||
.and_then(|root| root.get("props"))
|
||||
.and_then(|props| props.get("summary"))
|
||||
.and_then(|summary| summary.as_str())
|
||||
.unwrap_or("Odin CommonJS surface");
|
||||
let title = string_field(&surface, "title", &provider_id);
|
||||
let updated_at = string_field(&surface, "updatedAt", "unknown");
|
||||
let tile = GjallarOverviewTileRecord {
|
||||
tile_id: format!("{overview_id}:tile:0"),
|
||||
overview_id: overview_id.to_string(),
|
||||
source_record: format!("gamecult.eve.surface_state:{provider_id}"),
|
||||
tile_kind: "compat-surface".to_string(),
|
||||
title,
|
||||
state: "active".to_string(),
|
||||
detail: detail.to_string(),
|
||||
priority: 0,
|
||||
row_span: 1,
|
||||
column_span: target_columns.max(1),
|
||||
observed_at: updated_at.clone(),
|
||||
};
|
||||
GjallarComposition {
|
||||
overview: GjallarOverviewRecord {
|
||||
overview_id: overview_id.to_string(),
|
||||
source_snapshot_id: provider_id,
|
||||
title: "Gjallar".to_string(),
|
||||
status: "active".to_string(),
|
||||
summary: "1 drawable tile from Odin compatibility surface".to_string(),
|
||||
tile_count: 1,
|
||||
target_columns: target_columns.max(1),
|
||||
target_rows: 1,
|
||||
source_observed_at: updated_at,
|
||||
composed_at,
|
||||
},
|
||||
tiles: vec![tile],
|
||||
}
|
||||
}
|
||||
|
||||
fn string_field(value: &serde_json::Value, key: &str, fallback: &str) -> String {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(|entry| entry.as_str())
|
||||
.filter(|entry| !entry.trim().is_empty())
|
||||
.unwrap_or(fallback)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
impl Options {
|
||||
fn parse(args: impl Iterator<Item = String>) -> Result<Self> {
|
||||
let repo_root = find_repo_root(env::current_dir().context("reading current directory")?);
|
||||
let mut options = Options {
|
||||
store_path: repo_root
|
||||
odin_store_path: repo_root.join("scratch").join("odin").join("odin.ccmp"),
|
||||
gjallar_store_path: repo_root
|
||||
.join("scratch")
|
||||
.join("gjallar")
|
||||
.join("gjallar.affordances.cc"),
|
||||
persona_store_path: repo_root.join("personas").join("gjallar.persona_state.cc"),
|
||||
persona_key: "persona:gjallar".to_string(),
|
||||
verse_id: Some("odin.local".to_string()),
|
||||
.join("gjallar.overview.ccmp"),
|
||||
overview_id: "gjallar.overview:nightwing".to_string(),
|
||||
surface_key: "surface:gamecult.network.status".to_string(),
|
||||
target_columns: 4,
|
||||
verse_keys: split_keys(
|
||||
"starfire.local,nightwing.local,periwinkle.local,yggdrasil.local",
|
||||
),
|
||||
service_keys: split_keys("odin,cultcache,interfaces,gpu"),
|
||||
interface_keys: split_keys("odin.allseer"),
|
||||
observation_stream_keys: split_keys("periwinkle:motion:sensor,periwinkle:camera:media"),
|
||||
translation_route_keys: Vec::new(),
|
||||
interval_seconds: None,
|
||||
};
|
||||
|
||||
let mut args = args.peekable();
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--store" => options.store_path = PathBuf::from(take_value(&mut args, "--store")?),
|
||||
"--persona-store" => {
|
||||
options.persona_store_path =
|
||||
PathBuf::from(take_value(&mut args, "--persona-store")?)
|
||||
"--odin-store" => {
|
||||
options.odin_store_path = PathBuf::from(take_value(&mut args, "--odin-store")?)
|
||||
}
|
||||
"--store" | "--gjallar-store" => {
|
||||
options.gjallar_store_path =
|
||||
PathBuf::from(take_value(&mut args, "--gjallar-store")?)
|
||||
}
|
||||
"--overview" => options.overview_id = take_value(&mut args, "--overview")?,
|
||||
"--surface-key" => options.surface_key = take_value(&mut args, "--surface-key")?,
|
||||
"--columns" => {
|
||||
options.target_columns = take_value(&mut args, "--columns")?
|
||||
.parse()
|
||||
.context("--columns must be a positive integer")?
|
||||
}
|
||||
"--verses" => options.verse_keys = split_keys(take_value(&mut args, "--verses")?),
|
||||
"--services" => {
|
||||
options.service_keys = split_keys(take_value(&mut args, "--services")?)
|
||||
}
|
||||
"--interfaces" => {
|
||||
options.interface_keys = split_keys(take_value(&mut args, "--interfaces")?)
|
||||
}
|
||||
"--streams" => {
|
||||
options.observation_stream_keys =
|
||||
split_keys(take_value(&mut args, "--streams")?)
|
||||
}
|
||||
"--routes" => {
|
||||
options.translation_route_keys = split_keys(take_value(&mut args, "--routes")?)
|
||||
}
|
||||
"--persona-key" => options.persona_key = take_value(&mut args, "--persona-key")?,
|
||||
"--verse" => options.verse_id = Some(take_value(&mut args, "--verse")?),
|
||||
"--no-verse" => options.verse_id = None,
|
||||
"--interval-seconds" => {
|
||||
options.interval_seconds = Some(
|
||||
take_value(&mut args, "--interval-seconds")?
|
||||
|
|
@ -115,8 +259,11 @@ impl Options {
|
|||
}
|
||||
}
|
||||
|
||||
if options.persona_key.trim().is_empty() {
|
||||
return Err(anyhow!("--persona-key must not be empty"));
|
||||
if options.overview_id.trim().is_empty() {
|
||||
return Err(anyhow!("--overview must not be empty"));
|
||||
}
|
||||
if options.target_columns == 0 {
|
||||
return Err(anyhow!("--columns must be greater than zero"));
|
||||
}
|
||||
if options.interval_seconds == Some(0) {
|
||||
return Err(anyhow!("--interval-seconds must be greater than zero"));
|
||||
|
|
@ -131,6 +278,16 @@ fn take_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<Str
|
|||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn split_keys(value: impl AsRef<str>) -> Vec<String> {
|
||||
value
|
||||
.as_ref()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_repo_root(start: PathBuf) -> PathBuf {
|
||||
for candidate in start.ancestors() {
|
||||
if candidate.join("Cargo.toml").exists() && candidate.join("personas").is_dir() {
|
||||
|
|
@ -149,5 +306,5 @@ fn timestamp() -> Result<String> {
|
|||
}
|
||||
|
||||
fn help_text() -> &'static str {
|
||||
"Usage: gjallar [--store <path>] [--persona-store <path>] [--persona-key <key>] [--verse <id>|--no-verse] [--interval-seconds <seconds>]\n\nGjallar reads its canonical Persona CultCache source and publishes typed gjallar.affordance.v1 state through CultMesh."
|
||||
"Usage: gjallar [--odin-store <path>] [--gjallar-store <path>] [--overview <id>] [--surface-key <key>] [--columns <n>] [--verses <keys>] [--services <keys>] [--interfaces <keys>] [--streams <keys>] [--routes <keys>] [--interval-seconds <seconds>]\n\nGjallar reads Odin-owned typed records and publishes a compact typed overview feed for Nightwing."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ path = "src/lib.rs"
|
|||
anyhow = "1"
|
||||
cultcache-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultcache-rs" }
|
||||
cultmesh-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultmesh-rs" }
|
||||
serde_json = "1"
|
||||
cultnet-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultnet-rs" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use cultcache_rs::DatabaseEntry;
|
||||
use serde_json::Value;
|
||||
|
||||
pub const ODIN_SNAPSHOT_SCHEMA: &str = "odin.snapshot.v1";
|
||||
pub const ODIN_VERSE_SCHEMA: &str = "odin.verse.v1";
|
||||
|
|
@ -6,7 +7,12 @@ 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";
|
||||
pub const GJALLAR_AFFORDANCE_SCHEMA: &str = "gjallar.affordance.v1";
|
||||
pub const GJALLAR_OVERVIEW_SCHEMA: &str = "gjallar.overview.v1";
|
||||
pub const GJALLAR_OVERVIEW_TILE_SCHEMA: &str = "gjallar.overview_tile.v1";
|
||||
pub const EVE_SURFACE_STATE_SCHEMA: &str = "gamecult.eve.surface_state.v1";
|
||||
pub const EVE_INTERFACE_BINDING_SCHEMA: &str = "gamecult.eve.interface_binding.v1";
|
||||
pub const EVE_PROVIDER_ADVERTISEMENT_SCHEMA: &str = "gamecult.eve.provider_advertisement.v1";
|
||||
pub const VOIDBOT_SWARM_STATE_SNAPSHOT_SCHEMA: &str = "voidbot.swarm_state_snapshot.v1";
|
||||
pub const IDUNN_DESIRED_DAEMON_SCHEMA: &str = "idunn.desired_daemon.v1";
|
||||
pub const IDUNN_DAEMON_HEALTH_SCHEMA: &str = "idunn.daemon_health.v1";
|
||||
pub const IDUNN_KEEPALIVE_DECISION_SCHEMA: &str = "idunn.keepalive_decision.v1";
|
||||
|
|
@ -132,28 +138,105 @@ pub struct OdinTranslationRouteRecord {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "gjallar.affordance", schema = "gjallar.affordance.v1")]
|
||||
pub struct GjallarAffordanceRecord {
|
||||
#[cultcache(type = "gjallar.overview", schema = "gjallar.overview.v1")]
|
||||
pub struct GjallarOverviewRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub affordance_id: String,
|
||||
pub overview_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub source_record: String,
|
||||
pub source_snapshot_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub verse_id: Option<String>,
|
||||
pub title: String,
|
||||
#[cultcache(key = 3)]
|
||||
pub surface_kind: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub action: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub authority: String,
|
||||
#[cultcache(key = 6)]
|
||||
pub status: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub summary: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub tile_count: u32,
|
||||
#[cultcache(key = 6)]
|
||||
pub target_columns: u32,
|
||||
#[cultcache(key = 7)]
|
||||
pub provenance: String,
|
||||
pub target_rows: u32,
|
||||
#[cultcache(key = 8)]
|
||||
pub source_observed_at: String,
|
||||
#[cultcache(key = 9)]
|
||||
pub composed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "gjallar.overview_tile", schema = "gjallar.overview_tile.v1")]
|
||||
pub struct GjallarOverviewTileRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub tile_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub overview_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub source_record: String,
|
||||
#[cultcache(key = 3)]
|
||||
pub tile_kind: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub title: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub state: String,
|
||||
#[cultcache(key = 6)]
|
||||
pub detail: String,
|
||||
#[cultcache(key = 7)]
|
||||
pub priority: i32,
|
||||
#[cultcache(key = 8)]
|
||||
pub row_span: u32,
|
||||
#[cultcache(key = 9)]
|
||||
pub column_span: u32,
|
||||
#[cultcache(key = 10)]
|
||||
pub observed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.eve.surface_state",
|
||||
schema = "gamecult.eve.surface_state.v1"
|
||||
)]
|
||||
pub struct EveSurfaceStateRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub provider_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub title: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub version: i64,
|
||||
#[cultcache(key = 3)]
|
||||
pub updated_at: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub surface: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.eve.interface_binding",
|
||||
schema = "gamecult.eve.interface_binding.v1"
|
||||
)]
|
||||
pub struct EveInterfaceBindingCompatRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.eve.provider_advertisement",
|
||||
schema = "gamecult.eve.provider_advertisement.v1"
|
||||
)]
|
||||
pub struct EveProviderAdvertisementCompatRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "voidbot.swarm_state_snapshot",
|
||||
schema = "voidbot.swarm_state_snapshot.v1"
|
||||
)]
|
||||
pub struct VoidBotSwarmStateSnapshotCompatRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "idunn.desired_daemon", schema = "idunn.desired_daemon.v1")]
|
||||
pub struct IdunnDesiredDaemonRecord {
|
||||
|
|
@ -266,7 +349,12 @@ cultmesh_rs::cultmesh_documents!(OdinDocuments {
|
|||
OdinInterfaceRecord => ODIN_INTERFACE_SCHEMA,
|
||||
OdinObservationStreamRecord => ODIN_OBSERVATION_STREAM_SCHEMA,
|
||||
OdinTranslationRouteRecord => ODIN_TRANSLATION_ROUTE_SCHEMA,
|
||||
GjallarAffordanceRecord => GJALLAR_AFFORDANCE_SCHEMA,
|
||||
GjallarOverviewRecord => GJALLAR_OVERVIEW_SCHEMA,
|
||||
GjallarOverviewTileRecord => GJALLAR_OVERVIEW_TILE_SCHEMA,
|
||||
EveSurfaceStateRecord => EVE_SURFACE_STATE_SCHEMA,
|
||||
EveInterfaceBindingCompatRecord => EVE_INTERFACE_BINDING_SCHEMA,
|
||||
EveProviderAdvertisementCompatRecord => EVE_PROVIDER_ADVERTISEMENT_SCHEMA,
|
||||
VoidBotSwarmStateSnapshotCompatRecord => VOIDBOT_SWARM_STATE_SNAPSHOT_SCHEMA,
|
||||
IdunnDesiredDaemonRecord => IDUNN_DESIRED_DAEMON_SCHEMA,
|
||||
IdunnDaemonHealthRecord => IDUNN_DAEMON_HEALTH_SCHEMA,
|
||||
IdunnKeepaliveDecisionRecord => IDUNN_KEEPALIVE_DECISION_SCHEMA,
|
||||
|
|
@ -283,5 +371,4 @@ pub struct OdinRecords {
|
|||
pub interfaces: Vec<OdinInterfaceRecord>,
|
||||
pub observation_streams: Vec<OdinObservationStreamRecord>,
|
||||
pub translation_routes: Vec<OdinTranslationRouteRecord>,
|
||||
pub gjallar_affordances: Vec<GjallarAffordanceRecord>,
|
||||
}
|
||||
|
|
|
|||
245
crates/odin-core/src/gjallar.rs
Normal file
245
crates/odin-core/src/gjallar.rs
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
use crate::documents::{
|
||||
GjallarOverviewRecord, GjallarOverviewTileRecord, OdinInterfaceRecord,
|
||||
OdinObservationStreamRecord, OdinServiceRecord, OdinSnapshotRecord, OdinTranslationRouteRecord,
|
||||
OdinVerseRecord,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GjallarCompositionInput {
|
||||
pub overview_id: String,
|
||||
pub composed_at: String,
|
||||
pub target_columns: u32,
|
||||
pub snapshot: OdinSnapshotRecord,
|
||||
pub verses: Vec<OdinVerseRecord>,
|
||||
pub services: Vec<OdinServiceRecord>,
|
||||
pub interfaces: Vec<OdinInterfaceRecord>,
|
||||
pub observation_streams: Vec<OdinObservationStreamRecord>,
|
||||
pub translation_routes: Vec<OdinTranslationRouteRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GjallarComposition {
|
||||
pub overview: GjallarOverviewRecord,
|
||||
pub tiles: Vec<GjallarOverviewTileRecord>,
|
||||
}
|
||||
|
||||
pub fn compose_gjallar_overview(input: GjallarCompositionInput) -> GjallarComposition {
|
||||
let mut tiles = Vec::new();
|
||||
let overview_id = input.overview_id;
|
||||
let target_columns = input.target_columns.max(1);
|
||||
|
||||
tiles.push(tile(
|
||||
&overview_id,
|
||||
0,
|
||||
"odin.snapshot:latest",
|
||||
"summary",
|
||||
"Odin",
|
||||
snapshot_status(&input.snapshot),
|
||||
format!(
|
||||
"{} verses / {} services / {} interfaces / {} streams",
|
||||
input.snapshot.verse_count,
|
||||
input.snapshot.service_count,
|
||||
input.snapshot.interface_count,
|
||||
input.snapshot.observation_stream_count
|
||||
),
|
||||
input.snapshot.observed_at.clone(),
|
||||
));
|
||||
|
||||
for (index, interface) in input.interfaces.iter().enumerate() {
|
||||
tiles.push(tile(
|
||||
&overview_id,
|
||||
100 + index as i32,
|
||||
format!("odin.interface:{}", interface.provider_id),
|
||||
"interface",
|
||||
interface.title.clone(),
|
||||
interface.state.clone(),
|
||||
interface.source.clone(),
|
||||
interface.observed_at.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
for (index, stream) in input.observation_streams.iter().enumerate() {
|
||||
tiles.push(tile(
|
||||
&overview_id,
|
||||
300 + index as i32,
|
||||
format!("odin.observation_stream:{}", stream.stream_key),
|
||||
"observation-stream",
|
||||
format!("{} {}", stream.device_id, stream.stream_id),
|
||||
stream.state.clone(),
|
||||
stream.detail.clone(),
|
||||
stream.observed_at.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
for (index, service) in input.services.iter().enumerate() {
|
||||
tiles.push(tile(
|
||||
&overview_id,
|
||||
500 + index as i32,
|
||||
format!("odin.service:{}", service.service_id),
|
||||
"service",
|
||||
service.name.clone(),
|
||||
service.state.clone(),
|
||||
service.detail.clone(),
|
||||
service.observed_at.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
for (index, verse) in input.verses.iter().enumerate() {
|
||||
tiles.push(tile(
|
||||
&overview_id,
|
||||
700 + index as i32,
|
||||
format!("odin.verse:{}", verse.verse_id),
|
||||
"verse",
|
||||
verse.name.clone(),
|
||||
verse.status.clone(),
|
||||
verse.role.clone(),
|
||||
verse.observed_at.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
for (index, route) in input.translation_routes.iter().enumerate() {
|
||||
tiles.push(tile(
|
||||
&overview_id,
|
||||
900 + index as i32,
|
||||
format!("odin.translation_route:{}", route.route_id),
|
||||
"translation-route",
|
||||
route.target_schema.clone(),
|
||||
route.translation_kind.clone(),
|
||||
route.owner.clone(),
|
||||
input.snapshot.observed_at.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
tiles.sort_by(|left, right| left.priority.cmp(&right.priority));
|
||||
let target_rows = (tiles.len() as u32).div_ceil(target_columns).max(1);
|
||||
let status = if tiles.iter().any(|entry| entry.state == "failed") {
|
||||
"degraded"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
|
||||
let overview = GjallarOverviewRecord {
|
||||
overview_id: overview_id.clone(),
|
||||
source_snapshot_id: input.snapshot.snapshot_id,
|
||||
title: "Gjallar".to_string(),
|
||||
status: status.to_string(),
|
||||
summary: format!("{} drawable tiles from Odin sight", tiles.len()),
|
||||
tile_count: tiles.len() as u32,
|
||||
target_columns,
|
||||
target_rows,
|
||||
source_observed_at: input.snapshot.observed_at,
|
||||
composed_at: input.composed_at,
|
||||
};
|
||||
|
||||
GjallarComposition { overview, tiles }
|
||||
}
|
||||
|
||||
fn tile(
|
||||
overview_id: &str,
|
||||
priority: i32,
|
||||
source_record: impl Into<String>,
|
||||
tile_kind: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
state: impl Into<String>,
|
||||
detail: impl Into<String>,
|
||||
observed_at: impl Into<String>,
|
||||
) -> GjallarOverviewTileRecord {
|
||||
GjallarOverviewTileRecord {
|
||||
tile_id: format!("{}:tile:{priority}", overview_id),
|
||||
overview_id: overview_id.to_string(),
|
||||
source_record: source_record.into(),
|
||||
tile_kind: tile_kind.into(),
|
||||
title: title.into(),
|
||||
state: state.into(),
|
||||
detail: detail.into(),
|
||||
priority,
|
||||
row_span: 1,
|
||||
column_span: 1,
|
||||
observed_at: observed_at.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_status(snapshot: &OdinSnapshotRecord) -> String {
|
||||
if snapshot.service_count == 0 && snapshot.interface_count == 0 {
|
||||
"waiting".to_string()
|
||||
} else {
|
||||
"active".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn compose_overview_orders_dashboard_tiles_for_nightwing() {
|
||||
let composition = compose_gjallar_overview(GjallarCompositionInput {
|
||||
overview_id: "gjallar.overview:nightwing".to_string(),
|
||||
composed_at: "2026-06-05T00:00:00Z".to_string(),
|
||||
target_columns: 2,
|
||||
snapshot: OdinSnapshotRecord {
|
||||
snapshot_id: "latest".to_string(),
|
||||
observed_at: "2026-06-05T00:00:00Z".to_string(),
|
||||
verse_count: 1,
|
||||
service_count: 1,
|
||||
interface_count: 1,
|
||||
observation_stream_count: 1,
|
||||
source: "unit-test".to_string(),
|
||||
},
|
||||
verses: vec![OdinVerseRecord {
|
||||
verse_id: "nightwing.local".to_string(),
|
||||
name: "Nightwing".to_string(),
|
||||
role: "terminal-lowerer".to_string(),
|
||||
status: "active".to_string(),
|
||||
capabilities: vec!["tui".to_string()],
|
||||
observed_at: "2026-06-05T00:00:00Z".to_string(),
|
||||
}],
|
||||
services: vec![OdinServiceRecord {
|
||||
service_id: "odin".to_string(),
|
||||
verse_id: "starfire.local".to_string(),
|
||||
name: "Odin".to_string(),
|
||||
state: "active".to_string(),
|
||||
detail: "all-seer".to_string(),
|
||||
authority: "odin".to_string(),
|
||||
observed_at: "2026-06-05T00:00:00Z".to_string(),
|
||||
}],
|
||||
interfaces: vec![OdinInterfaceRecord {
|
||||
provider_id: "odin.allseer".to_string(),
|
||||
title: "Odin All-Seer".to_string(),
|
||||
state: "active".to_string(),
|
||||
source: "cultmesh".to_string(),
|
||||
version: Some("v1".to_string()),
|
||||
updated_at: None,
|
||||
observed_at: "2026-06-05T00:00:00Z".to_string(),
|
||||
}],
|
||||
observation_streams: vec![OdinObservationStreamRecord {
|
||||
stream_key: "periwinkle:motion:sensor".to_string(),
|
||||
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(),
|
||||
observed_at: "2026-06-05T00:00:00Z".to_string(),
|
||||
}],
|
||||
translation_routes: vec![OdinTranslationRouteRecord {
|
||||
route_id: "source=>target".to_string(),
|
||||
source_schema: "source".to_string(),
|
||||
target_schema: "target".to_string(),
|
||||
translation_kind: "projection".to_string(),
|
||||
owner: "odin".to_string(),
|
||||
version: "v1".to_string(),
|
||||
notes: "test".to_string(),
|
||||
}],
|
||||
});
|
||||
|
||||
assert_eq!(composition.overview.tile_count, 6);
|
||||
assert_eq!(composition.overview.target_rows, 3);
|
||||
assert_eq!(composition.tiles[0].tile_kind, "summary");
|
||||
assert_eq!(
|
||||
composition.tiles[1].source_record,
|
||||
"odin.interface:odin.allseer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
pub mod documents;
|
||||
pub mod gjallar;
|
||||
pub mod idunn;
|
||||
pub mod pipeline;
|
||||
pub mod ports;
|
||||
pub mod repository;
|
||||
|
||||
pub use documents::*;
|
||||
pub use gjallar::*;
|
||||
pub use idunn::*;
|
||||
pub use pipeline::*;
|
||||
pub use ports::*;
|
||||
|
|
|
|||
|
|
@ -1,31 +1,29 @@
|
|||
use crate::documents::{
|
||||
GjallarAffordanceRecord, OdinInterfaceRecord, OdinObservationStreamRecord, OdinRecords,
|
||||
OdinServiceRecord, OdinSnapshotRecord, OdinTranslationRouteRecord, OdinVerseRecord,
|
||||
OdinInterfaceRecord, OdinObservationStreamRecord, OdinRecords, OdinServiceRecord,
|
||||
OdinSnapshotRecord, OdinTranslationRouteRecord, OdinVerseRecord,
|
||||
};
|
||||
use crate::ports::{
|
||||
Clock, GjallarAffordanceIngest, InterfaceIngest, ObservationStreamIngest, OdinInputBatch,
|
||||
ServiceIngest, TranslationRouteIngest, VerseIngest,
|
||||
Clock, InterfaceIngest, ObservationStreamIngest, OdinInputBatch, ServiceIngest,
|
||||
TranslationRouteIngest, VerseIngest,
|
||||
};
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct OdinIngestPipeline<V, S, I, O, T, G, C> {
|
||||
pub struct OdinIngestPipeline<V, S, I, O, T, C> {
|
||||
verses: V,
|
||||
services: S,
|
||||
interfaces: I,
|
||||
streams: O,
|
||||
translations: T,
|
||||
gjallar: G,
|
||||
clock: C,
|
||||
}
|
||||
|
||||
impl<V, S, I, O, T, G, C> OdinIngestPipeline<V, S, I, O, T, G, 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,
|
||||
G: GjallarAffordanceIngest,
|
||||
C: Clock,
|
||||
{
|
||||
pub fn new(
|
||||
|
|
@ -34,7 +32,6 @@ where
|
|||
interfaces: I,
|
||||
streams: O,
|
||||
translations: T,
|
||||
gjallar: G,
|
||||
clock: C,
|
||||
) -> Self {
|
||||
Self {
|
||||
|
|
@ -43,7 +40,6 @@ where
|
|||
interfaces,
|
||||
streams,
|
||||
translations,
|
||||
gjallar,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +53,6 @@ where
|
|||
interfaces: self.interfaces.observe_interfaces()?,
|
||||
observation_streams: self.streams.observe_streams()?,
|
||||
translation_routes: self.translations.observe_translation_routes()?,
|
||||
gjallar_affordances: self.gjallar.observe_gjallar_affordances()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -139,23 +134,6 @@ pub fn normalize_odin_records(input: OdinInputBatch) -> OdinRecords {
|
|||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let gjallar_affordances = input
|
||||
.gjallar_affordances
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, entry)| GjallarAffordanceRecord {
|
||||
affordance_id: format!("gjallar:{}:{}", entry.source_record, index),
|
||||
source_record: entry.source_record,
|
||||
verse_id: entry.verse_id,
|
||||
surface_kind: entry.surface_kind,
|
||||
action: entry.action,
|
||||
authority: entry.authority,
|
||||
status: entry.status,
|
||||
provenance: entry.provenance,
|
||||
observed_at: input.observed_at.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let snapshot = OdinSnapshotRecord {
|
||||
snapshot_id: "latest".to_string(),
|
||||
observed_at: input.observed_at,
|
||||
|
|
@ -173,7 +151,6 @@ pub fn normalize_odin_records(input: OdinInputBatch) -> OdinRecords {
|
|||
interfaces,
|
||||
observation_streams,
|
||||
translation_routes,
|
||||
gjallar_affordances,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -181,9 +158,8 @@ pub fn normalize_odin_records(input: OdinInputBatch) -> OdinRecords {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::ports::{
|
||||
FixedClock, GjallarAffordanceObservation, InterfaceObservation,
|
||||
ObservationStreamObservation, ServiceObservation, TranslationRouteObservation,
|
||||
VerseObservation,
|
||||
FixedClock, InterfaceObservation, ObservationStreamObservation, ServiceObservation,
|
||||
TranslationRouteObservation, VerseObservation,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
|
@ -255,20 +231,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
impl GjallarAffordanceIngest for MockIngest {
|
||||
fn observe_gjallar_affordances(&self) -> Result<Vec<GjallarAffordanceObservation>> {
|
||||
Ok(vec![GjallarAffordanceObservation {
|
||||
source_record: "odin.service:odin".to_string(),
|
||||
verse_id: Some("starfire.local".to_string()),
|
||||
surface_kind: "service".to_string(),
|
||||
action: "inspect".to_string(),
|
||||
authority: "odin".to_string(),
|
||||
status: "available".to_string(),
|
||||
provenance: "unit-test".to_string(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_collects_from_injected_ports() -> Result<()> {
|
||||
let pipeline = OdinIngestPipeline::new(
|
||||
|
|
@ -277,7 +239,6 @@ mod tests {
|
|||
MockIngest,
|
||||
MockIngest,
|
||||
MockIngest,
|
||||
MockIngest,
|
||||
FixedClock::new("2026-06-03T00:00:00Z"),
|
||||
);
|
||||
|
||||
|
|
@ -294,10 +255,6 @@ mod tests {
|
|||
records.translation_routes[0].route_id,
|
||||
"mimir.eve_sensor_observation.v1=>odin.observation_stream.v1"
|
||||
);
|
||||
assert_eq!(
|
||||
records.gjallar_affordances[0].affordance_id,
|
||||
"gjallar:odin.service:odin:0"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,17 +49,6 @@ pub struct TranslationRouteObservation {
|
|||
pub notes: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GjallarAffordanceObservation {
|
||||
pub source_record: String,
|
||||
pub verse_id: Option<String>,
|
||||
pub surface_kind: String,
|
||||
pub action: String,
|
||||
pub authority: String,
|
||||
pub status: String,
|
||||
pub provenance: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OdinInputBatch {
|
||||
pub observed_at: String,
|
||||
|
|
@ -69,7 +58,6 @@ pub struct OdinInputBatch {
|
|||
pub interfaces: Vec<InterfaceObservation>,
|
||||
pub observation_streams: Vec<ObservationStreamObservation>,
|
||||
pub translation_routes: Vec<TranslationRouteObservation>,
|
||||
pub gjallar_affordances: Vec<GjallarAffordanceObservation>,
|
||||
}
|
||||
|
||||
pub trait VerseIngest {
|
||||
|
|
@ -92,10 +80,6 @@ pub trait TranslationRouteIngest {
|
|||
fn observe_translation_routes(&self) -> Result<Vec<TranslationRouteObservation>>;
|
||||
}
|
||||
|
||||
pub trait GjallarAffordanceIngest {
|
||||
fn observe_gjallar_affordances(&self) -> Result<Vec<GjallarAffordanceObservation>>;
|
||||
}
|
||||
|
||||
pub trait Clock {
|
||||
fn now(&self) -> String;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::documents::{
|
||||
GjallarAffordanceRecord, OdinDocuments, OdinRecords, OdinServiceRecord, OdinSnapshotRecord,
|
||||
OdinVerseRecord,
|
||||
GjallarOverviewRecord, GjallarOverviewTileRecord, OdinDocuments, OdinRecords,
|
||||
OdinServiceRecord, OdinSnapshotRecord, OdinVerseRecord,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use cultmesh_rs::{CultMesh, CultMeshNode, CultMeshNodeOptions};
|
||||
|
|
@ -39,11 +39,12 @@ impl CultMeshOdinRepository {
|
|||
self.node.get::<OdinVerseRecord>(verse_id)
|
||||
}
|
||||
|
||||
pub fn get_gjallar_affordance(
|
||||
&self,
|
||||
affordance_id: &str,
|
||||
) -> Result<Option<GjallarAffordanceRecord>> {
|
||||
self.node.get::<GjallarAffordanceRecord>(affordance_id)
|
||||
pub fn get_gjallar_overview(&self, overview_id: &str) -> Result<Option<GjallarOverviewRecord>> {
|
||||
self.node.get::<GjallarOverviewRecord>(overview_id)
|
||||
}
|
||||
|
||||
pub fn get_gjallar_tile(&self, tile_id: &str) -> Result<Option<GjallarOverviewTileRecord>> {
|
||||
self.node.get::<GjallarOverviewTileRecord>(tile_id)
|
||||
}
|
||||
|
||||
pub fn document_binding_version(&self, document_type: &str) -> Option<String> {
|
||||
|
|
@ -74,9 +75,6 @@ impl OdinRepository for CultMeshOdinRepository {
|
|||
for route in &records.translation_routes {
|
||||
self.node.put(&route.route_id, route)?;
|
||||
}
|
||||
for affordance in &records.gjallar_affordances {
|
||||
self.node.put(&affordance.affordance_id, affordance)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -107,8 +105,8 @@ mod tests {
|
|||
use crate::documents::ODIN_SERVICE_SCHEMA;
|
||||
use crate::pipeline::normalize_odin_records;
|
||||
use crate::ports::{
|
||||
GjallarAffordanceObservation, InterfaceObservation, ObservationStreamObservation,
|
||||
OdinInputBatch, ServiceObservation, TranslationRouteObservation, VerseObservation,
|
||||
InterfaceObservation, ObservationStreamObservation, OdinInputBatch, ServiceObservation,
|
||||
TranslationRouteObservation, VerseObservation,
|
||||
};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
|
|
@ -155,15 +153,6 @@ mod tests {
|
|||
version: "v1".to_string(),
|
||||
notes: "test".to_string(),
|
||||
}],
|
||||
gjallar_affordances: vec![GjallarAffordanceObservation {
|
||||
source_record: "odin.service:odin".to_string(),
|
||||
verse_id: Some("starfire.local".to_string()),
|
||||
surface_kind: "service".to_string(),
|
||||
action: "inspect".to_string(),
|
||||
authority: "odin".to_string(),
|
||||
status: "available".to_string(),
|
||||
provenance: "unit-test".to_string(),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -200,13 +189,6 @@ mod tests {
|
|||
reloaded.get_verse("starfire.local")?.unwrap().capabilities,
|
||||
vec!["cultmesh".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.get_gjallar_affordance("gjallar:odin.service:odin:0")?
|
||||
.unwrap()
|
||||
.action,
|
||||
"inspect"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,22 +32,24 @@ Verse / host / device / provider inputs
|
|||
-> ingest ports
|
||||
-> normalization
|
||||
-> typed Odin records
|
||||
-> Gjallar affordance records
|
||||
-> CultMesh node
|
||||
-> CultCache .cc persistence
|
||||
-> CultNet/CultMesh document registry
|
||||
-> Eve/CultUI projection and agent affordance packets
|
||||
-> Gjallar overview composition
|
||||
-> Nightwing terminal lowering
|
||||
-> Eve/CultUI projection and compact display feeds
|
||||
```
|
||||
|
||||
The first Rust core lives in `crates/odin-core`:
|
||||
|
||||
- `documents.rs`: typed Odin records and the CultMesh document set.
|
||||
- `ports.rs`: narrow ingest traits, Gjallar affordance input, plus clock
|
||||
injection for deterministic tests.
|
||||
- `ports.rs`: narrow ingest traits plus clock injection for deterministic tests.
|
||||
- `pipeline.rs`: collection and normalization from input observations to typed
|
||||
records, including `gjallar.affordance.v1`.
|
||||
Odin records.
|
||||
- `repository.rs`: `OdinRepository` abstraction, in-memory mock repository, and
|
||||
CultMesh-backed repository.
|
||||
- `gjallar.rs`: pure overview composition from Odin records to typed Gjallar
|
||||
overview/tile records.
|
||||
|
||||
The Rust spine owns the future architecture. The CommonJS daemon remains the
|
||||
legacy operational body until each organ crosses this typed boundary.
|
||||
|
|
@ -57,10 +59,10 @@ legacy operational body until each organ crosses this typed boundary.
|
|||
Odin's executable body is split by ownership:
|
||||
|
||||
- `crates/odin-core`: Rust target core. Owns typed Odin documents, ingest
|
||||
ports, Gjallar affordance records, 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.
|
||||
ports, Gjallar overview records, normalization, composition, 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.
|
||||
|
|
@ -74,16 +76,16 @@ Odin's executable body is split by ownership:
|
|||
|
||||
The entrypoint is not allowed to grow new probe, surface, provider, layout, or renderer policy. If a new owner is needed, name the owner and its invariant before adding code.
|
||||
|
||||
Gjallar is the named herald organ for agent-context transmission. In the Rust
|
||||
spine, Gjallar has a typed `gjallar.affordance.v1` record and an injected
|
||||
affordance ingest port. Its
|
||||
current package lives in `docs/gjallar.md`, `personas/gjallar.persona_state.cc`,
|
||||
`crates/gjallar-daemon`, `assets/personas/gjallar-avatar.png`, and
|
||||
`assets/personas/gjallar-avatar-pixel-256.png`. It runs as its own Rust
|
||||
CultMesh entrypoint rather than being folded into Odin's coordinator. Gjallar
|
||||
may read Odin-owned state and emit affordance
|
||||
packets; it must not own the underlying registry, probe, provider, layout, or
|
||||
translation decisions.
|
||||
Gjallar is the named overview composition daemon for what Nightwing displays.
|
||||
In the Rust spine, Gjallar has typed `gjallar.overview.v1` and
|
||||
`gjallar.overview_tile.v1` records. Its package lives in
|
||||
`crates/gjallar-daemon` and `docs/gjallar.md`. It runs as its own Rust CultMesh
|
||||
entrypoint rather than being folded into Odin's coordinator or Nightwing's TUI.
|
||||
Gjallar may read Odin-owned state and emit a packed overview feed. During the
|
||||
CommonJS-to-Rust migration it may also read Odin's existing
|
||||
`gamecult.eve.surface_state.v1` compatibility record and wrap it as a Gjallar
|
||||
tile. It must not own the underlying registry, probe, provider truth, terminal
|
||||
glyph rendering, or translation decisions.
|
||||
|
||||
Idunn is the named keepalive organ for daemon continuity. Its current Rust
|
||||
body lives in `crates/idunn-daemon` and `crates/odin-core/src/idunn.rs`. Idunn may read
|
||||
|
|
@ -143,9 +145,10 @@ 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,
|
||||
including Gjallar affordances, persist through CultMesh/CultCache and reload
|
||||
from `.cc`.
|
||||
- `compose_overview_orders_dashboard_tiles_for_nightwing`: proves Gjallar can
|
||||
turn Odin records into an ordered overview feed without daemon boot.
|
||||
- `cultmesh_repository_round_trips_typed_records`: proves typed Odin records
|
||||
persist through CultMesh/CultCache and reload from `.cc`.
|
||||
|
||||
## Service Architecture Contract
|
||||
|
||||
|
|
@ -185,7 +188,11 @@ Odin currently publishes service squares for:
|
|||
- Raven: SSH reachability.
|
||||
- Yggdrasil: SSH/HTTP/HTTPS reachability plus nginx, StreamPixels, Heimdall, Repixelizer, and Bifrost systemd state.
|
||||
|
||||
The compact Nightwing TUI lowers these into dense cells and fills surplus screen space with derived schema, route, owner, and probe facets from the same Odin-owned service records.
|
||||
Gjallar composes these into the compact overview feed. The Nightwing TUI lowers
|
||||
that feed into dense cells and fills surplus screen space according to
|
||||
Gjallar's tile ordering, spans, and density hints. If Nightwing starts deriving
|
||||
dashboard composition directly from Odin service records, the composition owner
|
||||
has leaked out of Gjallar.
|
||||
|
||||
## Current Interface Surface
|
||||
|
||||
|
|
|
|||
194
docs/gjallar.md
194
docs/gjallar.md
|
|
@ -1,144 +1,92 @@
|
|||
# Gjallar
|
||||
|
||||
Gjallar is the herald organ for Odin.
|
||||
Gjallar is the overview composition daemon for Odin.
|
||||
|
||||
Odin is the all-seer: it accepts Verse discovery, schema catalogs, translation
|
||||
routes, provider surfaces, and observation projections. Gjallar is the
|
||||
transmission daemon that turns that sight into agent-ready context: a fractal
|
||||
tapestry of affordances across every Verse this Odin can currently see.
|
||||
routes, provider surfaces, and observation projections. Gjallar reads that
|
||||
Odin-owned state and composes the dense overview feed that Nightwing displays.
|
||||
Nightwing should not run a bespoke dashboard brain; it should ask Gjallar what
|
||||
to draw and then lower that feed into fast terminal cells.
|
||||
|
||||
Gjallar should make the next capable agent less blind. It should not become a
|
||||
second Odin.
|
||||
Gjallar exists so Odin does not need to care how a pile of dashboards fits on
|
||||
one screen, and Nightwing does not need to know how Odin's world model is built.
|
||||
|
||||
## Authority Map
|
||||
|
||||
- Owner: Gjallar owns agent-context transmission of Odin-owned sight.
|
||||
- Inputs: Odin's accepted registry, schema index, translation map, provider
|
||||
interfaces, observation-stream summaries, layout hints, and provenance
|
||||
metadata.
|
||||
- Outputs: compact affordance packets for Codex, native CultMesh agents, repo
|
||||
Faces, Bifrost, and other agent consumers; optional Persona-facing memory
|
||||
summaries that name what Odin can see and what can be acted on.
|
||||
- Derived state: summaries, prompts, Persona projections, Bifrost speech
|
||||
requests, Discord lowerings, and handoff notes are notification-only
|
||||
projections of Odin state.
|
||||
- Forbidden writers: Gjallar does not probe hosts, decide Verse truth, mutate
|
||||
provider-owned dashboards, own renderer layout, or invent schema translation
|
||||
routes.
|
||||
- Shared paths: human dashboards, Codex context assembly, native CultMesh agent
|
||||
bootstrap, Bifrost-routed Persona speech, and compatibility MCP access must
|
||||
consume the same Odin-owned records before Gjallar speaks.
|
||||
- Deletion line: any Gjallar path that starts storing independent discovery
|
||||
truth should be cut or demoted to a cache that names Odin as source.
|
||||
- Owner: Gjallar owns overview composition for Odin sight.
|
||||
- Inputs: Odin's accepted snapshot, Verse records, service records, interface
|
||||
records, observation stream records, translation route records, and explicit
|
||||
operator/Nightwing display constraints.
|
||||
- Outputs: typed `gjallar.overview.v1` and `gjallar.overview_tile.v1` records:
|
||||
overview metadata, tile ordering, density hints, source pointers, status,
|
||||
labels, and compact detail text for Nightwing.
|
||||
- Derived state: tile priority, grouping, target rows/columns, row/column spans,
|
||||
visual emphasis hints, and compact summaries are derived from Odin state.
|
||||
- Forbidden writers: Gjallar does not probe hosts, accept Verse truth, mutate
|
||||
provider-owned dashboards, own terminal glyph rendering, or invent schema
|
||||
translation routes.
|
||||
- Shared paths: Nightwing TUI, browser previews, future compact overlays, and
|
||||
agent summaries consume the same Gjallar overview feed when they want the
|
||||
"everything Odin sees" view.
|
||||
- Deletion line: any Nightwing path that independently composes Odin dashboards
|
||||
should move into Gjallar; any Gjallar path that decides discovery truth should
|
||||
move back into Odin.
|
||||
|
||||
## Body
|
||||
|
||||
The initial local package is deliberately small:
|
||||
- `crates/gjallar-daemon` is Gjallar's Rust CultMesh daemon.
|
||||
- `crates/odin-core/src/gjallar.rs` owns the pure composition function so unit
|
||||
tests can exercise packing decisions without booting a daemon.
|
||||
- `gjallar.overview.v1` describes the feed Nightwing asks for.
|
||||
- `gjallar.overview_tile.v1` describes each drawable tile and names its Odin
|
||||
source record.
|
||||
- `assets/personas/gjallar-avatar.png` and
|
||||
`assets/personas/gjallar-avatar-pixel-256.png` remain branding assets for the
|
||||
view/persona surface.
|
||||
|
||||
- `personas/gjallar.persona_state.cc` is the canonical repo-local Persona
|
||||
state: a CultCache MessagePack store containing a `gamecult.persona_state.v0`
|
||||
document keyed as `persona:gjallar`.
|
||||
- `scripts/write-gjallar-persona-cc.mjs` mints and inspects that store from the
|
||||
canonical EpiphanyAgent schema. The script is a local writer, not Gjallar's
|
||||
runtime body.
|
||||
- `crates/gjallar-daemon` is Gjallar's Rust CultMesh organ. It requires the
|
||||
Persona `.cc` source and publishes typed `gjallar.affordance.v1` records
|
||||
through Odin's document set.
|
||||
- `assets/personas/gjallar-avatar.png` is the local avatar asset.
|
||||
- `assets/personas/gjallar-avatar-pixel-256.png` is the 256px pixel-art avatar
|
||||
variant for compact Persona surfaces.
|
||||
- This document is the organ contract for the runtime daemon.
|
||||
|
||||
Gjallar is executable as its own Rust CultMesh runtime beside Odin. Do not fold
|
||||
it into `src/odin-coordinator.cjs`. The coordinator already owns lifecycle,
|
||||
refresh, persistence, and transport for Odin. Gjallar's entrypoint owns context
|
||||
publication and no more.
|
||||
|
||||
## Persona Registration
|
||||
|
||||
VoidBot's current native Persona reader expects a JSON `personaStatePath`. That
|
||||
is a compatibility projection boundary, not the state owner and not the
|
||||
transport owner. Any registry entry must point at a generated projection or
|
||||
learn to read the CultCache record; the canonical source remains:
|
||||
|
||||
```text
|
||||
id: gjallar
|
||||
repoName: Odin
|
||||
displayName: Gjallar
|
||||
repoPath: E:\Projects\Odin
|
||||
avatarPath: E:\Projects\Odin\assets\personas\gjallar-avatar.png
|
||||
pixelAvatarPath: E:\Projects\Odin\assets\personas\gjallar-avatar-pixel-256.png
|
||||
personaStateStore: E:\Projects\Odin\personas\gjallar.persona_state.cc
|
||||
personaStateKey: persona:gjallar
|
||||
personaStateSchema: gamecult.persona_state.v0
|
||||
```
|
||||
|
||||
That compatibility registration belongs in the current Discord adapter, not
|
||||
here. This repo owns the Persona source record and avatar. Bifrost owns the
|
||||
CultMesh speech crossing when a Persona interpreter decides it wants to speak.
|
||||
Discord is then a Bifrost lowering/receipt path, with VoidBot acting only as
|
||||
legacy room cognition and compatibility delivery machinery.
|
||||
|
||||
## Transport Boundary
|
||||
|
||||
Persona speech is a bridge crossing. The interpreter may decide that a Face
|
||||
speaks, but the public transport request is a Bifrost CultMesh command or
|
||||
document, not a VoidBot-owned side effect.
|
||||
|
||||
Target shape:
|
||||
|
||||
```text
|
||||
Persona runtime / interpreter
|
||||
-> Bifrost speech request through CultMesh
|
||||
-> Bifrost policy, Heimdall claims, topic/work/context linkage
|
||||
-> Discord, GitHub, owner DM, or future public surface lowering
|
||||
-> Bifrost receipt
|
||||
```
|
||||
|
||||
VoidBot owns Discord observation, room cognition, moderation judgment, and
|
||||
compatibility delivery. Its repo search, Discord history search, and
|
||||
archive/source retrieval are required native CultCache/CultMesh services. Any
|
||||
remaining VoidBot-local or MCP-only implementation is migration debt. MCP is a
|
||||
bridge for external agentic access, not the native affordance path for GameCult
|
||||
agents that can speak CultMesh.
|
||||
|
||||
## CultMesh Runtime Body
|
||||
|
||||
Gjallar's runtime body is Rust in `crates/gjallar-daemon`:
|
||||
Run one composition pass from the repo root:
|
||||
|
||||
```powershell
|
||||
cargo run -p gjallar-daemon --
|
||||
```
|
||||
|
||||
The current Rust entrypoint requires the local `persona:gjallar` CultCache
|
||||
source and publishes a typed `gjallar.affordance.v1` packet through CultMesh.
|
||||
It does not decode Persona as JSON, does not own discovery truth, and does not
|
||||
pretend the Persona store is its runtime state.
|
||||
By default, Gjallar reads Odin's local CultMesh store at
|
||||
`scratch/odin/odin.ccmp` and writes its feed to
|
||||
`scratch/gjallar/gjallar.overview.ccmp`.
|
||||
|
||||
## Runtime Contract
|
||||
|
||||
```text
|
||||
Odin typed state
|
||||
-> Gjallar overview composition
|
||||
-> gjallar.overview / gjallar.overview_tile CultMesh records
|
||||
-> Nightwing terminal lowerer
|
||||
```
|
||||
|
||||
Nightwing owns terminal speed, key handling, glyph selection, color lowering,
|
||||
and screen refresh. Gjallar owns what should be on the overview and how densely
|
||||
it should be packed. Odin owns the facts being summarized.
|
||||
|
||||
## Current Cut
|
||||
|
||||
The current Rust daemon has two input paths:
|
||||
|
||||
- Preferred: read Rust typed Odin records such as `odin.snapshot.v1`,
|
||||
`odin.service.v1`, and `odin.interface.v1` from an Odin CultMesh store.
|
||||
- Compatibility: read Odin's current CommonJS
|
||||
`gamecult.eve.surface_state.v1` record as a raw MessagePack map, then wrap it
|
||||
as a typed Gjallar overview tile.
|
||||
|
||||
The compatibility path exists so Gjallar can talk to today's Odin without
|
||||
pretending the migration is complete. The next improvement is for Odin to
|
||||
publish a typed index/catalog document that Gjallar can read instead of carrying
|
||||
configured key lists.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Gjallar is a herald, not a second source of truth.
|
||||
- Odin remains the accepted owner of all-seer state.
|
||||
- Verse owners remain owners of their local schemas, command boundaries, and
|
||||
provider surfaces.
|
||||
- Agent consumers receive provenance: what Odin saw, where it came from, and
|
||||
whether it is authoritative, stale, predicted, denied, or unavailable.
|
||||
- If Gjallar cannot name the Odin record behind a claim, the claim is not ready
|
||||
for durable transmission.
|
||||
|
||||
## First Affordance Packet Shape
|
||||
|
||||
Keep the first packet plain until the schema earns promotion:
|
||||
|
||||
```text
|
||||
source: Odin state document or Eve/CultUI provider record
|
||||
verse: Verse or provider id
|
||||
surface: service, interface, observation-stream, schema, or translation route
|
||||
affordance: what an agent can inspect, request, invoke, or lower
|
||||
authority: owner of the real decision
|
||||
status: authoritative, stale, predicted, denied, unavailable, or unknown
|
||||
provenance: endpoint, document id, timestamp, and version when available
|
||||
```
|
||||
|
||||
No regex tribunal for meaning. If natural-language interpretation is needed,
|
||||
use a capable reader and keep the accepted packet inspectable.
|
||||
- Gjallar owns overview composition, not discovery truth.
|
||||
- Nightwing owns terminal lowering, not dashboard composition.
|
||||
- Every tile names the Odin source record behind it.
|
||||
- Missing Odin records disappear from the feed rather than becoming invented
|
||||
truth.
|
||||
|
|
|
|||
Loading…
Reference in a new issue