loopd: [heartbeat] table and summarise name check
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -177,6 +177,8 @@ pub struct Config {
|
||||
pub memory: Memory,
|
||||
#[serde(default)]
|
||||
pub compact: Compact,
|
||||
#[serde(default)]
|
||||
pub heartbeat: Heartbeat,
|
||||
}
|
||||
|
||||
/// `[memory]`: `memoryd`'s socket for `loopd` (M5a spec, section 9). An empty path means the
|
||||
@@ -207,6 +209,24 @@ impl Default for Compact {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[heartbeat]`: how often the standing `heartbeat` session runs (M5d spec, section 4.1).
|
||||
/// `every_s` 0 disables it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Heartbeat {
|
||||
pub every_s: u64,
|
||||
pub prompt: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Default for Heartbeat {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
every_s: 0,
|
||||
prompt: std::path::PathBuf::from("heartbeat.md"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Read(std::path::PathBuf, std::io::Error),
|
||||
@@ -235,6 +255,7 @@ impl Config {
|
||||
toml::from_str(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
config.baseline.system = parent.join(&config.baseline.system);
|
||||
config.heartbeat.prompt = parent.join(&config.heartbeat.prompt);
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
@@ -259,4 +280,19 @@ impl Config {
|
||||
pub fn compact_prompt(&self) -> std::path::PathBuf {
|
||||
self.baseline.system.with_file_name("compact.md")
|
||||
}
|
||||
|
||||
/// The heartbeat prompt, already joined to the config file's directory by `load`.
|
||||
pub fn heartbeat_prompt(&self) -> std::path::PathBuf {
|
||||
self.heartbeat.prompt.clone()
|
||||
}
|
||||
|
||||
/// Unknown `[compact] summarise` names are a config error (M5d spec, section 4.3).
|
||||
pub fn check_summarise(&self, registry: &crate::tools::Registry) -> Result<(), String> {
|
||||
for name in &self.compact.summarise {
|
||||
if registry.get(name).is_none() {
|
||||
return Err(format!("[compact] summarise: unknown tool {name}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ fn run_selftest(path: &Path) -> ExitCode {
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = cfg.check_summarise(&loopd::tools::Registry::m5c()) {
|
||||
eprintln!("loopd: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
|
||||
let client = Client::new(cfg);
|
||||
match run_selftest_check(&client) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -74,6 +79,11 @@ fn run_serve(path: &Path) -> ExitCode {
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = cfg.check_summarise(&loopd::tools::Registry::m5c()) {
|
||||
eprintln!("loopd: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
|
||||
// One loopd per home (M5b spec, section 9a). The lock comes before the socket is touched and
|
||||
// before the self-test, so a second copy neither takes the socket of the one that runs nor
|
||||
// sends its slot a prompt. Held until the process ends.
|
||||
|
||||
@@ -132,7 +132,7 @@ fn loopd_serve_uses_the_m5c_registry() {
|
||||
let main = include_str!("../src/main.rs");
|
||||
assert_eq!(
|
||||
main.matches("Registry::m5c()").count(),
|
||||
1,
|
||||
3,
|
||||
"main.rs uses m5c()"
|
||||
);
|
||||
for other in ["m2b()", "m3a()", "m5a()"] {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Tests for `[heartbeat]` and unknown `[compact] summarise` names. Do not edit.
|
||||
|
||||
use loopd::config::Config;
|
||||
use loopd::tools::Registry;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/config")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
fn minimal() -> String {
|
||||
std::fs::read_to_string(fixture("minimal.toml")).unwrap()
|
||||
}
|
||||
|
||||
struct Scratch {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Scratch {
|
||||
let n = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("loopd-hb-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch { dir }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_heartbeat_table_is_disabled() {
|
||||
let c = Config::load(&fixture("minimal.toml")).unwrap();
|
||||
assert_eq!(c.heartbeat.every_s, 0);
|
||||
assert_eq!(
|
||||
c.heartbeat_prompt(),
|
||||
fixture("minimal.toml")
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("heartbeat.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_table_overrides_every_default() {
|
||||
let text = format!(
|
||||
"{}\n[heartbeat]\nevery_s = 60\nprompt = \"pulse.md\"\n",
|
||||
minimal()
|
||||
);
|
||||
let c = Config::parse(&text).unwrap();
|
||||
assert_eq!(c.heartbeat.every_s, 60);
|
||||
assert_eq!(c.heartbeat.prompt, PathBuf::from("pulse.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_are_errors() {
|
||||
let bad = format!("{}\n[heartbeat]\nzz_unknown = 1\n", minimal());
|
||||
assert!(Config::parse(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_prompt_is_beside_the_config_file() {
|
||||
let home = Scratch::new();
|
||||
let cfg_path = home.dir.join("loopd.toml");
|
||||
std::fs::write(&cfg_path, minimal()).unwrap();
|
||||
std::fs::write(home.dir.join("system.md"), "sys\n").unwrap();
|
||||
let c = Config::load(&cfg_path).unwrap();
|
||||
assert_eq!(c.heartbeat_prompt(), home.dir.join("heartbeat.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_summarise_http_fetch_is_known_to_m5c() {
|
||||
let c = Config::parse(&minimal()).unwrap();
|
||||
c.check_summarise(&Registry::m5c()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_summarise_name_is_an_error() {
|
||||
let text = format!("{}\n[compact]\nsummarise = [\"no_such_tool\"]\n", minimal());
|
||||
let c = Config::parse(&text).unwrap();
|
||||
let err = c.check_summarise(&Registry::m5c()).unwrap_err();
|
||||
assert!(err.contains("no_such_tool"), "{err}");
|
||||
assert!(err.contains("summarise"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_summarise_is_allowed() {
|
||||
let text = format!("{}\n[compact]\nsummarise = []\n", minimal());
|
||||
let c = Config::parse(&text).unwrap();
|
||||
c.check_summarise(&Registry::m5c()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_fetch_is_known_and_clock_is_known() {
|
||||
let text = format!(
|
||||
"{}\n[compact]\nsummarise = [\"http_fetch\", \"clock\"]\n",
|
||||
minimal()
|
||||
);
|
||||
let c = Config::parse(&text).unwrap();
|
||||
c.check_summarise(&Registry::m5c()).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user