refactor(claw): the calendar table is the one truth — live, not config
Same move as the watches, and the config's last piece of integration state goes with it: - `rhema-calendar-integration` owns the calendar table now (`store.rs`): SQLite in the shared dispatch database, the pattern the claw's watch table established. `CalendarWatch` gains the `validate` the config's boot check used to run — per-kind URL and credential rules, plus the lead-under-horizon check — so an invalid calendar is loud at the one door every writer shares. - `CalendarSource::from_store` reads the table fresh on every detect pass (the forge source's pattern): a calendar added over the route is live next tick, a removed one stops polling, no restart in the story. The static constructor stays for tests. - `POST/DELETE/GET /v1/calendars` on the daemon: the POST body is the integration's `CalendarWatch` shape, validated at the table; DELETE takes `?name=`; the api listing names the door. - `ClawConfig.calendars` is gone. A config still carrying it is refused at parse time, like the retired `watches:` seed. The config-side validation and its tests moved with the type. The config file now carries only what boot needs: credentials, the surface, paths, the poll cadence. Everything the daemon reconfigures at runtime lives in its tables, behind its HTTP doors.
This commit is contained in:
1 parent
c6a3ed8c97
commit
818079d1f4
12 files changed
+481
-377
No files matched your search
@@ -73,6 +73,8 @@ pub mod event;
|
||||
pub mod google;
|
||||
pub mod ics;
|
||||
pub mod reminder;
|
||||
pub mod store;
|
||||
pub use store::CalendarStore;
|
||||
|
||||
// Re-exported so implementors of the async seam can apply #[async_trait]
|
||||
// without adding the crate themselves.
|
||||
|
||||
@@ -73,6 +73,7 @@ fn reminder_key_fn() -> Arc<KeyFn> {
|
||||
}
|
||||
|
||||
/// One configured calendar, resolved to a client the source polls.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Feed {
|
||||
name: String,
|
||||
client: Arc<dyn Calendar>,
|
||||
@@ -236,7 +237,15 @@ fn nonempty(value: Option<&str>) -> Option<&str> {
|
||||
pub struct CalendarSource {
|
||||
dispatcher: Arc<Dispatcher>,
|
||||
clock: Arc<dyn Clock>,
|
||||
feeds: Vec<Feed>,
|
||||
/// Where this pass's feeds come from: a static list (tests, and
|
||||
/// callers that hold the specs) or the calendar table, read fresh
|
||||
/// on every detect pass so a runtime add is live next tick.
|
||||
feeds: FeedSource,
|
||||
}
|
||||
|
||||
enum FeedSource {
|
||||
Static(Vec<Feed>),
|
||||
Store(Arc<crate::store::CalendarStore>),
|
||||
}
|
||||
|
||||
impl CalendarSource {
|
||||
@@ -262,10 +271,37 @@ impl CalendarSource {
|
||||
Ok(Self {
|
||||
dispatcher: dispatcher.clone(),
|
||||
clock,
|
||||
feeds,
|
||||
feeds: FeedSource::Static(feeds),
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds from the calendar table instead of a static list: the
|
||||
/// source reads it fresh every pass, so a calendar added over the
|
||||
/// HTTP route (or a chat tool) is live without a restart, and a
|
||||
/// removed one stops polling on the next tick. Config validation
|
||||
/// ran when the row landed; a calendar that still fails to resolve
|
||||
/// here names itself and costs its tick, not the daemon.
|
||||
pub fn from_store(
|
||||
dispatcher: &Arc<Dispatcher>,
|
||||
clock: Arc<dyn Clock>,
|
||||
store: Arc<crate::store::CalendarStore>,
|
||||
) -> Self {
|
||||
dispatcher.register_source(CALENDARS_SOURCE, reminder_key_fn());
|
||||
Self {
|
||||
dispatcher: dispatcher.clone(),
|
||||
clock,
|
||||
feeds: FeedSource::Store(store),
|
||||
}
|
||||
}
|
||||
|
||||
/// The feeds this source polls, resolved for one detect pass.
|
||||
fn feeds_for_pass(&self) -> Result<Vec<Feed>, String> {
|
||||
match &self.feeds {
|
||||
FeedSource::Static(feeds) => Ok(feeds.to_vec()),
|
||||
FeedSource::Store(store) => store_feeds(store),
|
||||
}
|
||||
}
|
||||
|
||||
/// A source over ready-made feeds. Tests use this; production
|
||||
/// builds from the config with [`CalendarSource::new`].
|
||||
#[cfg(test)]
|
||||
@@ -278,11 +314,30 @@ impl CalendarSource {
|
||||
Self {
|
||||
dispatcher: dispatcher.clone(),
|
||||
clock,
|
||||
feeds,
|
||||
feeds: FeedSource::Static(feeds),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the table's calendars into this pass's feeds. A feed's
|
||||
/// client is rebuilt per pass — the table is small, and correctness
|
||||
/// (a runtime add is live) beats a pool the row could outlive.
|
||||
fn store_feeds(store: &crate::store::CalendarStore) -> Result<Vec<Feed>, String> {
|
||||
let calendars = store
|
||||
.list()
|
||||
.map_err(|err| format!("calendar table: {err}"))?;
|
||||
let mut feeds = Vec::with_capacity(calendars.len());
|
||||
for calendar in &calendars {
|
||||
feeds.push(Feed {
|
||||
name: calendar.name.trim().to_owned(),
|
||||
client: client_for(calendar)?,
|
||||
leads: calendar.leads(),
|
||||
horizon_hours: calendar.horizon(),
|
||||
});
|
||||
}
|
||||
Ok(feeds)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Source for CalendarSource {
|
||||
fn name(&self) -> &str {
|
||||
@@ -298,7 +353,8 @@ impl Source for CalendarSource {
|
||||
let now = OffsetDateTime::from_unix_timestamp_nanos(now_ms as i128 * 1_000_000)
|
||||
.map_err(|err| SourceError::Backend(format!("clock: {err}")))?;
|
||||
let mut fed = 0;
|
||||
for feed in &self.feeds {
|
||||
let feeds = self.feeds_for_pass().map_err(SourceError::Backend)?;
|
||||
for feed in &feeds {
|
||||
let window = Window::new(
|
||||
now,
|
||||
now + time::Duration::hours(i64::from(feed.horizon_hours)),
|
||||
@@ -608,6 +664,68 @@ impl CalendarKind {
|
||||
}
|
||||
}
|
||||
|
||||
impl CalendarWatch {
|
||||
/// The rules a calendar must satisfy to poll — the checks the
|
||||
/// config's boot validation used to run, now at the one door every
|
||||
/// writer shares: the table's `add`.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("name must not be blank".to_owned());
|
||||
}
|
||||
match self.kind {
|
||||
CalendarKind::Ics => {
|
||||
let url = nonempty(self.url.as_deref()).ok_or_else(|| {
|
||||
"ics is a feed URL: set url to the calendar's address".to_owned()
|
||||
})?;
|
||||
if !(url.starts_with("https://")
|
||||
|| url.starts_with("http://")
|
||||
|| url.starts_with("webcal://"))
|
||||
{
|
||||
return Err(format!(
|
||||
"url must be https://, http://, or webcal://, got {url:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
CalendarKind::Caldav => {
|
||||
let url = nonempty(self.url.as_deref()).ok_or_else(|| {
|
||||
"caldav is a collection: set url to the calendar's DAV address".to_owned()
|
||||
})?;
|
||||
if !(url.starts_with("https://") || url.starts_with("http://")) {
|
||||
return Err(format!("url must be https:// or http://, got {url:?}"));
|
||||
}
|
||||
let has_user = nonempty(self.username.as_deref()).is_some();
|
||||
if has_user && self.resolved_password().is_none() {
|
||||
return Err("no password: set password or CALDAV_PASSWORD".to_owned());
|
||||
}
|
||||
}
|
||||
CalendarKind::Google => {
|
||||
if nonempty(self.calendar_id.as_deref()).is_none() {
|
||||
return Err(
|
||||
"google needs a calendar: set calendar_id (\"primary\", an address, a hex id)"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
if self.resolved_api_key().is_none() && self.resolved_token().is_none() {
|
||||
return Err(
|
||||
"no credential: set api_key (public calendars) or token, or CALENDAR_TOKEN"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The horizon is what the poll can see; a lead at or past it
|
||||
// fires on an event that was never visible.
|
||||
let horizon_minutes = u64::from(self.horizon()) * 60;
|
||||
let longest = self.leads().into_iter().max().unwrap_or_default();
|
||||
if longest >= horizon_minutes {
|
||||
return Err(format!(
|
||||
"lead {longest}m is not under the {horizon_minutes}m horizon: a reminder cannot fire before its event is visible"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One watched calendar. Unknown keys are rejected, as on a watch.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! The calendar table: agent-managed calendars in SQLite, the same
|
||||
//! database file the dispatcher's store uses — the pattern the claw's
|
||||
//! watch table established. The config stopped carrying calendars:
|
||||
//! the table is the one truth, and the source reads it fresh on every
|
||||
//! detect pass, so a row added over `/v1/calendars` (or a chat tool)
|
||||
//! is live without a restart.
|
||||
|
||||
use crate::reminder::CalendarWatch;
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// An open handle on the calendar table. Its own rusqlite connection
|
||||
/// to the shared database file — WAL and a busy timeout, so the
|
||||
/// dispatcher's connection and this one interleave through SQLite's
|
||||
/// own locking.
|
||||
pub struct CalendarStore {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl CalendarStore {
|
||||
/// Opens (creating, if needed) the calendar table inside `path`.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, std::io::Error> {
|
||||
let path = path.as_ref();
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let conn = Connection::open(path).map_err(std::io::Error::other)?;
|
||||
conn.busy_timeout(std::time::Duration::from_millis(5_000))
|
||||
.map_err(std::io::Error::other)?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS calendars (
|
||||
name TEXT PRIMARY KEY,
|
||||
spec TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// An in-memory table; tests use this.
|
||||
pub fn in_memory() -> Result<Self, std::io::Error> {
|
||||
let conn = Connection::open_in_memory().map_err(std::io::Error::other)?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS calendars (
|
||||
name TEXT PRIMARY KEY,
|
||||
spec TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds (or refuses, when the name is taken) a calendar. The spec
|
||||
/// is validated first — the same door every writer shares, the
|
||||
/// route and a future chat tool alike — so an invalid calendar is
|
||||
/// loud wherever it enters.
|
||||
pub fn add(&self, calendar: &CalendarWatch) -> Result<bool, std::io::Error> {
|
||||
calendar
|
||||
.validate()
|
||||
.map_err(std::io::Error::other)?;
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let spec = serde_json::to_string(calendar).map_err(std::io::Error::other)?;
|
||||
let inserted = conn
|
||||
.execute(
|
||||
"INSERT INTO calendars (name, spec, created_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(name) DO NOTHING",
|
||||
rusqlite::params![
|
||||
calendar.name.trim(),
|
||||
spec,
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
],
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok(inserted == 1)
|
||||
}
|
||||
|
||||
/// Removes the named calendar; `false` when there was none.
|
||||
pub fn remove(&self, name: &str) -> Result<bool, std::io::Error> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let removed = conn
|
||||
.execute(
|
||||
"DELETE FROM calendars WHERE name = ?1",
|
||||
rusqlite::params![name.trim()],
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok(removed == 1)
|
||||
}
|
||||
|
||||
/// Every calendar, oldest first. The source reads this fresh on
|
||||
/// every detect pass, so runtime adds take effect without a
|
||||
/// restart.
|
||||
pub fn list(&self) -> Result<Vec<CalendarWatch>, std::io::Error> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT spec FROM calendars ORDER BY created_at ASC, name ASC")
|
||||
.map_err(std::io::Error::other)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(std::io::Error::other)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(std::io::Error::other)?;
|
||||
rows.iter()
|
||||
.map(|spec| serde_json::from_str(spec).map_err(std::io::Error::other))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::CalendarKind;
|
||||
|
||||
fn ics(name: &str) -> CalendarWatch {
|
||||
serde_json::from_str(&format!(
|
||||
"{{\"name\": \"{name}\", \"kind\": \"ics\", \"url\": \"https://example.net/f.ics\"}}"
|
||||
))
|
||||
.expect("parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calendar_round_trips_through_the_table() {
|
||||
let store = CalendarStore::in_memory().expect("store opens");
|
||||
assert!(store.add(&ics("team")).expect("add lands"));
|
||||
assert!(
|
||||
!store.add(&ics("team")).expect("add again"),
|
||||
"a taken name is refused, not overwritten"
|
||||
);
|
||||
let listed = store.list().expect("list reads");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].name, "team");
|
||||
assert_eq!(listed[0].kind, CalendarKind::Ics);
|
||||
assert!(store.remove("team").expect("remove lands"));
|
||||
assert!(store.list().expect("list reads").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_calendar_is_loud_wherever_it_enters() {
|
||||
let store = CalendarStore::in_memory().expect("store opens");
|
||||
let blank: CalendarWatch =
|
||||
serde_json::from_str("{\"name\": \" \", \"kind\": \"ics\"}").expect("parses");
|
||||
let err = store.add(&blank).expect_err("a blank name is refused");
|
||||
assert!(err.to_string().contains("name"), "got: {err}");
|
||||
let no_url: CalendarWatch =
|
||||
serde_json::from_str("{\"name\": \"x\", \"kind\": \"ics\"}").expect("parses");
|
||||
let err = store.add(&no_url).expect_err("an urlless ics is refused");
|
||||
assert!(err.to_string().contains("url"), "got: {err}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user