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
@@ -14,12 +14,6 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// The calendar feed types are the integration's: the reminder source
|
|
||||||
// polls them there, and the claw's config only describes them.
|
|
||||||
pub use rhema_calendar_integration::reminder::{
|
|
||||||
CalendarKind, CalendarWatch, DEFAULT_HORIZON_HOURS, DEFAULT_LEAD_MINUTES,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// How often the resident loop runs a detect pass, when
|
/// How often the resident loop runs a detect pass, when
|
||||||
/// [`ClawConfig::poll_interval_secs`] is unset.
|
/// [`ClawConfig::poll_interval_secs`] is unset.
|
||||||
pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 60;
|
pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 60;
|
||||||
@@ -69,17 +63,6 @@ pub enum ConfigError {
|
|||||||
"ingest_token is the same value as another credential; the ingest token must be its own secret, write-only on the event door"
|
"ingest_token is the same value as another credential; the ingest token must be its own secret, write-only on the event door"
|
||||||
)]
|
)]
|
||||||
IngestTokenCollision,
|
IngestTokenCollision,
|
||||||
/// One calendar watch is unusable; the daemon refuses to start
|
|
||||||
/// rather than poll a half-configured calendar.
|
|
||||||
#[error("calendar #{index} ({name}): {reason}")]
|
|
||||||
Calendar {
|
|
||||||
/// The calendar's position in the config file, zero-based.
|
|
||||||
index: usize,
|
|
||||||
/// The calendar's name as written.
|
|
||||||
name: String,
|
|
||||||
/// What is missing or malformed.
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
/// The HTTP surface would listen beyond loopback with no bearer
|
/// The HTTP surface would listen beyond loopback with no bearer
|
||||||
/// token: anyone who can reach the port could run tools on this
|
/// token: anyone who can reach the port could run tools on this
|
||||||
/// machine.
|
/// machine.
|
||||||
@@ -261,10 +244,6 @@ pub struct ClawConfig {
|
|||||||
/// Unset closes the door to credentials; the human token can still
|
/// Unset closes the door to credentials; the human token can still
|
||||||
/// ingest.
|
/// ingest.
|
||||||
pub ingest_token: Option<String>,
|
pub ingest_token: Option<String>,
|
||||||
/// The calendars to remind about. Config-file-only: a calendar is
|
|
||||||
/// read-only ambient state, not an agent-managed resource, and a
|
|
||||||
/// change takes the daemon restart it takes to edit the file.
|
|
||||||
pub calendars: Vec<CalendarWatch>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClawConfig {
|
impl ClawConfig {
|
||||||
@@ -292,7 +271,6 @@ impl Default for ClawConfig {
|
|||||||
tools_bin: None,
|
tools_bin: None,
|
||||||
ingest_token: None,
|
ingest_token: None,
|
||||||
transcript: None,
|
transcript: None,
|
||||||
calendars: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,111 +412,6 @@ impl ClawConfig {
|
|||||||
return Err(ConfigError::IngestTokenCollision);
|
return Err(ConfigError::IngestTokenCollision);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.validate_calendars()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The calendar half of the startup checks: every calendar named,
|
|
||||||
/// complete for its kind, with leads a horizon can see.
|
|
||||||
fn validate_calendars(&self) -> Result<(), ConfigError> {
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
for (index, calendar) in self.calendars.iter().enumerate() {
|
|
||||||
let fail = |reason: String| ConfigError::Calendar {
|
|
||||||
index,
|
|
||||||
name: calendar.name.clone(),
|
|
||||||
reason,
|
|
||||||
};
|
|
||||||
if calendar.name.trim().is_empty() {
|
|
||||||
return Err(fail("name must not be blank".to_owned()));
|
|
||||||
}
|
|
||||||
if !seen.insert(calendar.name.trim().to_owned()) {
|
|
||||||
return Err(fail(
|
|
||||||
"duplicate name: reminder keys would collide".to_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
match calendar.kind {
|
|
||||||
CalendarKind::Ics => {
|
|
||||||
let url = calendar
|
|
||||||
.url
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|url| !url.is_empty())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
fail("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(fail(format!(
|
|
||||||
"url must be https://, http://, or webcal://, got {url:?}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CalendarKind::Caldav => {
|
|
||||||
let url = calendar
|
|
||||||
.url
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|url| !url.is_empty())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
fail(
|
|
||||||
"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(fail(format!(
|
|
||||||
"url must be https:// or http://, got {url:?}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let has_user = calendar
|
|
||||||
.username
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.is_some_and(|user| !user.is_empty());
|
|
||||||
if has_user && calendar.resolved_password().is_none() {
|
|
||||||
return Err(fail(
|
|
||||||
"no password: set password in the config or CALDAV_PASSWORD".to_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CalendarKind::Google => {
|
|
||||||
if calendar
|
|
||||||
.calendar_id
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|id| !id.is_empty())
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
return Err(fail(
|
|
||||||
"google needs a calendar: set calendar_id (\"primary\", an \
|
|
||||||
address, a hex id)"
|
|
||||||
.to_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if calendar.resolved_api_key().is_none() && calendar.resolved_token().is_none()
|
|
||||||
{
|
|
||||||
return Err(fail(
|
|
||||||
"no credential: set api_key (public calendars) or token in the \
|
|
||||||
config, 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(calendar.horizon()) * 60;
|
|
||||||
let longest = calendar.leads().into_iter().max().unwrap_or_default();
|
|
||||||
if longest >= horizon_minutes {
|
|
||||||
return Err(fail(format!(
|
|
||||||
"lead {longest}m is not under the {horizon_minutes}m horizon: a \
|
|
||||||
reminder cannot fire before its event is visible"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,239 +560,6 @@ ingest_token: door-key
|
|||||||
assert_eq!(config.listen_addr(), DEFAULT_LISTEN);
|
assert_eq!(config.listen_addr(), DEFAULT_LISTEN);
|
||||||
assert_eq!(config.token, None);
|
assert_eq!(config.token, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_calendar_section_parses_field_for_field() {
|
|
||||||
let raw = "calendars:
|
|
||||||
- name: home
|
|
||||||
kind: ics
|
|
||||||
url: https://cloud.example.net/remote.php/dav/calendars/personal.ics
|
|
||||||
lead_minutes: [60, 15]
|
|
||||||
horizon_hours: 48
|
|
||||||
- name: work
|
|
||||||
kind: google
|
|
||||||
calendar_id: primary
|
|
||||||
api_key: pub-key
|
|
||||||
- name: shared
|
|
||||||
kind: google
|
|
||||||
calendar_id: abc123@group.calendar.google.com
|
|
||||||
token: oauth-token
|
|
||||||
- name: dav
|
|
||||||
kind: caldav
|
|
||||||
url: https://cloud.example.net/remote.php/dav/calendars/me/personal/
|
|
||||||
username: me
|
|
||||||
password: app-pass
|
|
||||||
";
|
|
||||||
let config: ClawConfig = serde_yaml::from_str(raw).expect("parses");
|
|
||||||
assert_eq!(config.calendars.len(), 4);
|
|
||||||
let home = &config.calendars[0];
|
|
||||||
assert_eq!(home.name, "home");
|
|
||||||
assert_eq!(home.kind, CalendarKind::Ics);
|
|
||||||
assert_eq!(home.leads(), vec![60, 15]);
|
|
||||||
assert_eq!(home.horizon(), 48);
|
|
||||||
assert_eq!(
|
|
||||||
config.calendars[1].resolved_api_key().as_deref(),
|
|
||||||
Some("pub-key")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
config.calendars[2].resolved_token().as_deref(),
|
|
||||||
Some("oauth-token")
|
|
||||||
);
|
|
||||||
assert_eq!(config.calendars[3].kind, CalendarKind::Caldav);
|
|
||||||
assert_eq!(config.calendars[3].username.as_deref(), Some("me"));
|
|
||||||
assert_eq!(
|
|
||||||
config.calendars[3].resolved_password().as_deref(),
|
|
||||||
Some("app-pass")
|
|
||||||
);
|
|
||||||
config.validate().expect("a complete list validates");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn calendar_defaults_fill_what_the_file_omits() {
|
|
||||||
let config: ClawConfig = serde_yaml::from_str(
|
|
||||||
"calendars:\n - name: home\n kind: ics\n url: https://c.example.net/a.ics\n",
|
|
||||||
)
|
|
||||||
.expect("parses");
|
|
||||||
assert_eq!(config.calendars[0].leads(), vec![DEFAULT_LEAD_MINUTES]);
|
|
||||||
assert_eq!(config.calendars[0].horizon(), DEFAULT_HORIZON_HOURS);
|
|
||||||
// A blank or zero lead list reads as the default, not as
|
|
||||||
// silence.
|
|
||||||
let config: ClawConfig = serde_yaml::from_str(
|
|
||||||
"calendars:\n - name: home\n kind: ics\n url: https://c.example.net/a.ics\n lead_minutes: []\n",
|
|
||||||
)
|
|
||||||
.expect("parses");
|
|
||||||
assert_eq!(config.calendars[0].leads(), vec![DEFAULT_LEAD_MINUTES]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_calendar_typo_is_rejected_and_named() {
|
|
||||||
let err = serde_yaml::from_str::<ClawConfig>(
|
|
||||||
"calendars:\n - name: home\n kind: ics\n uurl: https://c.example.net/a.ics\n",
|
|
||||||
)
|
|
||||||
.expect_err("a typo'd calendar key is loud");
|
|
||||||
assert!(err.to_string().contains("uurl"), "got: {err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn calendar_validation_fails_loudly_and_specifically() {
|
|
||||||
let config = |calendars: Vec<CalendarWatch>| ClawConfig {
|
|
||||||
calendars,
|
|
||||||
..ClawConfig::default()
|
|
||||||
};
|
|
||||||
let ics = |name: &str, url: Option<&str>| CalendarWatch {
|
|
||||||
name: name.to_owned(),
|
|
||||||
kind: CalendarKind::Ics,
|
|
||||||
url: url.map(str::to_owned),
|
|
||||||
calendar_id: None,
|
|
||||||
token: None,
|
|
||||||
username: None,
|
|
||||||
password: None,
|
|
||||||
api_key: None,
|
|
||||||
lead_minutes: None,
|
|
||||||
horizon_hours: None,
|
|
||||||
};
|
|
||||||
let caldav = |name: &str, url: Option<&str>| CalendarWatch {
|
|
||||||
name: name.to_owned(),
|
|
||||||
kind: CalendarKind::Caldav,
|
|
||||||
url: url.map(str::to_owned),
|
|
||||||
calendar_id: None,
|
|
||||||
token: None,
|
|
||||||
username: None,
|
|
||||||
password: None,
|
|
||||||
api_key: None,
|
|
||||||
lead_minutes: None,
|
|
||||||
horizon_hours: None,
|
|
||||||
};
|
|
||||||
let google = |name: &str, id: Option<&str>, api_key: Option<&str>| CalendarWatch {
|
|
||||||
name: name.to_owned(),
|
|
||||||
kind: CalendarKind::Google,
|
|
||||||
url: None,
|
|
||||||
calendar_id: id.map(str::to_owned),
|
|
||||||
token: None,
|
|
||||||
username: None,
|
|
||||||
password: None,
|
|
||||||
api_key: api_key.map(str::to_owned),
|
|
||||||
lead_minutes: None,
|
|
||||||
horizon_hours: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let err = config(vec![ics("", Some("https://c.example.net/a.ics"))])
|
|
||||||
.validate()
|
|
||||||
.expect_err("a blank name is loud");
|
|
||||||
assert!(err.to_string().contains("blank"), "got: {err}");
|
|
||||||
|
|
||||||
let err = config(vec![
|
|
||||||
ics("home", Some("https://c.example.net/a.ics")),
|
|
||||||
ics("home", Some("webcal://c.example.net/b.ics")),
|
|
||||||
])
|
|
||||||
.validate()
|
|
||||||
.expect_err("a duplicate name is loud");
|
|
||||||
assert!(err.to_string().contains("duplicate"), "got: {err}");
|
|
||||||
|
|
||||||
let err = config(vec![ics("home", None)])
|
|
||||||
.validate()
|
|
||||||
.expect_err("a url-less feed is loud");
|
|
||||||
assert!(err.to_string().contains("feed URL"), "got: {err}");
|
|
||||||
|
|
||||||
let err = config(vec![ics("home", Some("ftp://c.example.net/a.ics"))])
|
|
||||||
.validate()
|
|
||||||
.expect_err("a junk scheme is loud");
|
|
||||||
assert!(err.to_string().contains("https://"), "got: {err}");
|
|
||||||
|
|
||||||
let err = config(vec![caldav("home", None)])
|
|
||||||
.validate()
|
|
||||||
.expect_err("a url-less collection is loud");
|
|
||||||
assert!(err.to_string().contains("DAV"), "got: {err}");
|
|
||||||
|
|
||||||
let err = config(vec![caldav("home", Some("webcal://c.example.net/dav/"))])
|
|
||||||
.validate()
|
|
||||||
.expect_err("webcal is not a dav spelling");
|
|
||||||
assert!(err.to_string().contains("https://"), "got: {err}");
|
|
||||||
|
|
||||||
let err = config(vec![google("work", None, Some("k"))])
|
|
||||||
.validate()
|
|
||||||
.expect_err("an id-less google calendar is loud");
|
|
||||||
assert!(err.to_string().contains("calendar_id"), "got: {err}");
|
|
||||||
|
|
||||||
let err = config(vec![google("work", Some("primary"), None)])
|
|
||||||
.validate()
|
|
||||||
.expect_err("a credential-less google calendar is loud");
|
|
||||||
assert!(err.to_string().contains("CALENDAR_TOKEN"), "got: {err}");
|
|
||||||
|
|
||||||
let mut late = ics("home", Some("https://c.example.net/a.ics"));
|
|
||||||
late.lead_minutes = Some(vec![60]);
|
|
||||||
late.horizon_hours = Some(1);
|
|
||||||
let err = config(vec![late])
|
|
||||||
.validate()
|
|
||||||
.expect_err("a lead past the horizon is loud");
|
|
||||||
assert!(err.to_string().contains("horizon"), "got: {err}");
|
|
||||||
|
|
||||||
// A username with a password validates, as does a collection
|
|
||||||
// with none of it (an open server) or a token instead.
|
|
||||||
let mut basic = caldav("home", Some("https://c.example.net/dav/home/"));
|
|
||||||
basic.username = Some("me".to_owned());
|
|
||||||
basic.password = Some("app-pass".to_owned());
|
|
||||||
let mut bearer = caldav("shared", Some("http://10.0.0.5/dav/shared/"));
|
|
||||||
bearer.token = Some("sekrit".to_owned());
|
|
||||||
config(vec![
|
|
||||||
ics("feed", Some("webcal://c.example.net/a.ics")),
|
|
||||||
basic,
|
|
||||||
bearer,
|
|
||||||
google("work", Some("primary"), Some("k")),
|
|
||||||
])
|
|
||||||
.validate()
|
|
||||||
.expect("a complete list validates");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_caldav_password_falls_back_to_the_env_and_blank_counts_as_unset() {
|
|
||||||
let env = |name: &str| match name {
|
|
||||||
"CALDAV_PASSWORD" => Ok("from-env".to_owned()),
|
|
||||||
_ => Err(std::env::VarError::NotPresent),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
CalendarWatch::resolve_password_with(env, Some(" from-config ")),
|
|
||||||
Some("from-config".to_owned()),
|
|
||||||
"the config password wins"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
CalendarWatch::resolve_password_with(env, None),
|
|
||||||
Some("from-env".to_owned()),
|
|
||||||
"the env password serves when no config password"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
CalendarWatch::resolve_password_with(
|
|
||||||
|_| Err(std::env::VarError::NotPresent),
|
|
||||||
Some(" ")
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
"a blank password counts as unset"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_calendar_token_falls_back_to_the_env_and_blank_counts_as_unset() {
|
|
||||||
let env = |name: &str| match name {
|
|
||||||
"CALENDAR_TOKEN" => Ok("from-env".to_owned()),
|
|
||||||
_ => Err(std::env::VarError::NotPresent),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
CalendarWatch::resolve_token_with(env, Some(" from-config ")),
|
|
||||||
Some("from-config".to_owned()),
|
|
||||||
"the config token wins"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
CalendarWatch::resolve_token_with(env, None),
|
|
||||||
Some("from-env".to_owned()),
|
|
||||||
"the env token serves when no config token"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
CalendarWatch::resolve_token_with(|_| Err(std::env::VarError::NotPresent), Some(" ")),
|
|
||||||
None,
|
|
||||||
"a blank token counts as unset"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn forge_names_parse_as_the_sources_spell_them() {
|
fn forge_names_parse_as_the_sources_spell_them() {
|
||||||
assert_eq!(ForgeKind::parse("github"), Some(ForgeKind::GitHub));
|
assert_eq!(ForgeKind::parse("github"), Some(ForgeKind::GitHub));
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ impl Daemon {
|
|||||||
// drives, jobs the jobs dispatcher answers.
|
// drives, jobs the jobs dispatcher answers.
|
||||||
let jobs = Arc::new(crate::job_store::CodingJobStore::open(&db)?);
|
let jobs = Arc::new(crate::job_store::CodingJobStore::open(&db)?);
|
||||||
|
|
||||||
|
// The calendar table: one truth, read fresh by the reminders
|
||||||
|
// source every pass; `/v1/calendars` writes it.
|
||||||
|
let calendars = Arc::new(rhema_calendar_integration::CalendarStore::open(&db)?);
|
||||||
|
|
||||||
let forge_dispatcher = Arc::new(Dispatcher::new(
|
let forge_dispatcher = Arc::new(Dispatcher::new(
|
||||||
dispatch,
|
dispatch,
|
||||||
Arc::new(SystemClock),
|
Arc::new(SystemClock),
|
||||||
@@ -327,7 +331,9 @@ impl Daemon {
|
|||||||
identity: identity.clone(),
|
identity: identity.clone(),
|
||||||
hub: hub.clone(),
|
hub: hub.clone(),
|
||||||
workspace: interactive_workspace.clone(),
|
workspace: interactive_workspace.clone(),
|
||||||
calendars: config.calendars.clone(),
|
// The calendar table: one truth, read fresh by the
|
||||||
|
// reminders source every pass; `/v1/calendars` writes it.
|
||||||
|
calendars: calendars.clone(),
|
||||||
}));
|
}));
|
||||||
eprintln!("[claw] extensions: {}", extensions.names().join(", "));
|
eprintln!("[claw] extensions: {}", extensions.names().join(", "));
|
||||||
// The sources register on their lanes as they build: a watch of
|
// The sources register on their lanes as they build: a watch of
|
||||||
@@ -384,6 +390,7 @@ impl Daemon {
|
|||||||
interactive_driver.clone(),
|
interactive_driver.clone(),
|
||||||
store,
|
store,
|
||||||
watches,
|
watches,
|
||||||
|
calendars.clone(),
|
||||||
notifications,
|
notifications,
|
||||||
tasks,
|
tasks,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ use rhema_autonomous_agent::{Allowlist, Source, ToolRegistry};
|
|||||||
use rhema_core::ToolResolver;
|
use rhema_core::ToolResolver;
|
||||||
use rhema_rsd::{Dispatcher, ResourceKey, SystemClock};
|
use rhema_rsd::{Dispatcher, ResourceKey, SystemClock};
|
||||||
|
|
||||||
use crate::config::{CalendarWatch, ForgeKind};
|
use crate::config::ForgeKind;
|
||||||
use crate::consents::WaitBridge;
|
use crate::consents::WaitBridge;
|
||||||
use crate::daemon::ClawError;
|
use crate::daemon::ClawError;
|
||||||
use crate::forge::ForgeSource;
|
use crate::forge::ForgeSource;
|
||||||
use crate::hub::EventHub;
|
use crate::hub::EventHub;
|
||||||
use rhema_calendar_integration::CalendarSource;
|
use rhema_calendar_integration::CalendarSource;
|
||||||
|
use rhema_calendar_integration::CalendarStore;
|
||||||
|
|
||||||
/// The store handles the standard set is assembled over. One struct,
|
/// The store handles the standard set is assembled over. One struct,
|
||||||
/// so a new extension is a field plus a constructor line, not another
|
/// so a new extension is a field plus a constructor line, not another
|
||||||
@@ -78,8 +79,9 @@ pub struct ExtensionDeps {
|
|||||||
/// The interactive workspace the account exec and the identity
|
/// The interactive workspace the account exec and the identity
|
||||||
/// registry resolve against.
|
/// registry resolve against.
|
||||||
pub workspace: PathBuf,
|
pub workspace: PathBuf,
|
||||||
/// The calendar feeds the reminders source polls.
|
/// The calendar table, shared with `/v1/calendars`: the source
|
||||||
pub calendars: Vec<CalendarWatch>,
|
/// reads it fresh every pass, so a row added at runtime is live.
|
||||||
|
pub calendars: Arc<CalendarStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The dispatch lanes a source can feed, handed to
|
/// The dispatch lanes a source can feed, handed to
|
||||||
@@ -477,7 +479,7 @@ impl ClawExtension for PhoneExtension {
|
|||||||
/// The calendar reminders: a due occurrence is an event on the resident
|
/// The calendar reminders: a due occurrence is an event on the resident
|
||||||
/// lane, never a timer near the model.
|
/// lane, never a timer near the model.
|
||||||
struct CalendarExtension {
|
struct CalendarExtension {
|
||||||
calendars: Vec<CalendarWatch>,
|
calendars: Arc<CalendarStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClawExtension for CalendarExtension {
|
impl ClawExtension for CalendarExtension {
|
||||||
@@ -486,9 +488,11 @@ impl ClawExtension for CalendarExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sources(&self, lanes: &Lanes) -> Result<Vec<Box<dyn Source>>, ClawError> {
|
fn sources(&self, lanes: &Lanes) -> Result<Vec<Box<dyn Source>>, ClawError> {
|
||||||
let source =
|
let source = CalendarSource::from_store(
|
||||||
CalendarSource::new(&lanes.interactive, Arc::new(SystemClock), &self.calendars)
|
&lanes.interactive,
|
||||||
.map_err(ClawError::Backend)?;
|
Arc::new(SystemClock),
|
||||||
|
self.calendars.clone(),
|
||||||
|
);
|
||||||
Ok(vec![Box::new(source)])
|
Ok(vec![Box::new(source)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +67,9 @@ pub struct ClawState {
|
|||||||
driver: Arc<Driver>,
|
driver: Arc<Driver>,
|
||||||
store: Arc<SqliteStore>,
|
store: Arc<SqliteStore>,
|
||||||
watches: Arc<ClawStore>,
|
watches: Arc<ClawStore>,
|
||||||
|
/// The calendar table: `/v1/calendars` writes it, the reminders
|
||||||
|
/// source reads it fresh every pass.
|
||||||
|
calendars: Arc<rhema_calendar_integration::CalendarStore>,
|
||||||
notifications: Arc<NotificationStore>,
|
notifications: Arc<NotificationStore>,
|
||||||
/// The scheduled-task table the `/v1/tasks` routes serve — the same
|
/// The scheduled-task table the `/v1/tasks` routes serve — the same
|
||||||
/// rows the resident scheduler fires.
|
/// rows the resident scheduler fires.
|
||||||
@@ -117,6 +120,7 @@ impl ClawState {
|
|||||||
driver: Arc<Driver>,
|
driver: Arc<Driver>,
|
||||||
store: Arc<SqliteStore>,
|
store: Arc<SqliteStore>,
|
||||||
watches: Arc<ClawStore>,
|
watches: Arc<ClawStore>,
|
||||||
|
calendars: Arc<rhema_calendar_integration::CalendarStore>,
|
||||||
notifications: Arc<NotificationStore>,
|
notifications: Arc<NotificationStore>,
|
||||||
tasks: Arc<TaskStore>,
|
tasks: Arc<TaskStore>,
|
||||||
bootstrap: Arc<Bootstrap>,
|
bootstrap: Arc<Bootstrap>,
|
||||||
@@ -138,6 +142,7 @@ impl ClawState {
|
|||||||
driver,
|
driver,
|
||||||
store,
|
store,
|
||||||
watches,
|
watches,
|
||||||
|
calendars,
|
||||||
notifications,
|
notifications,
|
||||||
tasks,
|
tasks,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
@@ -232,6 +237,12 @@ pub fn router(state: ClawState) -> Router {
|
|||||||
.route("/bootstrap", get(bootstrap_status))
|
.route("/bootstrap", get(bootstrap_status))
|
||||||
.route("/watches", get(watches_list).post(watches_add))
|
.route("/watches", get(watches_list).post(watches_add))
|
||||||
.route("/watches/{forge}/{*repo}", delete(watches_remove))
|
.route("/watches/{forge}/{*repo}", delete(watches_remove))
|
||||||
|
.route(
|
||||||
|
"/calendars",
|
||||||
|
get(calendars_list)
|
||||||
|
.post(calendars_add)
|
||||||
|
.delete(calendars_remove),
|
||||||
|
)
|
||||||
.route("/accounts", get(accounts_list).post(accounts_add))
|
.route("/accounts", get(accounts_list).post(accounts_add))
|
||||||
.route("/accounts/{id}", delete(accounts_remove))
|
.route("/accounts/{id}", delete(accounts_remove))
|
||||||
.route("/consents", get(consents_list))
|
.route("/consents", get(consents_list))
|
||||||
@@ -282,7 +293,7 @@ pub fn router(state: ClawState) -> Router {
|
|||||||
Json(json!({
|
Json(json!({
|
||||||
"service": "rhema-claw",
|
"service": "rhema-claw",
|
||||||
"chat": "/v1/sessions",
|
"chat": "/v1/sessions",
|
||||||
"api": ["/v1/sessions", "/v1/providers", "/v1/watches",
|
"api": ["/v1/sessions", "/v1/providers", "/v1/watches", "/v1/calendars",
|
||||||
"/v1/accounts", "/v1/notifications", "/v1/phones",
|
"/v1/accounts", "/v1/notifications", "/v1/phones",
|
||||||
"/v1/bootstrap", "/v1/ingest"],
|
"/v1/bootstrap", "/v1/ingest"],
|
||||||
}))
|
}))
|
||||||
@@ -618,6 +629,74 @@ async fn events(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/calendars`: one calendar spec, the integration's
|
||||||
|
/// `CalendarWatch` shape, validated at the table's door — the same
|
||||||
|
/// rules the config's boot check used to run.
|
||||||
|
async fn calendars_add(
|
||||||
|
State(state): State<ClawState>,
|
||||||
|
Json(body): Json<rhema_calendar_integration::CalendarWatch>,
|
||||||
|
) -> Response {
|
||||||
|
match state.calendars.add(&body) {
|
||||||
|
Ok(true) => (
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(json!({"added": body.name.trim()})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Ok(false) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"added": body.name.trim(),
|
||||||
|
"note": "a calendar with that name already exists"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(err) => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": err.to_string()})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /v1/calendars?name=…`: forget a calendar; the reminders
|
||||||
|
/// source stops polling it on the next tick.
|
||||||
|
async fn calendars_remove(
|
||||||
|
State(state): State<ClawState>,
|
||||||
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let Some(name) = params
|
||||||
|
.get("name")
|
||||||
|
.map(|n| n.trim())
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
else {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "name is required"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
match state.calendars.remove(name) {
|
||||||
|
Ok(removed) => Json(json!({"removed": removed, "name": name})).into_response(),
|
||||||
|
Err(err) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": err.to_string()})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /v1/calendars`: every calendar, in creation order.
|
||||||
|
async fn calendars_list(State(state): State<ClawState>) -> Response {
|
||||||
|
match state.calendars.list() {
|
||||||
|
Ok(list) => Json(json!({"calendars": list})).into_response(),
|
||||||
|
Err(err) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": err.to_string()})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One `TurnEvent` as an SSE frame: `data: {json}`. A lagged subscriber
|
/// One `TurnEvent` as an SSE frame: `data: {json}`. A lagged subscriber
|
||||||
/// gets a comment naming the drop — live delivery is best-effort; the
|
/// gets a comment naming the drop — live delivery is best-effort; the
|
||||||
/// record replays what completed. A serialization failure is logged with
|
/// record replays what completed. A serialization failure is logged with
|
||||||
|
|||||||
@@ -694,7 +694,12 @@ mod tests {
|
|||||||
identity,
|
identity,
|
||||||
hub,
|
hub,
|
||||||
workspace: workspace.to_path_buf(),
|
workspace: workspace.to_path_buf(),
|
||||||
calendars: Vec::new(),
|
// The test assembly"s calendar table: in-memory, and
|
||||||
|
// empty — the interactive tests do not remind.
|
||||||
|
calendars: std::sync::Arc::new(
|
||||||
|
rhema_calendar_integration::CalendarStore::in_memory()
|
||||||
|
.expect("the calendar table opens"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ fn config(dir: &tempfile::TempDir) -> ClawConfig {
|
|||||||
tools_bin: None,
|
tools_bin: None,
|
||||||
transcript: None,
|
transcript: None,
|
||||||
ingest_token: None,
|
ingest_token: None,
|
||||||
calendars: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ fn config(dir: &tempfile::TempDir) -> ClawConfig {
|
|||||||
tools_bin: None,
|
tools_bin: None,
|
||||||
transcript: None,
|
transcript: None,
|
||||||
ingest_token: None,
|
ingest_token: None,
|
||||||
calendars: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ fn config(dir: &tempfile::TempDir, token: Option<&str>) -> ClawConfig {
|
|||||||
tools_bin: None,
|
tools_bin: None,
|
||||||
ingest_token: None,
|
ingest_token: None,
|
||||||
transcript: None,
|
transcript: None,
|
||||||
calendars: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,6 +892,89 @@ async fn a_configured_token_guards_v1_and_leaves_healthz_open() {
|
|||||||
assert_eq!(whole["choices"][0]["message"]["content"], "on it");
|
assert_eq!(whole["choices"][0]["message"]["content"], "on it");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The calendar table, over its door: add, list, refuse the invalid
|
||||||
|
/// loudly, remove — and the route shares the table the reminders
|
||||||
|
/// source reads, so no restart is in the story.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn calendars_round_trip_over_their_door() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let daemon = Daemon::build(
|
||||||
|
&config(&dir, None),
|
||||||
|
mock_registry(Arc::new(MockProvider::new(Vec::new()))),
|
||||||
|
rhema_rsd::DispatcherConfig::default(),
|
||||||
|
)
|
||||||
|
.expect("the daemon assembles");
|
||||||
|
let base = serve(&daemon).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// A valid ics lands.
|
||||||
|
let res = client
|
||||||
|
.post(format!("{base}/v1/calendars"))
|
||||||
|
.json(&json!({"name": "team", "kind": "ics", "url": "https://c.example.net/a.ics"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("the post answers");
|
||||||
|
assert_eq!(res.status(), 201, "a new calendar is created");
|
||||||
|
|
||||||
|
// The invalid is loud, wherever it would enter.
|
||||||
|
let res = client
|
||||||
|
.post(format!("{base}/v1/calendars"))
|
||||||
|
.json(&json!({"name": "broken", "kind": "ics"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("the post answers");
|
||||||
|
assert_eq!(
|
||||||
|
res.status(),
|
||||||
|
400,
|
||||||
|
"an urlless ics is refused: {}",
|
||||||
|
res.status()
|
||||||
|
);
|
||||||
|
let err: serde_json::Value = res.json().await.expect("the error parses");
|
||||||
|
assert!(
|
||||||
|
err["error"].as_str().unwrap().contains("ics is a feed URL"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The list carries what landed, and nothing else.
|
||||||
|
let listed: serde_json::Value = client
|
||||||
|
.get(format!("{base}/v1/calendars"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("the list answers")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("the list parses");
|
||||||
|
let names: Vec<&str> = listed["calendars"]
|
||||||
|
.as_array()
|
||||||
|
.expect("an array")
|
||||||
|
.iter()
|
||||||
|
.map(|c| c["name"].as_str().expect("a name"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, vec!["team"], "{listed}");
|
||||||
|
|
||||||
|
// Removal is a delete on the name; the next pass simply has one
|
||||||
|
// fewer feed.
|
||||||
|
let res = client
|
||||||
|
.delete(format!("{base}/v1/calendars?name=team"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("the delete answers");
|
||||||
|
assert_eq!(res.status(), 200);
|
||||||
|
let listed: serde_json::Value = client
|
||||||
|
.get(format!("{base}/v1/calendars"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("the list answers")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("the list parses");
|
||||||
|
assert_eq!(
|
||||||
|
listed["calendars"].as_array().map(Vec::len),
|
||||||
|
Some(0),
|
||||||
|
"{listed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The ingest door: an external event lands on its key in the resident
|
/// The ingest door: an external event lands on its key in the resident
|
||||||
/// dispatcher, an identical redelivery dedups, the daemon's own source
|
/// dispatcher, an identical redelivery dedups, the daemon's own source
|
||||||
/// names cannot be re-registered, and the bearer guard covers it like
|
/// names cannot be re-registered, and the bearer guard covers it like
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ authors.workspace = true
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
# The reminder source: one `Source` over the feeds, detect-pass shaped
|
# The reminder source: one `Source` over the feeds, detect-pass shaped
|
||||||
# like the task scheduler's
|
# like the task scheduler's
|
||||||
rhema-autonomous-agent = { path = "../../crates/rhema-autonomous-agent", version = "0.1.0" }
|
rhema-autonomous-agent = { path = "../../crates/rhema-autonomous-agent", version = "0.1.0" }
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ pub mod event;
|
|||||||
pub mod google;
|
pub mod google;
|
||||||
pub mod ics;
|
pub mod ics;
|
||||||
pub mod reminder;
|
pub mod reminder;
|
||||||
|
pub mod store;
|
||||||
|
pub use store::CalendarStore;
|
||||||
|
|
||||||
// Re-exported so implementors of the async seam can apply #[async_trait]
|
// Re-exported so implementors of the async seam can apply #[async_trait]
|
||||||
// without adding the crate themselves.
|
// 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.
|
/// One configured calendar, resolved to a client the source polls.
|
||||||
|
#[derive(Clone)]
|
||||||
pub(crate) struct Feed {
|
pub(crate) struct Feed {
|
||||||
name: String,
|
name: String,
|
||||||
client: Arc<dyn Calendar>,
|
client: Arc<dyn Calendar>,
|
||||||
@@ -236,7 +237,15 @@ fn nonempty(value: Option<&str>) -> Option<&str> {
|
|||||||
pub struct CalendarSource {
|
pub struct CalendarSource {
|
||||||
dispatcher: Arc<Dispatcher>,
|
dispatcher: Arc<Dispatcher>,
|
||||||
clock: Arc<dyn Clock>,
|
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 {
|
impl CalendarSource {
|
||||||
@@ -262,10 +271,37 @@ impl CalendarSource {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
dispatcher: dispatcher.clone(),
|
dispatcher: dispatcher.clone(),
|
||||||
clock,
|
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
|
/// A source over ready-made feeds. Tests use this; production
|
||||||
/// builds from the config with [`CalendarSource::new`].
|
/// builds from the config with [`CalendarSource::new`].
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -278,11 +314,30 @@ impl CalendarSource {
|
|||||||
Self {
|
Self {
|
||||||
dispatcher: dispatcher.clone(),
|
dispatcher: dispatcher.clone(),
|
||||||
clock,
|
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]
|
#[async_trait]
|
||||||
impl Source for CalendarSource {
|
impl Source for CalendarSource {
|
||||||
fn name(&self) -> &str {
|
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)
|
let now = OffsetDateTime::from_unix_timestamp_nanos(now_ms as i128 * 1_000_000)
|
||||||
.map_err(|err| SourceError::Backend(format!("clock: {err}")))?;
|
.map_err(|err| SourceError::Backend(format!("clock: {err}")))?;
|
||||||
let mut fed = 0;
|
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(
|
let window = Window::new(
|
||||||
now,
|
now,
|
||||||
now + time::Duration::hours(i64::from(feed.horizon_hours)),
|
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.
|
/// One watched calendar. Unknown keys are rejected, as on a watch.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
#[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