Move Gjallar daemon body to Rust
This commit is contained in:
parent
a73950a7fe
commit
113d5bf5e5
10 changed files with 201 additions and 142 deletions
9
Cargo.lock
generated
9
Cargo.lock
generated
|
|
@ -356,6 +356,15 @@ dependencies = [
|
|||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gjallar-daemon"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cultmesh-rs",
|
||||
"odin-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"crates/gjallar-daemon",
|
||||
"crates/idunn-daemon",
|
||||
"crates/odin-core",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ Local package surfaces:
|
|||
|
||||
- Organ contract: `docs/gjallar.md`
|
||||
- Canonical Persona state: `personas/gjallar.persona_state.cc`
|
||||
- C# CultMesh organ: `src/Gjallar/Gjallar.csproj`
|
||||
- Rust CultMesh organ: `crates/gjallar-daemon`
|
||||
- Avatar asset: `assets/personas/gjallar-avatar.png`
|
||||
- Pixel avatar: `assets/personas/gjallar-avatar-pixel-256.png`
|
||||
|
||||
|
|
|
|||
15
crates/gjallar-daemon/Cargo.toml
Normal file
15
crates/gjallar-daemon/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "gjallar-daemon"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "gjallar"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
cultmesh-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultmesh-rs" }
|
||||
odin-core = { path = "../odin-core" }
|
||||
153
crates/gjallar-daemon/src/main.rs
Normal file
153
crates/gjallar-daemon/src/main.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
use anyhow::{Context, Result, anyhow};
|
||||
use cultmesh_rs::{CultMesh, CultMeshNodeOptions};
|
||||
use odin_core::{GjallarAffordanceRecord, OdinDocuments};
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
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>,
|
||||
interval_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let options = Options::parse(env::args().skip(1))?;
|
||||
|
||||
if let Some(parent) = options.store_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
|
||||
if let Some(interval_seconds) = options.interval_seconds {
|
||||
loop {
|
||||
run_cycle(&options)?;
|
||||
thread::sleep(Duration::from_secs(interval_seconds));
|
||||
}
|
||||
}
|
||||
|
||||
run_cycle(&options)
|
||||
}
|
||||
|
||||
fn run_cycle(options: &Options) -> Result<()> {
|
||||
if !options.persona_store_path.exists() {
|
||||
return Err(anyhow!(
|
||||
"Gjallar Persona CultCache store not found: {}",
|
||||
options.persona_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 mut node = CultMesh::create_node(
|
||||
&options.store_path,
|
||||
OdinDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: "gjallar-daemon".to_string(),
|
||||
pull_on_start: true,
|
||||
},
|
||||
)?;
|
||||
|
||||
node.put(&affordance.affordance_id, &affordance)?;
|
||||
|
||||
println!("Gjallar published {}", affordance.affordance_id);
|
||||
println!("CultMesh store: {}", options.store_path.display());
|
||||
println!("Persona source: {}", options.persona_store_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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
|
||||
.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()),
|
||||
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")?)
|
||||
}
|
||||
"--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")?
|
||||
.parse()
|
||||
.context("--interval-seconds must be a positive integer")?,
|
||||
)
|
||||
}
|
||||
"--help" | "-h" => return Err(anyhow!(help_text())),
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"unknown Gjallar argument: {other}\n\n{}",
|
||||
help_text()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if options.persona_key.trim().is_empty() {
|
||||
return Err(anyhow!("--persona-key must not be empty"));
|
||||
}
|
||||
if options.interval_seconds == Some(0) {
|
||||
return Err(anyhow!("--interval-seconds must be greater than zero"));
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
}
|
||||
|
||||
fn take_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn find_repo_root(start: PathBuf) -> PathBuf {
|
||||
for candidate in start.ancestors() {
|
||||
if candidate.join("Cargo.toml").exists() && candidate.join("personas").is_dir() {
|
||||
return candidate.to_path_buf();
|
||||
}
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
fn timestamp() -> Result<String> {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("system clock is before Unix epoch")?
|
||||
.as_secs();
|
||||
Ok(format!("unix:{seconds}"))
|
||||
}
|
||||
|
||||
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."
|
||||
}
|
||||
|
|
@ -78,10 +78,10 @@ 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`,
|
||||
`src/Gjallar/Gjallar.csproj`, `assets/personas/gjallar-avatar.png`, and
|
||||
`assets/personas/gjallar-avatar-pixel-256.png`. When it becomes executable, it
|
||||
must be added as its own C# CultMesh entrypoint rather than being folded into
|
||||
Odin's coordinator. Gjallar may read Odin-owned state and emit affordance
|
||||
`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.
|
||||
|
||||
|
|
|
|||
|
|
@ -41,18 +41,18 @@ The initial local package is deliberately small:
|
|||
- `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.
|
||||
- `src/Gjallar/Gjallar.csproj` is the first C# CultMesh organ. It opens the
|
||||
Persona `.cc` store through `CultMesh.CreateNodeAsync`; typed Persona decode
|
||||
waits for a generated C# `gamecult.persona_state.v0` model.
|
||||
- `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 until a runtime daemon exists.
|
||||
- This document is the organ contract for the runtime daemon.
|
||||
|
||||
When Gjallar becomes executable, package it as its own C# CultMesh runtime
|
||||
entrypoint beside Odin rather than folding it into `src/odin-coordinator.cjs`.
|
||||
The coordinator already owns lifecycle, refresh, persistence, and transport for
|
||||
Odin. Gjallar's entrypoint should own context publication and no more.
|
||||
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
|
||||
|
||||
|
|
@ -102,22 +102,18 @@ 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 Direction
|
||||
## CultMesh Runtime Body
|
||||
|
||||
Gjallar's runtime body should be C# over `GameCult.Mesh`:
|
||||
Gjallar's runtime body is Rust in `crates/gjallar-daemon`:
|
||||
|
||||
```csharp
|
||||
using GameCult.Mesh;
|
||||
|
||||
using var node = await CultMesh.CreateNodeAsync("personas/gjallar.persona_state.cc");
|
||||
```powershell
|
||||
cargo run -p gjallar-daemon --
|
||||
```
|
||||
|
||||
The current C# entrypoint opens the CultMesh node without pulling typed payloads
|
||||
because the C# document model for `gamecult.persona_state.v0` has not been
|
||||
generated yet. The next durable act should generate that model, read the
|
||||
`persona:gjallar` record from the local CultCache, and publish an affordance
|
||||
packet document through CultMesh. Do not make a JSON sidecar the source of truth
|
||||
while waiting for that runtime.
|
||||
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.
|
||||
|
||||
## Invariants
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
"scripts": {
|
||||
"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",
|
||||
"gjallar:build": "cargo build -p gjallar-daemon",
|
||||
"gjallar:start": "cargo run -p gjallar-daemon --",
|
||||
"idunn:build": "cargo build -p idunn-daemon",
|
||||
"idunn:start": "cargo run -p idunn-daemon --",
|
||||
"rust:test": "cargo test --workspace",
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Mesh\GameCult.Mesh.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Mesh;
|
||||
|
||||
var options = GjallarOptions.Parse(args);
|
||||
var cachePath = Path.GetFullPath(options.CachePath);
|
||||
|
||||
if (!File.Exists(cachePath))
|
||||
{
|
||||
Console.Error.WriteLine($"Gjallar Persona CultCache store not found: {cachePath}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var node = await CultMesh.CreateNodeAsync(
|
||||
cachePath,
|
||||
new CultMeshNodeOptions
|
||||
{
|
||||
StartServer = options.Serve,
|
||||
CacheOptions = new CultCacheOpenOptions
|
||||
{
|
||||
// The store is canonical now. Typed C# decode waits for a generated
|
||||
// gamecult.persona_state.v0 document model instead of pretending
|
||||
// JSON is the runtime body.
|
||||
PullOnOpen = false
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine("Gjallar CultMesh organ initialized.");
|
||||
Console.WriteLine($"Persona store: {cachePath}");
|
||||
Console.WriteLine("Persona schema: gamecult.persona_state.v0");
|
||||
Console.WriteLine("Persona key: persona:gjallar");
|
||||
Console.WriteLine($"CultMesh server: {(options.Serve ? "started" : "disabled")}");
|
||||
Console.WriteLine("Typed C# Persona decode/publication is the next cut after the schema model is generated.");
|
||||
|
||||
if (!options.Serve)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var stopping = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
stopping.TrySetResult();
|
||||
};
|
||||
|
||||
Console.WriteLine("Press Ctrl+C to stop Gjallar.");
|
||||
await stopping.Task.ConfigureAwait(false);
|
||||
return 0;
|
||||
|
||||
internal sealed record GjallarOptions(string CachePath, bool Serve)
|
||||
{
|
||||
public static GjallarOptions Parse(string[] args)
|
||||
{
|
||||
var repoRoot = FindRepoRoot(Directory.GetCurrentDirectory());
|
||||
var cachePath = Path.Combine(repoRoot, "personas", "gjallar.persona_state.cc");
|
||||
var serve = false;
|
||||
|
||||
for (var index = 0; index < args.Length; index++)
|
||||
{
|
||||
switch (args[index])
|
||||
{
|
||||
case "--serve":
|
||||
serve = true;
|
||||
break;
|
||||
case "--cache" when index + 1 < args.Length:
|
||||
cachePath = args[++index];
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
PrintUsage();
|
||||
Environment.Exit(0);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown Gjallar argument: {args[index]}");
|
||||
}
|
||||
}
|
||||
|
||||
return new GjallarOptions(cachePath, serve);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot(string start)
|
||||
{
|
||||
var current = new DirectoryInfo(start);
|
||||
while (current != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "package.json")) &&
|
||||
Directory.Exists(Path.Combine(current.FullName, "personas")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return start;
|
||||
}
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("Gjallar");
|
||||
Console.WriteLine(" --cache <path> Persona CultCache store path.");
|
||||
Console.WriteLine(" --serve Start the CultMesh server instead of only opening the node.");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue