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};
|
||||
|
||||
// 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;
|
||||
@@ -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"
|
||||
)]
|
||||
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
|
||||
/// token: anyone who can reach the port could run tools on this
|
||||
/// machine.
|
||||
@@ -261,10 +244,6 @@ pub struct ClawConfig {
|
||||
/// Unset closes the door to credentials; the human token can still
|
||||
/// ingest.
|
||||
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 {
|
||||
@@ -292,7 +271,6 @@ impl Default for ClawConfig {
|
||||
tools_bin: None,
|
||||
ingest_token: None,
|
||||
transcript: None,
|
||||
calendars: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,111 +412,6 @@ impl ClawConfig {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -687,239 +560,6 @@ ingest_token: door-key
|
||||
assert_eq!(config.listen_addr(), DEFAULT_LISTEN);
|
||||
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]
|
||||
fn forge_names_parse_as_the_sources_spell_them() {
|
||||
assert_eq!(ForgeKind::parse("github"), Some(ForgeKind::GitHub));
|
||||
|
||||
@@ -191,6 +191,10 @@ impl Daemon {
|
||||
// drives, jobs the jobs dispatcher answers.
|
||||
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(
|
||||
dispatch,
|
||||
Arc::new(SystemClock),
|
||||
@@ -327,7 +331,9 @@ impl Daemon {
|
||||
identity: identity.clone(),
|
||||
hub: hub.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(", "));
|
||||
// The sources register on their lanes as they build: a watch of
|
||||
@@ -384,6 +390,7 @@ impl Daemon {
|
||||
interactive_driver.clone(),
|
||||
store,
|
||||
watches,
|
||||
calendars.clone(),
|
||||
notifications,
|
||||
tasks,
|
||||
bootstrap,
|
||||
|
||||
@@ -36,12 +36,13 @@ use rhema_autonomous_agent::{Allowlist, Source, ToolRegistry};
|
||||
use rhema_core::ToolResolver;
|
||||
use rhema_rsd::{Dispatcher, ResourceKey, SystemClock};
|
||||
|
||||
use crate::config::{CalendarWatch, ForgeKind};
|
||||
use crate::config::ForgeKind;
|
||||
use crate::consents::WaitBridge;
|
||||
use crate::daemon::ClawError;
|
||||
use crate::forge::ForgeSource;
|
||||
use crate::hub::EventHub;
|
||||
use rhema_calendar_integration::CalendarSource;
|
||||
use rhema_calendar_integration::CalendarStore;
|
||||
|
||||
/// The store handles the standard set is assembled over. One struct,
|
||||
/// 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
|
||||
/// registry resolve against.
|
||||
pub workspace: PathBuf,
|
||||
/// The calendar feeds the reminders source polls.
|
||||
pub calendars: Vec<CalendarWatch>,
|
||||
/// The calendar table, shared with `/v1/calendars`: the source
|
||||
/// 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
|
||||
@@ -477,7 +479,7 @@ impl ClawExtension for PhoneExtension {
|
||||
/// The calendar reminders: a due occurrence is an event on the resident
|
||||
/// lane, never a timer near the model.
|
||||
struct CalendarExtension {
|
||||
calendars: Vec<CalendarWatch>,
|
||||
calendars: Arc<CalendarStore>,
|
||||
}
|
||||
|
||||
impl ClawExtension for CalendarExtension {
|
||||
@@ -486,9 +488,11 @@ impl ClawExtension for CalendarExtension {
|
||||
}
|
||||
|
||||
fn sources(&self, lanes: &Lanes) -> Result<Vec<Box<dyn Source>>, ClawError> {
|
||||
let source =
|
||||
CalendarSource::new(&lanes.interactive, Arc::new(SystemClock), &self.calendars)
|
||||
.map_err(ClawError::Backend)?;
|
||||
let source = CalendarSource::from_store(
|
||||
&lanes.interactive,
|
||||
Arc::new(SystemClock),
|
||||
self.calendars.clone(),
|
||||
);
|
||||
Ok(vec![Box::new(source)])
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,9 @@ pub struct ClawState {
|
||||
driver: Arc<Driver>,
|
||||
store: Arc<SqliteStore>,
|
||||
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>,
|
||||
/// The scheduled-task table the `/v1/tasks` routes serve — the same
|
||||
/// rows the resident scheduler fires.
|
||||
@@ -117,6 +120,7 @@ impl ClawState {
|
||||
driver: Arc<Driver>,
|
||||
store: Arc<SqliteStore>,
|
||||
watches: Arc<ClawStore>,
|
||||
calendars: Arc<rhema_calendar_integration::CalendarStore>,
|
||||
notifications: Arc<NotificationStore>,
|
||||
tasks: Arc<TaskStore>,
|
||||
bootstrap: Arc<Bootstrap>,
|
||||
@@ -138,6 +142,7 @@ impl ClawState {
|
||||
driver,
|
||||
store,
|
||||
watches,
|
||||
calendars,
|
||||
notifications,
|
||||
tasks,
|
||||
bootstrap,
|
||||
@@ -232,6 +237,12 @@ pub fn router(state: ClawState) -> Router {
|
||||
.route("/bootstrap", get(bootstrap_status))
|
||||
.route("/watches", get(watches_list).post(watches_add))
|
||||
.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/{id}", delete(accounts_remove))
|
||||
.route("/consents", get(consents_list))
|
||||
@@ -282,7 +293,7 @@ pub fn router(state: ClawState) -> Router {
|
||||
Json(json!({
|
||||
"service": "rhema-claw",
|
||||
"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/bootstrap", "/v1/ingest"],
|
||||
}))
|
||||
@@ -618,6 +629,74 @@ async fn events(
|
||||
.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
|
||||
/// gets a comment naming the drop — live delivery is best-effort; the
|
||||
/// record replays what completed. A serialization failure is logged with
|
||||
|
||||
@@ -694,7 +694,12 @@ mod tests {
|
||||
identity,
|
||||
hub,
|
||||
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,
|
||||
transcript: None,
|
||||
ingest_token: None,
|
||||
calendars: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ fn config(dir: &tempfile::TempDir) -> ClawConfig {
|
||||
tools_bin: None,
|
||||
transcript: None,
|
||||
ingest_token: None,
|
||||
calendars: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ fn config(dir: &tempfile::TempDir, token: Option<&str>) -> ClawConfig {
|
||||
tools_bin: None,
|
||||
ingest_token: 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");
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// dispatcher, an identical redelivery dedups, the daemon's own source
|
||||
/// names cannot be re-registered, and the bearer guard covers it like
|
||||
|
||||
Reference in new issue
Block a user