refactor(calendar): move the reminder source beside the feeds

CalendarWatch, the client resolution, and the slot policy were all
feed domain living in the claw's config and source tree; only the
extension adapter was the claw's. The reminder source now ships in
the integration as the default answer to "what does a start time
mean": one slot per lead time, calendar:<name> keys, fed to the
consumer's dispatcher. The crate doc drops its no-policy claim to
match, and the claw's resident prefixes take the calendar prefix
from the integration so the spelling cannot drift.
This commit is contained in:
vhaudiquet committed 2026-10-20 05:53:43 +02:00
1 parent fc0cbbcb6b
commit b70a56c325
7 files changed
+251 -218

No files matched your search

-546
View File
@@ -1,546 +0,0 @@
//! The calendar source: deterministic detection of due reminders, the
//! task scheduler's shape over a calendar feed.
//!
//! The [`CalendarSource`] is a [`Source`] named `"calendars"` on the
//! resident dispatcher: its `detect` pass polls every configured
//! calendar for the occurrences starting within its horizon and
//! ingests one event per due reminder slot, a (occurrence, lead time)
//! pair whose moment has come. The dedup id carries all three facts
//! (`{calendar}:{occurrence id}:{lead}`), so a slot feeds once and its
//! repeats dedup away in the dispatcher, and the next lead's slot is a
//! different id firing later. A slot whose start has passed is never
//! fed: a reminder never fires after the fact. All-day occurrences
//! never fire: a lead in minutes against a midnight start is noise,
//! and a date-shaped nudge is what the task tools are for.
//!
//! The model is never reached here; the turn happens because the
//! dispatcher admitted the `calendar:<name>` key, exactly as for a
//! task firing, and the resident handler's delivery rule auto-notifies
//! the final text to the user's devices. One session per calendar
//! persists across its reminders, so the claw remembers what it said
//! about the last one. A dead feed is logged and skipped, never fatal:
//! an outage must not eat the other calendars' reminders on the same
//! pass. Cadence belongs to the daemon's loop.
use std::sync::Arc;
use rhema_autonomous_agent::{Source, SourceError, async_trait};
use rhema_calendar_integration::{
CaldavCalendar, Calendar, CalendarEvent, GoogleCalendar, IcsCalendar, Window,
};
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn};
use serde_json::json;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::config::{CalendarKind, CalendarWatch};
use crate::interactive::passthrough_key_fn;
/// The source name the calendar reminders ingest under; registered on
/// the resident dispatcher, so allowlists and `/v1/ingest` can name it.
pub const CALENDARS_SOURCE: &str = "calendars";
/// The env variable a calendar's token falls back to.
pub const TOKEN_ENV: &str = "CALENDAR_TOKEN";
/// The env variable a CalDAV password falls back to.
pub const CALDAV_PASSWORD_ENV: &str = "CALDAV_PASSWORD";
/// One calendar's reminder key: `calendar:<name>` — the session the
/// reminder turns run in, persistent across the calendar's reminders.
pub fn calendar_key(name: &str) -> String {
format!("calendar:{name}")
}
/// One configured calendar, resolved to a client the source polls.
pub(crate) struct Feed {
name: String,
client: Arc<dyn Calendar>,
leads: Vec<u64>,
horizon_hours: u32,
}
/// The client one calendar watch polls through. Credentials resolve
/// before construction: the api key wins for Google, else the token
/// (config first, then [`TOKEN_ENV`]), and a feed takes its token or
/// polls anonymous, which is how most published feed URLs work.
fn client_for(calendar: &CalendarWatch) -> Result<Arc<dyn Calendar>, String> {
match calendar.kind {
CalendarKind::Google => {
let id = calendar
.calendar_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
.ok_or_else(|| "google needs calendar_id".to_owned())?;
if let Some(key) = calendar.resolved_api_key() {
return Ok(Arc::new(GoogleCalendar::with_api_key(key, id)));
}
let token = calendar
.resolved_token()
.ok_or_else(|| "google needs api_key or token".to_owned())?;
Ok(Arc::new(GoogleCalendar::new(token, id)))
}
CalendarKind::Ics => {
let url = calendar
.url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())
.ok_or_else(|| "ics needs url".to_owned())?;
let mut feed = IcsCalendar::new(url);
if let Some(token) = calendar.resolved_token() {
feed = feed.with_token(token);
}
Ok(Arc::new(feed))
}
CalendarKind::Caldav => {
let url = calendar
.url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())
.ok_or_else(|| "caldav needs url".to_owned())?;
let mut dav = CaldavCalendar::new(url);
// A username means Basic auth, the self-hosted norm, and
// the password must have resolved at validation; a token
// without one is the bearer spelling; neither polls
// anonymous, which is what open servers take.
if let Some(user) = calendar
.username
.as_deref()
.map(str::trim)
.filter(|user| !user.is_empty())
{
let password = calendar.resolved_password().ok_or_else(|| {
"caldav needs a password: set password or CALDAV_PASSWORD".to_owned()
})?;
dav = dav.with_basic(user, password);
} else if let Some(token) = calendar.resolved_token() {
dav = dav.with_token(token);
}
Ok(Arc::new(dav))
}
}
}
/// The reminder slots of one poll's events: (event, lead) pairs that
/// are due, in feed order, leads in configured order. Due means the
/// lead's warning moment has arrived and the event has not started;
/// all-day events and started events contribute nothing.
pub(crate) fn due_slots<'a>(
events: &'a [CalendarEvent],
leads: &[u64],
now: OffsetDateTime,
) -> Vec<(&'a CalendarEvent, u64)> {
let now_secs = now.unix_timestamp();
let mut slots = Vec::new();
for event in events {
if event.all_day {
continue;
}
let start = event.start.unix_timestamp();
if start <= now_secs {
continue;
}
for lead in leads {
if start - i64::try_from(*lead).unwrap_or(i64::MAX) * 60 <= now_secs {
slots.push((event, *lead));
}
}
}
slots
}
/// The event one due slot becomes: keyed to the calendar's session,
/// its dedup id naming the calendar, the occurrence, and the lead, so
/// a refired slot dedups and the next lead does not.
pub(crate) fn reminder_event(
calendar: &str,
event: &CalendarEvent,
lead: u64,
now: OffsetDateTime,
received_at: u64,
) -> Event {
// The start is strictly future at this point, so the count is at
// least one.
let minutes = ((event.start - now).whole_seconds() + 59) / 60;
let title = match event.title.trim() {
"" => "(untitled)",
trimmed => trimmed,
};
let start = event.start.format(&Rfc3339).unwrap_or_default();
let mut lines = vec![
format!(
"Calendar reminder from the daemon (source `{CALENDARS_SOURCE}`), not a user message."
),
format!("Calendar {calendar:?}: {title:?} starts in {minutes} minutes at {start}."),
];
if let Some(location) = nonempty(event.location.as_deref()) {
lines.push(format!("Where: {location}"));
}
if let Some(notes) = nonempty(event.description.as_deref()) {
lines.push(format!("Notes: {notes}"));
}
if let Some(url) = nonempty(event.url.as_deref()) {
lines.push(format!("Link: {url}"));
}
if !event.attendees.is_empty() {
lines.push(format!("Attendees: {}", event.attendees.join(", ")));
}
if let Some(organizer) = nonempty(event.organizer.as_deref()) {
lines.push(format!("Organizer: {organizer}"));
}
lines.push(
"Reply with one short line telling the user; this session's reply \
auto-notifies their devices."
.to_owned(),
);
Event {
id: format!("{calendar}:{}:{lead}", event.id),
source: CALENDARS_SOURCE.to_owned(),
payload: json!({ "key": calendar_key(calendar), "prompt": lines.join("\n") }),
received_at,
}
}
/// A trimmed string, or `None` when it is all whitespace.
fn nonempty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
/// The detectable stream of calendar reminders: every configured
/// calendar, polled on the daemon's tick, feeding the resident
/// dispatcher.
pub struct CalendarSource {
dispatcher: Arc<Dispatcher>,
clock: Arc<dyn Clock>,
feeds: Vec<Feed>,
}
impl CalendarSource {
/// Registers the `calendars` source's key function on `dispatcher`
/// and resolves `calendars` into clients. Config validation ran at
/// load; a calendar that still fails here names itself in the
/// error, and the daemon refuses to start.
pub fn new(
dispatcher: &Arc<Dispatcher>,
clock: Arc<dyn Clock>,
calendars: &[CalendarWatch],
) -> Result<Self, String> {
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(),
});
}
dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn());
Ok(Self {
dispatcher: dispatcher.clone(),
clock,
feeds,
})
}
/// A source over ready-made feeds. Tests use this; production
/// builds from the config with [`CalendarSource::new`].
#[cfg(test)]
pub(crate) fn from_feeds(
dispatcher: &Arc<Dispatcher>,
clock: Arc<dyn Clock>,
feeds: Vec<Feed>,
) -> Self {
dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn());
Self {
dispatcher: dispatcher.clone(),
clock,
feeds,
}
}
}
#[async_trait]
impl Source for CalendarSource {
fn name(&self) -> &str {
CALENDARS_SOURCE
}
fn key_fn(&self) -> Arc<KeyFn> {
passthrough_key_fn()
}
async fn detect(&self) -> Result<usize, SourceError> {
let now_ms = self.clock.now_millis() as i64;
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 window = Window::new(
now,
now + time::Duration::hours(i64::from(feed.horizon_hours)),
);
let events = match feed.client.events(&window).await {
Ok(events) => events,
// A dead feed is this tick's loss, not the daemon's:
// the next pass polls it again, and the other
// calendars' reminders never wait behind it.
Err(err) => {
eprintln!("[claw] warning: calendar {} failed: {err}", feed.name);
continue;
}
};
for (event, lead) in due_slots(&events, &feed.leads, now) {
self.dispatcher.ingest(reminder_event(
&feed.name,
event,
lead,
now,
now_ms as u64,
))?;
fed += 1;
}
}
Ok(fed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rhema_rsd::{DispatcherConfig, FixedClock, ResourceKey, SystemClock, VolatileStore};
/// 2026-10-20T00:00:00Z, in milliseconds.
const NOW_MS: i64 = 1_792_444_800_000;
fn dispatcher() -> Arc<Dispatcher> {
Arc::new(Dispatcher::new(
DispatcherConfig {
debounce_window_millis: 0,
..DispatcherConfig::default()
},
Arc::new(SystemClock),
Arc::new(VolatileStore::new()),
))
}
/// A feed that answers with fixed events, whatever the window.
struct Fixed(Vec<CalendarEvent>);
#[async_trait]
impl Calendar for Fixed {
async fn events(
&self,
_window: &Window,
) -> Result<Vec<CalendarEvent>, rhema_calendar_integration::CalendarError> {
Ok(self.0.clone())
}
}
fn event(id: &str, title: &str, minutes_from_now: i64) -> CalendarEvent {
let start = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000 + minutes_from_now * 60)
.expect("timestamp");
CalendarEvent {
id: format!("ics:{id}@{}", start.unix_timestamp()),
title: title.to_owned(),
start,
end: Some(start + time::Duration::minutes(30)),
all_day: false,
location: None,
description: None,
attendees: Vec::new(),
organizer: None,
url: None,
raw: serde_json::json!({}),
}
}
fn source(
dispatcher: &Arc<Dispatcher>,
clock: Arc<dyn Clock>,
events: Vec<CalendarEvent>,
) -> CalendarSource {
CalendarSource::from_feeds(
dispatcher,
clock,
vec![Feed {
name: "home".to_owned(),
client: Arc::new(Fixed(events)),
leads: vec![60, 15],
horizon_hours: 24,
}],
)
}
fn inbox_depth(dispatcher: &Dispatcher) -> usize {
dispatcher
.status(&ResourceKey::from(calendar_key("home")))
.expect("status reads")
.inbox_depth
}
#[tokio::test]
async fn a_due_slot_fires_once_and_stays_deduped() {
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
let dispatcher = dispatcher();
let source = source(
&dispatcher,
clock.clone(),
vec![event("standup", "standup", 90)],
);
// Ninety minutes out, neither lead is due yet.
assert_eq!(source.detect().await.expect("detect runs"), 0);
// Sixty minutes out the first slot is due: one event on the
// calendar's key.
clock.advance(30 * 60 * 1000);
assert_eq!(source.detect().await.expect("detect runs"), 1);
assert_eq!(inbox_depth(&dispatcher), 1);
// The next pass replays the same dedup id: fed, deduped away.
assert_eq!(source.detect().await.expect("detect runs"), 1);
assert_eq!(inbox_depth(&dispatcher), 1);
}
#[tokio::test]
async fn every_lead_fires_once_at_its_own_moment() {
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
let dispatcher = dispatcher();
// Starts in 90 minutes: the 60 lead is not due yet.
let source = source(&dispatcher, clock.clone(), vec![event("demo", "demo", 90)]);
assert_eq!(source.detect().await.expect("detect runs"), 0);
// At 60 minutes out the first slot fires.
clock.advance(30 * 60 * 1000);
assert_eq!(source.detect().await.expect("detect runs"), 1);
// At 15 minutes out the second slot fires under its own id.
// The count includes the first slot's replay, which dedups:
// the inbox holds one event per lead, never more.
clock.advance(45 * 60 * 1000);
assert_eq!(source.detect().await.expect("detect runs"), 2);
assert_eq!(inbox_depth(&dispatcher), 2);
// Past the start: nothing more, ever.
clock.advance(20 * 60 * 1000);
assert_eq!(source.detect().await.expect("detect runs"), 0);
assert_eq!(inbox_depth(&dispatcher), 2);
}
#[tokio::test]
async fn a_slot_first_seen_late_fires_late_once() {
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
let dispatcher = dispatcher();
// The daemon only meets this event 15 minutes before it
// starts: the 60 slot's moment came and went unseen, the 15
// slot is due. Both are fed on that first pass, then dedup.
let source = source(&dispatcher, clock.clone(), vec![event("late", "late", 15)]);
assert_eq!(source.detect().await.expect("detect runs"), 2);
assert_eq!(source.detect().await.expect("detect runs"), 2);
assert_eq!(inbox_depth(&dispatcher), 2);
}
#[tokio::test]
async fn a_started_or_all_day_event_never_fires() {
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
let dispatcher = dispatcher();
let mut all_day = event("offsite", "offsite", 10);
all_day.all_day = true;
let started = event("past", "already going", -5);
let source = source(&dispatcher, clock.clone(), vec![all_day, started]);
assert_eq!(source.detect().await.expect("detect runs"), 0);
assert_eq!(inbox_depth(&dispatcher), 0);
}
#[tokio::test]
async fn a_dead_feed_is_skipped_not_fatal() {
struct Dead;
#[async_trait]
impl Calendar for Dead {
async fn events(
&self,
_window: &Window,
) -> Result<Vec<CalendarEvent>, rhema_calendar_integration::CalendarError> {
Err(rhema_calendar_integration::CalendarError::Network(
"down".to_owned(),
))
}
}
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
let dispatcher = dispatcher();
let source = CalendarSource::from_feeds(
&dispatcher,
clock.clone(),
vec![
Feed {
name: "dead".to_owned(),
client: Arc::new(Dead),
leads: vec![15],
horizon_hours: 24,
},
Feed {
name: "alive".to_owned(),
client: Arc::new(Fixed(vec![event("alive-event", "alive", 10)])),
leads: vec![15],
horizon_hours: 24,
},
],
);
assert_eq!(source.detect().await.expect("detect runs"), 1);
assert_eq!(
dispatcher
.status(&ResourceKey::from(calendar_key("alive")))
.expect("status reads")
.inbox_depth,
1
);
}
#[test]
fn the_prompt_names_everything_a_reply_needs() {
let now = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000).expect("timestamp");
let mut event = event("dentist", "dentist", 15);
event.location = Some("rue de la Paix".to_owned());
event.description = Some("cleaning".to_owned());
event.attendees = vec!["dr@example.net".to_owned()];
event.organizer = Some("dr@example.net".to_owned());
event.url = Some("https://meet.example.net/x".to_owned());
let slot = reminder_event("home", &event, 15, now, NOW_MS as u64);
assert_eq!(slot.source, CALENDARS_SOURCE);
assert_eq!(
slot.id,
format!("home:{}:15", event.id),
"the dedup id names calendar, occurrence, and lead"
);
assert_eq!(slot.payload["key"], json!(calendar_key("home")));
let prompt = slot.payload["prompt"].as_str().expect("prompt");
assert!(prompt.contains("\"dentist\""), "got: {prompt}");
assert!(prompt.contains("in 15 minutes"), "got: {prompt}");
assert!(prompt.contains("home"), "got: {prompt}");
assert!(prompt.contains("rue de la Paix"), "got: {prompt}");
assert!(prompt.contains("cleaning"), "got: {prompt}");
assert!(prompt.contains("dr@example.net"), "got: {prompt}");
assert!(
prompt.contains("https://meet.example.net/x"),
"got: {prompt}"
);
assert!(prompt.contains("auto-notifies"), "got: {prompt}");
}
#[test]
fn an_untitled_event_still_reads_as_a_reminder() {
let now = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000).expect("timestamp");
let event = event("bare", " ", 3);
let slot = reminder_event("home", &event, 15, now, NOW_MS as u64);
let prompt = slot.payload["prompt"].as_str().expect("prompt");
assert!(prompt.contains("(untitled)"), "got: {prompt}");
assert!(prompt.contains("in 3 minutes"), "got: {prompt}");
}
}
+6 -169
View File
@@ -15,6 +15,12 @@ use std::path::PathBuf;
use rhema_forge_integration::RepoRef;
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
/// [`ClawConfig::poll_interval_secs`] is unset.
pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 60;
@@ -29,16 +35,6 @@ pub const DEFAULT_MAX_TURN_STEPS: u32 = 2048;
/// loopback only — the surface is for the machine's resident user.
pub const DEFAULT_LISTEN: &str = "127.0.0.1:7799";
/// The one reminder lead, in minutes, a calendar carries when
/// [`CalendarWatch::lead_minutes`] is unset: warn shortly before, and
/// let a busier lead list be configured where wanted.
pub const DEFAULT_LEAD_MINUTES: u64 = 15;
/// How far ahead a calendar polls, in hours, when
/// [`CalendarWatch::horizon_hours`] is unset. Must exceed the longest
/// lead: a reminder cannot fire before the event is visible.
pub const DEFAULT_HORIZON_HOURS: u32 = 24;
/// Why a config operation failed.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
@@ -221,165 +217,6 @@ pub fn resolve_token(
})
}
/// Which calendar feed a calendar watch polls. The wire spelling is
/// the source name the dispatcher registers and the reminder prompt
/// names.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CalendarKind {
/// An iCalendar feed over HTTP (`webcal://` counts): Google's
/// secret iCal address, a Nextcloud or Radicale share, Fastmail.
Ics,
/// A CalDAV collection, polled with a `calendar-query` REPORT:
/// Nextcloud, Radicale, Fastmail, a plain DAV server.
Caldav,
/// The Google Calendar API, against an OAuth access token or an
/// API key for public calendars.
Google,
}
impl CalendarKind {
/// The spelling the config, the reminder source, and the prompts
/// use.
pub fn name(self) -> &'static str {
match self {
CalendarKind::Ics => "ics",
CalendarKind::Caldav => "caldav",
CalendarKind::Google => "google",
}
}
/// Parses a calendar kind as the config spells it. `None` for
/// anything else.
pub fn parse(name: &str) -> Option<Self> {
match name.trim() {
"ics" => Some(CalendarKind::Ics),
"caldav" => Some(CalendarKind::Caldav),
"google" => Some(CalendarKind::Google),
_ => None,
}
}
}
/// 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)]
pub struct CalendarWatch {
/// The calendar's name: unique across the list; the reminder key
/// is `calendar:<name>` and prompts name the calendar by it.
pub name: String,
/// Which feed kind this polls.
pub kind: CalendarKind,
/// The feed URL (`https://` or `webcal://`). Required for ics.
pub url: Option<String>,
/// The Google calendar id (`primary`, an address, a hex id).
/// Required for google.
pub calendar_id: Option<String>,
/// A bearer token (an OAuth access token for Google, auth for a
/// feed or collection that demands it). Falls back to
/// `CALENDAR_TOKEN`.
pub token: Option<String>,
/// The CalDAV user, for Basic auth. Set only with a password (or
/// `CALDAV_PASSWORD`); a calendar with a username authenticates
/// Basic, and `token` is the fallback only when there is none.
pub username: Option<String>,
/// The CalDAV password or app-specific password. Falls back to
/// `CALDAV_PASSWORD`.
pub password: Option<String>,
/// A Google API key for a public calendar; wins over `token`.
pub api_key: Option<String>,
/// Reminder lead times, minutes; each occurrence warns once per
/// lead. `[DEFAULT_LEAD_MINUTES]` when unset.
pub lead_minutes: Option<Vec<u64>>,
/// How far ahead the poll looks, hours. `[DEFAULT_HORIZON_HOURS]`
/// when unset; must exceed the longest lead.
pub horizon_hours: Option<u32>,
}
impl CalendarWatch {
/// The reminder leads as configured, blank and zero entries
/// dropped; [`DEFAULT_LEAD_MINUTES`] alone when nothing usable is
/// configured.
pub fn leads(&self) -> Vec<u64> {
match self.lead_minutes.as_ref() {
Some(leads) => {
let usable: Vec<u64> = leads.iter().copied().filter(|lead| *lead > 0).collect();
if usable.is_empty() {
vec![DEFAULT_LEAD_MINUTES]
} else {
usable
}
}
None => vec![DEFAULT_LEAD_MINUTES],
}
}
/// The poll horizon as configured, [`DEFAULT_HORIZON_HOURS`] when
/// unset. Zero reads as the default: a horizon of nothing polls
/// nothing, and the config validation refuses it when it would
/// matter.
pub fn horizon(&self) -> u32 {
self.horizon_hours
.filter(|hours| *hours > 0)
.unwrap_or(DEFAULT_HORIZON_HOURS)
}
/// A trimmed `token`/`api_key` value; blank counts as unset.
fn trimmed(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
/// The API key as configured, if any.
pub fn resolved_api_key(&self) -> Option<String> {
Self::trimmed(self.api_key.as_deref())
}
/// The CalDAV password to authenticate with: the config value,
/// then `CALDAV_PASSWORD`. Parameterized over the env lookup in
/// the pure [`CalendarWatch::resolve_password_with`].
pub fn resolved_password(&self) -> Option<String> {
Self::resolve_password_with(|name| std::env::var(name), self.password.as_deref())
}
/// Pure password resolution: the config value first, then
/// `CALDAV_PASSWORD`. Parameterized over the env lookup so tests
/// can hold the environment still.
pub fn resolve_password_with(
get_env: impl Fn(&str) -> Result<String, std::env::VarError>,
stored: Option<&str>,
) -> Option<String> {
Self::trimmed(stored).or_else(|| {
get_env("CALDAV_PASSWORD")
.ok()
.and_then(|token| Self::trimmed(Some(&token)))
})
}
/// The token to poll with: the config value, then `CALENDAR_TOKEN`;
/// the first non-blank wins. Parameterized over the env lookup in
/// the pure [`CalendarWatch::resolve_token_with`].
pub fn resolved_token(&self) -> Option<String> {
Self::resolve_token_with(|name| std::env::var(name), self.token.as_deref())
}
/// Pure token resolution: the config value first, then
/// `CALENDAR_TOKEN`. Parameterized over the env lookup so tests can
/// hold the environment still.
pub fn resolve_token_with(
get_env: impl Fn(&str) -> Result<String, std::env::VarError>,
stored: Option<&str>,
) -> Option<String> {
Self::trimmed(stored).or_else(|| {
get_env("CALENDAR_TOKEN")
.ok()
.and_then(|token| Self::trimmed(Some(&token)))
})
}
}
/// Whether a `host:port` listen address binds beyond loopback.
/// `localhost` (any case) and any loopback IP literal — `127.x.x.x`,
/// `::1` — are loopback; an empty host (`:7799`, the bind-everything
+1 -1
View File
@@ -36,12 +36,12 @@ use rhema_autonomous_agent::{Allowlist, Source, ToolRegistry};
use rhema_core::ToolResolver;
use rhema_rsd::{Dispatcher, ResourceKey, SystemClock};
use crate::calendar::CalendarSource;
use crate::config::{CalendarWatch, ForgeKind};
use crate::consents::WaitBridge;
use crate::daemon::ClawError;
use crate::forge::ForgeSource;
use crate::hub::EventHub;
use rhema_calendar_integration::CalendarSource;
/// The store handles the standard set is assembled over. One struct,
/// so a new extension is a field plus a constructor line, not another
+6 -1
View File
@@ -73,7 +73,12 @@ use crate::notifications::NotificationStore;
/// `interactive:`, `task:`, `phone:`, and `calendar:`. Forge keys —
/// they carry `#` — belong to the forge handler; these four and
/// nothing else belong to this one.
pub const RESIDENT_PREFIXES: [&str; 4] = ["interactive:", "task:", "phone:", "calendar:"];
pub const RESIDENT_PREFIXES: [&str; 4] = [
"interactive:",
"task:",
"phone:",
rhema_calendar_integration::reminder::KEY_PREFIX,
];
/// The prefix of the live chat surface's keys: `interactive:<name>` —
/// the name is the caller's label for the session (`web`, a chat
-4
View File
@@ -49,8 +49,6 @@
//! the standard set — the tool chain, the detect passes, and the
//! boot pass all iterate it
//! - [`forge`] — the sources: one detectable stream per forge kind
//! - [`calendar`] — the [`CalendarSource`]: due calendar reminders in,
//! events out
//! - [`task_source`] — the [`TaskScheduler`]: due tasks in, events out
//! - [`handler`] — the forge dispatch: snapshot, inbox, one turn, one
//! comment
@@ -69,7 +67,6 @@ pub mod accounts_tools;
pub mod attachments;
pub mod backends;
pub mod bootstrap;
pub mod calendar;
pub mod config;
pub mod consents;
pub mod daemon;
@@ -103,7 +100,6 @@ pub use bootstrap::{
BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD,
render_system,
};
pub use calendar::CalendarSource;
pub use config::{ClawConfig, ConfigError, ForgeKind, Watch};
pub use daemon::{ClawError, Daemon, TickReport, run_once};
pub use environment::write_report;