diff --git a/Cargo.lock b/Cargo.lock index 5cae94a..8258564 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -494,6 +494,15 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "muninn-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "cultmesh-rs", + "odin-core", +] + [[package]] name = "num-traits" version = "0.2.19" diff --git a/Cargo.toml b/Cargo.toml index 71ad6fd..e73a4eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/idunn-daemon", + "crates/muninn-daemon", "crates/odin-core", ] resolver = "3" diff --git a/crates/idunn-daemon/src/main.rs b/crates/idunn-daemon/src/main.rs index d4c61d3..5594d74 100644 --- a/crates/idunn-daemon/src/main.rs +++ b/crates/idunn-daemon/src/main.rs @@ -306,7 +306,10 @@ fn run_operator_alarm_command(options: &Options, alarm: &IdunnOperatorAlarmRecor match output { Ok(output) if output.status.success() => { - println!("Idunn operator alarm command completed for {}.", alarm.daemon_id); + println!( + "Idunn operator alarm command completed for {}.", + alarm.daemon_id + ); } Ok(output) => { eprintln!( diff --git a/crates/muninn-daemon/Cargo.toml b/crates/muninn-daemon/Cargo.toml new file mode 100644 index 0000000..528a6d7 --- /dev/null +++ b/crates/muninn-daemon/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "muninn-daemon" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "muninn" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +cultmesh-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultmesh-rs" } +odin-core = { path = "../odin-core" } diff --git a/crates/muninn-daemon/src/main.rs b/crates/muninn-daemon/src/main.rs new file mode 100644 index 0000000..53e63a9 --- /dev/null +++ b/crates/muninn-daemon/src/main.rs @@ -0,0 +1,578 @@ +use anyhow::{Context, Result, anyhow}; +use cultmesh_rs::{CultMesh, CultMeshNodeOptions}; +use odin_core::{MuninnCaptureStreamRecord, MuninnTelemetrySurfaceRecord, OdinDocuments}; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Mode { + Serve, + Activate, + Health, + DryRun, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Options { + mode: Mode, + store_path: PathBuf, + surface_id: String, + stream_id: String, + host_id: String, + target_host: String, + port: u16, + obs_target_host: Option, + obs_port: u16, + width: u32, + height: u32, + framerate: u32, + ddagrab_output_index: u32, + audio_device: String, + audio_sample_rate: u32, + audio_channels: u32, + ffmpeg_path: String, + loopback_script: PathBuf, + log_root: PathBuf, + interval_seconds: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct MuxPlan { + command_line: String, + command_file: PathBuf, + targets: Vec, +} + +trait ProcessSpawner { + fn spawn_mux(&self, plan: &MuxPlan) -> Result; +} + +struct CmdSpawner; + +impl ProcessSpawner for CmdSpawner { + fn spawn_mux(&self, plan: &MuxPlan) -> Result { + Command::new("cmd.exe") + .arg("/d") + .arg("/c") + .arg(&plan.command_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("starting mux command {}", plan.command_file.display())) + } +} + +fn main() -> Result<()> { + let options = Options::parse(env::args().skip(1))?; + match options.mode { + Mode::Serve => serve(options), + Mode::Activate => activate(options, CmdSpawner), + Mode::Health => health_check(&options), + Mode::DryRun => { + let plan = build_mux_plan(&options, "dry-run".to_string()); + println!("{}", plan.command_line); + Ok(()) + } + } +} + +fn serve(options: Options) -> Result<()> { + ensure_state_dirs(&options)?; + let mut node = open_node(&options, "muninn-daemon")?; + + loop { + publish_surface(&mut node, &options, "idle", &[])?; + if let Some(interval) = options.interval_seconds { + thread::sleep(Duration::from_secs(interval)); + } else { + return Ok(()); + } + } +} + +fn activate(options: Options, spawner: impl ProcessSpawner) -> Result<()> { + ensure_state_dirs(&options)?; + let mut node = open_node(&options, "muninn-activation")?; + let plan = build_mux_plan(&options, timestamp()?); + write_command_file(&plan)?; + + let supervisor_pid = std::process::id(); + let mut restart_count = 0; + + loop { + publish_surface( + &mut node, + &options, + "active", + std::slice::from_ref(&options.stream_id), + )?; + + let mut child = match spawner.spawn_mux(&plan) { + Ok(child) => child, + Err(error) => { + publish_stream( + &mut node, + &options, + &plan, + "failed", + supervisor_pid, + None, + restart_count, + &format!("could not start mux: {error:#}"), + )?; + return Err(error); + } + }; + + publish_stream( + &mut node, + &options, + &plan, + "running", + supervisor_pid, + Some(child.id()), + restart_count, + "requested stream is active", + )?; + + let status = child.wait().context("waiting for mux process")?; + restart_count += 1; + publish_stream( + &mut node, + &options, + &plan, + "restarting", + supervisor_pid, + None, + restart_count, + &format!("mux exited with {status}"), + )?; + thread::sleep(Duration::from_secs((2 + restart_count).min(30) as u64)); + } +} + +fn publish_surface( + node: &mut cultmesh_rs::CultMeshNode, + options: &Options, + state: &str, + active_streams: &[String], +) -> Result<()> { + let record = MuninnTelemetrySurfaceRecord { + surface_id: options.surface_id.clone(), + host_id: options.host_id.clone(), + state: state.to_string(), + available_sources: available_sources(options), + stream_affordances: vec![ + "screen.capture.ddagrab".to_string(), + "audio.loopback.wasapi".to_string(), + "audio.input.enumeration.pending".to_string(), + "video.input.enumeration.pending".to_string(), + ], + active_streams: active_streams.to_vec(), + activation_authority: "muninn.activate.explicit-request".to_string(), + detail: if active_streams.is_empty() { + "Muninn is idle; no capture streams are consuming resources.".to_string() + } else { + "Muninn has explicit active stream requests.".to_string() + }, + updated_at: timestamp()?, + }; + node.put("latest", &record)?; + node.put(&record.surface_id, &record)?; + Ok(()) +} + +fn available_sources(options: &Options) -> Vec { + vec![ + format!("screen:ddagrab:output_idx={}", options.ddagrab_output_index), + format!( + "audio-loopback:wasapi:{}:{}ch@{}", + options.audio_device, options.audio_channels, options.audio_sample_rate + ), + "sensor:microphone:enumeration-pending".to_string(), + "sensor:camera:enumeration-pending".to_string(), + ] +} + +fn publish_stream( + node: &mut cultmesh_rs::CultMeshNode, + options: &Options, + plan: &MuxPlan, + state: &str, + supervisor_pid: u32, + mux_pid: Option, + restart_count: u32, + detail: &str, +) -> Result<()> { + let record = MuninnCaptureStreamRecord { + stream_id: options.stream_id.clone(), + host_id: options.host_id.clone(), + state: state.to_string(), + video_source: format!( + "ddagrab:output_idx={}:{}x{}@{}", + options.ddagrab_output_index, options.width, options.height, options.framerate + ), + audio_source: format!( + "wasapi-loopback:{}:{}ch@{}", + options.audio_device, options.audio_channels, options.audio_sample_rate + ), + transport: "srt".to_string(), + targets: plan.targets.clone(), + command_witness: plan.command_file.display().to_string(), + supervisor_pid: Some(supervisor_pid), + mux_pid, + restart_count, + detail: detail.to_string(), + updated_at: timestamp()?, + }; + node.put("latest-stream", &record)?; + node.put(&record.stream_id, &record)?; + Ok(()) +} + +fn health_check(options: &Options) -> Result<()> { + let node = open_node(options, "muninn-health")?; + let surface = node + .get_required::("latest") + .context("Muninn telemetry surface is unavailable")?; + if surface.state == "idle" || surface.state == "active" { + println!( + "Muninn healthy: {} on {} ({})", + surface.surface_id, surface.host_id, surface.state + ); + Ok(()) + } else { + Err(anyhow!( + "Muninn telemetry surface is {}: {}", + surface.state, + surface.detail + )) + } +} + +fn build_mux_plan(options: &Options, timestamp: String) -> MuxPlan { + let command_file = options.log_root.join(format!("muninn-{timestamp}.cmd")); + let targets = build_targets(options); + + let loopback = vec![ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-ExecutionPolicy".to_string(), + "Bypass".to_string(), + "-File".to_string(), + quote_cmd(&options.loopback_script.display().to_string()), + "-Output".to_string(), + quote_cmd("stdout"), + "-SampleRate".to_string(), + options.audio_sample_rate.to_string(), + "-Channels".to_string(), + options.audio_channels.to_string(), + "-Device".to_string(), + quote_cmd(&options.audio_device), + ] + .join(" "); + + let tee_targets = targets + .iter() + .map(|target| format!("[f=mpegts]{target}")) + .collect::>() + .join("|"); + + let ffmpeg_args = vec![ + "-hide_banner".to_string(), + "-loglevel".to_string(), + "warning".to_string(), + "-thread_queue_size".to_string(), + "1024".to_string(), + "-f".to_string(), + "lavfi".to_string(), + "-i".to_string(), + format!( + "ddagrab=framerate={}:output_idx={}:draw_mouse=1", + options.framerate, options.ddagrab_output_index + ), + "-thread_queue_size".to_string(), + "1024".to_string(), + "-f".to_string(), + "f32le".to_string(), + "-ar".to_string(), + options.audio_sample_rate.to_string(), + "-ac".to_string(), + options.audio_channels.to_string(), + "-i".to_string(), + "pipe:0".to_string(), + "-map".to_string(), + "0:v:0".to_string(), + "-map".to_string(), + "1:a:0".to_string(), + "-c:v".to_string(), + "h264_nvenc".to_string(), + "-preset".to_string(), + "p4".to_string(), + "-tune".to_string(), + "ll".to_string(), + "-b:v".to_string(), + "12000k".to_string(), + "-maxrate".to_string(), + "12000k".to_string(), + "-bufsize".to_string(), + "24000k".to_string(), + "-g".to_string(), + "60".to_string(), + "-c:a".to_string(), + "aac".to_string(), + "-b:a".to_string(), + "192k".to_string(), + "-ar".to_string(), + options.audio_sample_rate.to_string(), + "-ac".to_string(), + options.audio_channels.to_string(), + "-f".to_string(), + "tee".to_string(), + tee_targets, + ]; + let command_line = format!( + "{} | {} {}", + loopback, + quote_cmd(&options.ffmpeg_path), + ffmpeg_args + .into_iter() + .map(|arg| quote_cmd(&arg)) + .collect::>() + .join(" ") + ); + + MuxPlan { + command_line, + command_file, + targets, + } +} + +fn build_targets(options: &Options) -> Vec { + let mut targets = vec![srt_endpoint(&options.target_host, options.port)]; + if let Some(host) = &options.obs_target_host { + targets.push(srt_endpoint(host, options.obs_port)); + } + targets +} + +fn srt_endpoint(host: &str, port: u16) -> String { + format!("srt://{host}:{port}?mode=caller&latency=120000&timeout=30000000") +} + +fn write_command_file(plan: &MuxPlan) -> Result<()> { + fs::write( + &plan.command_file, + format!("@echo off\r\n{}\r\n", plan.command_line), + ) + .with_context(|| format!("writing {}", plan.command_file.display())) +} + +fn quote_cmd(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn ensure_state_dirs(options: &Options) -> Result<()> { + fs::create_dir_all(&options.log_root) + .with_context(|| format!("creating {}", options.log_root.display()))?; + if let Some(parent) = options.store_path.parent() { + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + Ok(()) +} + +fn open_node(options: &Options, runtime_id: &str) -> Result { + CultMesh::create_node( + &options.store_path, + OdinDocuments, + CultMeshNodeOptions { + runtime_id: runtime_id.to_string(), + pull_on_start: true, + }, + ) + .context("opening Muninn CultMesh store") +} + +impl Options { + fn parse(args: impl Iterator) -> Result { + let mut options = Options { + mode: Mode::Serve, + store_path: PathBuf::from("C:/Meta/Odin/state/muninn.telemetry.cc"), + surface_id: "muninn.telemetry.local".to_string(), + stream_id: "muninn.raven.av.srt".to_string(), + host_id: "raven".to_string(), + target_host: "10.77.0.2".to_string(), + port: 5200, + obs_target_host: Some("10.77.0.2".to_string()), + obs_port: 5204, + width: 1920, + height: 1080, + framerate: 30, + ddagrab_output_index: 0, + audio_device: "Realtek".to_string(), + audio_sample_rate: 48000, + audio_channels: 2, + ffmpeg_path: "ffmpeg".to_string(), + loopback_script: PathBuf::from("scripts/wasapi-loopback-capture.ps1"), + log_root: PathBuf::from("C:/Meta/Odin/logs/muninn"), + interval_seconds: None, + }; + + let mut args = args.peekable(); + while let Some(arg) = args.next() { + match arg.as_str() { + "serve" => options.mode = Mode::Serve, + "activate" => options.mode = Mode::Activate, + "--health" => options.mode = Mode::Health, + "--dry-run" => options.mode = Mode::DryRun, + "--store" => options.store_path = PathBuf::from(take_value(&mut args, "--store")?), + "--surface" => options.surface_id = take_value(&mut args, "--surface")?, + "--stream" => options.stream_id = take_value(&mut args, "--stream")?, + "--host" => options.host_id = take_value(&mut args, "--host")?, + "--target-host" => options.target_host = take_value(&mut args, "--target-host")?, + "--port" => options.port = take_value(&mut args, "--port")?.parse()?, + "--obs-target-host" => { + options.obs_target_host = Some(take_value(&mut args, "--obs-target-host")?) + } + "--no-obs-target" => options.obs_target_host = None, + "--obs-port" => options.obs_port = take_value(&mut args, "--obs-port")?.parse()?, + "--width" => options.width = take_value(&mut args, "--width")?.parse()?, + "--height" => options.height = take_value(&mut args, "--height")?.parse()?, + "--framerate" => { + options.framerate = take_value(&mut args, "--framerate")?.parse()? + } + "--ddagrab-output-index" => { + options.ddagrab_output_index = + take_value(&mut args, "--ddagrab-output-index")?.parse()? + } + "--audio-device" => options.audio_device = take_value(&mut args, "--audio-device")?, + "--audio-sample-rate" => { + options.audio_sample_rate = + take_value(&mut args, "--audio-sample-rate")?.parse()? + } + "--audio-channels" => { + options.audio_channels = take_value(&mut args, "--audio-channels")?.parse()? + } + "--ffmpeg" => options.ffmpeg_path = take_value(&mut args, "--ffmpeg")?, + "--loopback-script" => { + options.loopback_script = + PathBuf::from(take_value(&mut args, "--loopback-script")?) + } + "--log-root" => { + options.log_root = PathBuf::from(take_value(&mut args, "--log-root")?) + } + "--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 Muninn argument: {other}\n\n{}", + help_text() + )); + } + } + } + + 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, name: &str) -> Result { + args.next() + .ok_or_else(|| anyhow!("{name} requires a value")) +} + +fn timestamp() -> Result { + 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: muninn [serve|activate] [--store ] [--target-host ] [--port ] [--obs-target-host ] [--obs-port ] [--loopback-script ] [--ffmpeg ] [--dry-run] [--health]\n\nMuninn is Odin's portable telemetry Verse assembler. serve publishes cheap typed telemetry affordances; activate starts an explicitly requested local stream." +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serve_is_default_and_does_not_activate_streams() { + let options = Options::parse([].into_iter()).unwrap(); + + assert_eq!(options.mode, Mode::Serve); + assert!(options.interval_seconds.is_none()); + } + + #[test] + fn builds_two_srt_targets_for_explicit_activation() { + let options = Options::parse( + [ + "activate", + "--target-host", + "10.77.0.2", + "--port", + "5200", + "--obs-target-host", + "10.77.0.2", + "--obs-port", + "5204", + ] + .into_iter() + .map(String::from), + ) + .unwrap(); + + let plan = build_mux_plan(&options, "test".to_string()); + + assert_eq!(plan.targets.len(), 2); + assert!(plan.command_line.contains("ddagrab=framerate=30")); + assert!(plan.command_line.contains("srt://10.77.0.2:5200")); + assert!(plan.command_line.contains("srt://10.77.0.2:5204")); + } + + #[test] + fn can_disable_obs_target_for_partial_activation_smoke() { + let options = Options::parse( + ["activate", "--no-obs-target"] + .into_iter() + .map(String::from), + ) + .unwrap(); + let plan = build_mux_plan(&options, "test".to_string()); + + assert_eq!( + plan.targets, + vec!["srt://10.77.0.2:5200?mode=caller&latency=120000&timeout=30000000"] + ); + } + + #[test] + fn idle_surface_advertises_affordances_without_active_streams() { + let options = Options::parse([].into_iter()).unwrap(); + let sources = available_sources(&options); + + assert!(sources.iter().any(|source| source.starts_with("screen:"))); + assert!( + sources + .iter() + .any(|source| source.starts_with("audio-loopback:")) + ); + } +} diff --git a/crates/odin-core/src/documents.rs b/crates/odin-core/src/documents.rs index d9a6dff..0bb994b 100644 --- a/crates/odin-core/src/documents.rs +++ b/crates/odin-core/src/documents.rs @@ -17,6 +17,8 @@ pub const IDUNN_KEEPALIVE_DECISION_SCHEMA: &str = "idunn.keepalive_decision.v1"; pub const IDUNN_RESTART_REQUEST_SCHEMA: &str = "idunn.restart_request.v1"; pub const IDUNN_RESTART_RESULT_SCHEMA: &str = "idunn.restart_result.v1"; pub const IDUNN_OPERATOR_ALARM_SCHEMA: &str = "idunn.operator_alarm.v1"; +pub const MUNINN_TELEMETRY_SURFACE_SCHEMA: &str = "muninn.telemetry_surface.v1"; +pub const MUNINN_CAPTURE_STREAM_SCHEMA: &str = "muninn.capture_stream.v1"; #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "odin.snapshot", schema = "odin.snapshot.v1")] @@ -288,6 +290,63 @@ pub struct IdunnOperatorAlarmRecord { pub raised_at: String, } +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "muninn.capture_stream", schema = "muninn.capture_stream.v1")] +pub struct MuninnCaptureStreamRecord { + #[cultcache(key = 0)] + pub stream_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub state: String, + #[cultcache(key = 3)] + pub video_source: String, + #[cultcache(key = 4)] + pub audio_source: String, + #[cultcache(key = 5)] + pub transport: String, + #[cultcache(key = 6)] + pub targets: Vec, + #[cultcache(key = 7)] + pub command_witness: String, + #[cultcache(key = 8)] + pub supervisor_pid: Option, + #[cultcache(key = 9)] + pub mux_pid: Option, + #[cultcache(key = 10)] + pub restart_count: u32, + #[cultcache(key = 11)] + pub detail: String, + #[cultcache(key = 12)] + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "muninn.telemetry_surface", + schema = "muninn.telemetry_surface.v1" +)] +pub struct MuninnTelemetrySurfaceRecord { + #[cultcache(key = 0)] + pub surface_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub state: String, + #[cultcache(key = 3)] + pub available_sources: Vec, + #[cultcache(key = 4)] + pub stream_affordances: Vec, + #[cultcache(key = 5)] + pub active_streams: Vec, + #[cultcache(key = 6)] + pub activation_authority: String, + #[cultcache(key = 7)] + pub detail: String, + #[cultcache(key = 8)] + pub updated_at: String, +} + cultmesh_rs::cultmesh_documents!(OdinDocuments { OdinSnapshotRecord => ODIN_SNAPSHOT_SCHEMA, OdinVerseRecord => ODIN_VERSE_SCHEMA, @@ -305,6 +364,8 @@ cultmesh_rs::cultmesh_documents!(OdinDocuments { IdunnRestartRequestRecord => IDUNN_RESTART_REQUEST_SCHEMA, IdunnRestartResultRecord => IDUNN_RESTART_RESULT_SCHEMA, IdunnOperatorAlarmRecord => IDUNN_OPERATOR_ALARM_SCHEMA, + MuninnTelemetrySurfaceRecord => MUNINN_TELEMETRY_SURFACE_SCHEMA, + MuninnCaptureStreamRecord => MUNINN_CAPTURE_STREAM_SCHEMA, }); #[derive(Clone, Debug, Default, PartialEq, Eq)] diff --git a/docs/architecture.md b/docs/architecture.md index d028f2f..6cda721 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,8 +97,24 @@ schema truth, provider dashboards, identity grants, Discord delivery, owner-DM delivery, or renderer layout. Keepalive loops belong in Idunn, not Odin's coordinator or individual daemons. +Muninn is the portable local telemetry Verse assembler. Its Rust body lives in +`crates/muninn-daemon` and publishes `muninn.telemetry_surface.v1` through +CultMesh/CultCache. Muninn may run on Raven, Nightwing, Starfire, or any future +device body. It names locally accessible telemetry affordances: screen capture, +loopback audio, microphones, cameras, and future sensors. Muninn does not start +expensive capture streams merely because the daemon is alive. The default +`serve` posture publishes an idle typed surface; explicit activation, such as +`muninn activate` for Raven A/V over SRT, is the only path that starts FFmpeg, +WASAPI loopback, video capture, or similar resource-consuming workers. + +Muninn owns local telemetry discovery and stream activation boundaries. It does +not own Mimir's normalized ingest ledger, OBS rendering, Gjallar composition, +Odin discovery truth, or Idunn keepalive policy. Active stream records such as +`muninn.capture_stream.v1` are evidence of requested streams, not permission for +startup to burn capture resources. + Bifrost is the bridge for Persona speech and other public/owner-facing -crossings. When a Persona interpreter decides a Face speaks, the accepted +crossings. When a Persona interpreter decides a Persona speaks, the accepted side effect is a Bifrost CultMesh command or document that names actor, authority, target surface, context, policy result, and receipt path. VoidBot observes Discord, preserves room cognition, moderates, and may provide @@ -124,6 +140,8 @@ Verse announcement - Odin owns the accepted registry of known Verses. - A Verse owns its own schemas and authority model; Odin indexes and translates, it does not silently rewrite local truth. - Device clients own sensor and media capture; Mimir owns the normalized ingest ledger; Odin owns the aggregate operator projection. +- Muninn advertises local telemetry affordances cheaply and starts capture only + after an explicit activation request. - Translation paths must name source schema, target schema, lossiness, authority, and version. - Service presentation flows are CultMesh/Eve/CultUI interface projections. Odin aggregates those projection graphs; it does not replace them with nameplate summaries. - Renderers lower surfaces only. If a renderer fixes network truth, the machine is split-brained. diff --git a/docs/idunn.md b/docs/idunn.md index 96f087c..42df835 100644 --- a/docs/idunn.md +++ b/docs/idunn.md @@ -49,9 +49,8 @@ Idunn now shares Odin's Rust body: - `src/Idunn/README.md` is the user-facing introduction for developers, operators, and daemon authors. - `scripts/start-idunn-local.ps1` starts resident local watchdogs, including - VoidBot through `health-voidbot.cmd` and `restart-voidbot.cmd`, and Raven's - capture mux through `health-raven-capture-mux.cmd` and - `restart-raven-capture-mux.cmd`. + VoidBot through `health-voidbot.cmd` and `restart-voidbot.cmd`, and Muninn + through `health-muninn.cmd` and `restart-muninn.cmd`. - `scripts/notify-idunn-operator-alarm.ps1` is the local operator crossing: Idunn invokes it only after raising an operator alarm, and it asks Bifrost to publish a typed `gamecult.operator_dm_request.v1` CultMesh command document @@ -104,10 +103,13 @@ idunn.operator_alarm.v1 environment variables to `E:\Projects\Bifrost\tools\operator-notification.mjs publish-idunn-alarm`. Bifrost publishes the typed CultMesh command document; Idunn only decides that an alarm exists. -- Raven capture continuity is a remote keepalive path. Idunn probes - `http://127.0.0.1:8801/health` through SSH to Raven's WireGuard address - `10.77.0.4` and restarts through Mimir's `start-raven-daemon.ps1` with - Mimir and OBS SRT targets declared separately. +- Muninn continuity on Raven is a remote keepalive path. Idunn probes the + deployed Odin `muninn.exe --health` through the `raven` SSH alias and + restarts Muninn's idle `serve` posture when needed. Raven is the host/body; + Muninn is the local telemetry Verse assembler. Idunn must not activate + screen/audio streams as part of keepalive. Raven A/V over SRT is an explicit + activation path through `activate-muninn-raven-av-srt.ps1`, not daemon + startup behavior. ## First Runtime Direction diff --git a/docs/muninn.md b/docs/muninn.md new file mode 100644 index 0000000..26b0a6e --- /dev/null +++ b/docs/muninn.md @@ -0,0 +1,40 @@ +# Muninn + +Muninn is Odin's portable local telemetry Verse assembler. + +It runs on a device body and publishes a typed CultMesh/CultCache surface for +what that body can sense or expose locally: screen capture, loopback audio, +microphones, cameras, and future sensors. + +## Authority + +- Owner: Muninn owns local telemetry discovery, stream affordance publication, + and explicit activation of requested local streams. +- Inputs: cheap local probes, operator or Verse activation requests, and local + capture tools such as FFmpeg or WASAPI helpers. +- Outputs: `muninn.telemetry_surface.v1` and active `muninn.capture_stream.v1` + records. +- Forbidden writers: daemon startup, Idunn keepalive, Mimir ingest, OBS, Odin, + Gjallar, and renderer bodies must not start capture by implication. + +## Runtime + +```powershell +cargo build -p muninn-daemon +muninn serve --store C:\Meta\Odin\state\muninn.telemetry.cc --interval-seconds 15 +muninn --health --store C:\Meta\Odin\state\muninn.telemetry.cc +``` + +`serve` is cheap and idle. It publishes affordances and keeps the local Verse +surface fresh without starting FFmpeg, screen capture, microphone capture, or +loopback capture. + +Activation is explicit: + +```powershell +.\scripts\activate-muninn-raven-av-srt.ps1 +``` + +That Raven activation starts the requested screen and Realtek loopback stream +and publishes `muninn.capture_stream.v1`. Mimir and OBS are stream consumers; +they do not own Muninn. diff --git a/package.json b/package.json index 7ab5cbe..10d4ae0 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "gjallar:start": "cargo run -p gjallar-daemon --", "idunn:build": "cargo build -p idunn-daemon", "idunn:start": "cargo run -p idunn-daemon --", + "muninn:build": "cargo build -p muninn-daemon", + "muninn:start": "cargo run -p muninn-daemon --", "rust:test": "cargo test --workspace", "check": "node scripts/check-syntax.cjs", "start": "node src/odin-coordinator.cjs" diff --git a/scripts/activate-muninn-raven-av-srt.cmd b/scripts/activate-muninn-raven-av-srt.cmd new file mode 100644 index 0000000..badf485 --- /dev/null +++ b/scripts/activate-muninn-raven-av-srt.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\activate-muninn-raven-av-srt.ps1 diff --git a/scripts/activate-muninn-raven-av-srt.ps1 b/scripts/activate-muninn-raven-av-srt.ps1 new file mode 100644 index 0000000..484e6dd --- /dev/null +++ b/scripts/activate-muninn-raven-av-srt.ps1 @@ -0,0 +1,45 @@ +param( + [string] $RavenHost = "raven", + [string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn.exe", + [string] $StorePath = "C:\Meta\Odin\state\muninn.telemetry.cc", + [string] $LoopbackScript = "C:\Meta\Odin\Muninn\scripts\wasapi-loopback-capture.ps1", + [string] $LogRoot = "C:\Meta\Odin\logs\muninn", + [string] $Ffmpeg = "C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe", + [string] $TargetHost = "10.77.0.2", + [int] $Port = 5200, + [string] $ObsTargetHost = "10.77.0.2", + [int] $ObsPort = 5204, + [string] $AudioDevice = "Realtek" +) + +$ErrorActionPreference = "Stop" + +$remoteScript = @" +`$ErrorActionPreference = "Stop" +if (-not (Test-Path -LiteralPath "$MuninnExe")) { + throw "Muninn executable not found at $MuninnExe" +} +if (-not (Test-Path -LiteralPath "$LoopbackScript")) { + throw "Muninn loopback script not found at $LoopbackScript" +} +New-Item -ItemType Directory -Force -Path "$LogRoot" | Out-Null +New-Item -ItemType Directory -Force -Path (Split-Path -Parent "$StorePath") | Out-Null +Get-CimInstance Win32_Process | + Where-Object { (`$_.Name -ieq "muninn.exe" -and `$_.CommandLine -like "*activate*") -or (`$_.Name -ieq "ffmpeg.exe" -and `$_.CommandLine -like "*srt://$TargetHost*") -or (`$_.Name -ieq "powershell.exe" -and `$_.CommandLine -like "*wasapi-loopback-capture.ps1*") } | + ForEach-Object { taskkill.exe /PID `$_.ProcessId /T /F | Out-Null } +`$muninnDir = Split-Path -Parent "$MuninnExe" +`$launcher = Join-Path `$muninnDir "activate-raven-av-srt.cmd" +`$lines = @( + "@echo off", + "cd /d ""`$muninnDir""", + """$MuninnExe"" activate --store ""$StorePath"" --host raven --stream muninn.raven.av.srt --target-host ""$TargetHost"" --port $Port --obs-target-host ""$ObsTargetHost"" --obs-port $ObsPort --audio-device ""$AudioDevice"" --ffmpeg ""$Ffmpeg"" --loopback-script ""$LoopbackScript"" --log-root ""$LogRoot"" 1>>""$LogRoot\muninn-activate.out.log"" 2>>""$LogRoot\muninn-activate.err.log""" +) +Set-Content -LiteralPath `$launcher -Value `$lines -Encoding ASCII +cmd /c "schtasks /Delete /TN GameCult-Muninn-Activate /F 2>NUL" +cmd /c schtasks /Create /TN GameCult-Muninn-Activate /SC ONCE /ST 23:59 /TR `$launcher /IT /RL HIGHEST /F +cmd /c schtasks /Run /TN GameCult-Muninn-Activate +"@ + +$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript)) +& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $RavenHost "powershell.exe -NoProfile -EncodedCommand $encoded" +exit $LASTEXITCODE diff --git a/scripts/health-muninn.cmd b/scripts/health-muninn.cmd new file mode 100644 index 0000000..c2c911c --- /dev/null +++ b/scripts/health-muninn.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\health-muninn.ps1 diff --git a/scripts/health-muninn.ps1 b/scripts/health-muninn.ps1 new file mode 100644 index 0000000..c12dfdd --- /dev/null +++ b/scripts/health-muninn.ps1 @@ -0,0 +1,25 @@ +param( + [string] $RavenHost = "raven", + [string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn.exe", + [string] $StorePath = "C:\Meta\Odin\state\muninn.telemetry.cc" +) + +$ErrorActionPreference = "Stop" + +$remoteScript = @" +`$ErrorActionPreference = "Stop" +if (-not (Test-Path -LiteralPath "$MuninnExe")) { + throw "Muninn executable not found at $MuninnExe" +} +`$process = Get-CimInstance Win32_Process | + Where-Object { `$_.Name -ieq "muninn.exe" -and `$_.CommandLine -like "*serve*" } | + Select-Object -First 1 +if (`$null -eq `$process) { + throw "Muninn serve process is not running" +} +& "$MuninnExe" --health --store "$StorePath" +"@ + +$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript)) +& ssh.exe -o BatchMode=yes -o ConnectTimeout=5 $RavenHost "powershell.exe -NoProfile -EncodedCommand $encoded" +exit $LASTEXITCODE diff --git a/scripts/health-raven-capture-mux.cmd b/scripts/health-raven-capture-mux.cmd index 97b8482..c2c911c 100644 --- a/scripts/health-raven-capture-mux.cmd +++ b/scripts/health-raven-capture-mux.cmd @@ -1,2 +1,2 @@ @echo off -powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\health-raven-capture-mux.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\health-muninn.ps1 diff --git a/scripts/health-raven-capture-mux.ps1 b/scripts/health-raven-capture-mux.ps1 index 3386206..30947f5 100644 --- a/scripts/health-raven-capture-mux.ps1 +++ b/scripts/health-raven-capture-mux.ps1 @@ -1,9 +1,7 @@ param( - [string] $RavenHost = "10.77.0.4", - [string] $HealthUrl = "http://127.0.0.1:8801/health" + [string] $RavenHost = "raven" ) -$ErrorActionPreference = "Stop" - -& ssh.exe -o BatchMode=yes -o ConnectTimeout=5 $RavenHost "curl.exe -fsS $HealthUrl >NUL" +$script = Join-Path $PSScriptRoot "health-muninn.ps1" +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -RavenHost $RavenHost exit $LASTEXITCODE diff --git a/scripts/restart-muninn.cmd b/scripts/restart-muninn.cmd new file mode 100644 index 0000000..5bba9ff --- /dev/null +++ b/scripts/restart-muninn.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\restart-muninn.ps1 diff --git a/scripts/restart-muninn.ps1 b/scripts/restart-muninn.ps1 new file mode 100644 index 0000000..fe4dc75 --- /dev/null +++ b/scripts/restart-muninn.ps1 @@ -0,0 +1,37 @@ +param( + [string] $RavenHost = "raven", + [string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn.exe", + [string] $StorePath = "C:\Meta\Odin\state\muninn.telemetry.cc", + [string] $LogRoot = "C:\Meta\Odin\logs\muninn" +) + +$ErrorActionPreference = "Stop" + +$remoteScript = @" +`$ErrorActionPreference = "Stop" +if (-not (Test-Path -LiteralPath "$MuninnExe")) { + throw "Muninn executable not found at $MuninnExe" +} +Get-CimInstance Win32_Process | + Where-Object { `$_.Name -ieq "muninn.exe" -and `$_.CommandLine -like "*serve*" } | + ForEach-Object { + & taskkill.exe /PID `$_.ProcessId /T /F | Out-Null + } +New-Item -ItemType Directory -Force -Path "$LogRoot" | Out-Null +New-Item -ItemType Directory -Force -Path (Split-Path -Parent "$StorePath") | Out-Null +`$muninnDir = Split-Path -Parent "$MuninnExe" +`$launcher = Join-Path `$muninnDir "start-muninn-serve.cmd" +`$lines = @( + "@echo off", + "cd /d ""`$muninnDir""", + """$MuninnExe"" serve --store ""$StorePath"" --log-root ""$LogRoot"" --host raven --interval-seconds 15 1>>""$LogRoot\muninn-serve.out.log"" 2>>""$LogRoot\muninn-serve.err.log""" +) +Set-Content -LiteralPath `$launcher -Value `$lines -Encoding ASCII +cmd /c "schtasks /Delete /TN GameCult-Muninn /F 2>NUL" +cmd /c schtasks /Create /TN GameCult-Muninn /SC ONCE /ST 23:59 /TR `$launcher /IT /RL HIGHEST /F +cmd /c schtasks /Run /TN GameCult-Muninn +"@ + +$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript)) +& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $RavenHost "powershell.exe -NoProfile -EncodedCommand $encoded" +exit $LASTEXITCODE diff --git a/scripts/restart-raven-capture-mux.cmd b/scripts/restart-raven-capture-mux.cmd index bab354c..5bba9ff 100644 --- a/scripts/restart-raven-capture-mux.cmd +++ b/scripts/restart-raven-capture-mux.cmd @@ -1,2 +1,2 @@ @echo off -powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\restart-raven-capture-mux.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\restart-muninn.ps1 diff --git a/scripts/restart-raven-capture-mux.ps1 b/scripts/restart-raven-capture-mux.ps1 index 7f477cb..f1c6571 100644 --- a/scripts/restart-raven-capture-mux.ps1 +++ b/scripts/restart-raven-capture-mux.ps1 @@ -1,28 +1,7 @@ param( - [string] $RavenHost = "10.77.0.4", - [string] $MimirRepo = "E:\Projects\Mimir", - [string] $TargetHost = "10.77.0.2", - [int] $Port = 5200, - [string] $ObsTargetHost = "10.77.0.2", - [int] $ObsPort = 5204 + [string] $RavenHost = "raven" ) -$ErrorActionPreference = "Stop" - -$remoteScript = @" -`$ErrorActionPreference = "Stop" -`$startScript = Join-Path "$MimirRepo" "scripts\start-raven-daemon.ps1" -if (-not (Test-Path -LiteralPath `$startScript)) { - throw "Raven Mimir start script not found at `$startScript" -} -Get-CimInstance Win32_Process | - Where-Object { `$_.Name -ieq "dotnet.exe" -and `$_.CommandLine -like "*Mimir.RavenDaemon*" } | - ForEach-Object { - & taskkill.exe /PID `$_.ProcessId /T /F | Out-Null - } -& powershell.exe -NoProfile -ExecutionPolicy Bypass -File `$startScript -TargetHost "$TargetHost" -Port $Port -ObsTargetHost "$ObsTargetHost" -ObsPort $ObsPort -"@ - -$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript)) -& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $RavenHost "powershell.exe -NoProfile -EncodedCommand $encoded" +$script = Join-Path $PSScriptRoot "restart-muninn.ps1" +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -RavenHost $RavenHost exit $LASTEXITCODE diff --git a/scripts/start-idunn-local.ps1 b/scripts/start-idunn-local.ps1 index 51de1b6..06f07f3 100644 --- a/scripts/start-idunn-local.ps1 +++ b/scripts/start-idunn-local.ps1 @@ -56,11 +56,11 @@ $watchdogs = @( IntervalSeconds = 300 }, [pscustomobject]@{ - Id = "raven-capture-mux" - Name = "Raven capture mux" + Id = "muninn" + Name = "Muninn telemetry Verse assembler" Verse = "raven.local" - Health = "$repoRoot\scripts\health-raven-capture-mux.cmd" - Restart = "$repoRoot\scripts\restart-raven-capture-mux.cmd" + Health = "$repoRoot\scripts\health-muninn.cmd" + Restart = "$repoRoot\scripts\restart-muninn.cmd" }, [pscustomobject]@{ Id = "nightwing-gjallar"