From c6a3ed8c97c073fd1dcf1df8309040af57bedb54 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 24 Oct 2026 01:35:35 +0200 Subject: [PATCH] =?UTF-8?q?refactor(claw):=20the=20watch=20table=20is=20th?= =?UTF-8?q?e=20one=20truth=20=E2=80=94=20the=20config=20stops=20carrying?= =?UTF-8?q?=20watches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The YAML `watches:` seed was the last piece of forge state the config file carried. Everything else about watches already lived in the extension: the table (SQLite, runtime-managed), the HTTP routes (`GET/POST/DELETE /v1/watches`), the chat tools, the poller that reads the table fresh every pass. A static seed list on top meant two homes for one fact — and the seed's own semantics (import-once, ignored once the table has rows) had to be explained to every reader. Now the config carries none of it: - `ClawConfig.watches` is gone, with the `Watch` seed type and the boot-time validation loop that guarded it. A config still carrying `watches:` is refused at parse time — `deny_unknown_fields` turns the retirement into a loud migration, not a silent no-op. - `Watch` moved to `watch_store` (re-exported from the crate root), where the row's vocabulary belongs; `validate_watch` keeps guarding the one door every writer shares — the routes and the tools both call it, so the moved validation tests test it there. - `seed_from_config` and the daemon's seeding are gone; the operator seeds once over HTTP or `watches add`, and the table stays true. The one guarantee the seed carried that nothing else did — boot refuses a tokenless watch — goes with it, deliberately: a tokenless watch now fails at its first poll, loudly, like any other watch the agent adds at runtime. Boot-time certainty was paying for config state the daemon no longer owns. --- crates/rhema-claw/src/backends.rs | 3 +- crates/rhema-claw/src/config.rs | 182 ++------------------------ crates/rhema-claw/src/daemon.rs | 7 - crates/rhema-claw/src/lib.rs | 4 +- crates/rhema-claw/src/main.rs | 7 +- crates/rhema-claw/src/watch_store.rs | 177 +++++++++++-------------- crates/rhema-claw/tests/continuity.rs | 23 ++-- crates/rhema-claw/tests/e2e.rs | 23 ++-- crates/rhema-claw/tests/http.rs | 67 +--------- 9 files changed, 120 insertions(+), 373 deletions(-) diff --git a/crates/rhema-claw/src/backends.rs b/crates/rhema-claw/src/backends.rs index 887d452..648c56b 100644 --- a/crates/rhema-claw/src/backends.rs +++ b/crates/rhema-claw/src/backends.rs @@ -960,8 +960,7 @@ mod tests { fn test_config() -> crate::config::ClawConfig { serde_json::from_value(serde_json::json!({ - "poll_interval_secs": 60, - "watches": [] + "poll_interval_secs": 60 })) .expect("a minimal claw config") } diff --git a/crates/rhema-claw/src/config.rs b/crates/rhema-claw/src/config.rs index b7e9ebf..738ab61 100644 --- a/crates/rhema-claw/src/config.rs +++ b/crates/rhema-claw/src/config.rs @@ -12,7 +12,6 @@ use std::path::PathBuf; -use rhema_forge_integration::RepoRef; use serde::{Deserialize, Serialize}; // The calendar feed types are the integration's: the reminder source @@ -70,19 +69,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 watch is unusable; the daemon refuses to start rather than - /// poll a half-configured watch set. - #[error("watch #{index} ({forge} {repo}): {reason}")] - Watch { - /// The watch's position in the config file, zero-based. - index: usize, - /// The watch's forge kind. - forge: String, - /// The watch's `owner/name` as written. - repo: String, - /// What is missing or malformed. - reason: String, - }, /// One calendar watch is unusable; the daemon refuses to start /// rather than poll a half-configured calendar. #[error("calendar #{index} ({name}): {reason}")] @@ -166,51 +152,6 @@ impl ForgeKind { } } -/// One watched repository. Unknown keys are rejected: a typo'd key -/// must not silently read as unset. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -pub struct Watch { - /// Which forge this watch polls. - pub forge: ForgeKind, - /// The repository, `owner/name` (GitLab: `namespace/project`). - pub repo: String, - /// The instance base URL. Required for gitea (self-hosted only); - /// github and gitlab default to their production bases. - pub host: Option, - /// The forge token. Falls back to `GITHUB_TOKEN` / `GITEA_TOKEN` / - /// `GITLAB_TOKEN`; a blank either way counts as unset. - pub token: Option, -} - -impl Watch { - /// The repository coordinates, splitting `owner/name`. GitLab's owner - /// keeps its full namespace path, which is why the split is lazy: the - /// first `/` separates owner from name, never a deeper one. - pub fn repo_ref(&self) -> Result { - let (owner, name) = self - .repo - .split_once('/') - .ok_or_else(|| format!("repo must be owner/name, got {:?}", self.repo))?; - if owner.is_empty() || name.is_empty() { - return Err(format!("repo must be owner/name, got {:?}", self.repo)); - } - Ok(RepoRef::new(owner, name)) - } - - /// The token to poll with: the config value, then the forge's env - /// variable; whitespace-only counts as unset everywhere. - pub fn resolve_token(&self) -> Option { - // A closure, not `std::env::var` itself: the generic fn does not - // implement `Fn(&str)` for all lifetimes. - resolve_token( - |name| std::env::var(name), - self.forge, - self.token.as_deref(), - ) - } -} - /// Pure token resolution: the config value first, then the forge's env /// variable; the first non-blank wins. Parameterized over the env lookup /// so tests can hold the environment still. @@ -320,10 +261,6 @@ pub struct ClawConfig { /// Unset closes the door to credentials; the human token can still /// ingest. pub ingest_token: Option, - /// What to watch — the optional seed for the persistent watch table. - /// Imported once, when the table is still empty; after that the - /// table rules, and watches are managed in chat or over HTTP. - pub watches: Vec, /// 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. @@ -355,7 +292,6 @@ impl Default for ClawConfig { tools_bin: None, ingest_token: None, transcript: None, - watches: Vec::new(), calendars: Vec::new(), } } @@ -498,33 +434,6 @@ impl ClawConfig { return Err(ConfigError::IngestTokenCollision); } } - for (index, watch) in self.watches.iter().enumerate() { - let fail = |reason: String| ConfigError::Watch { - index, - forge: watch.forge.name().to_owned(), - repo: watch.repo.clone(), - reason, - }; - watch.repo_ref().map_err(fail)?; - if watch.forge == ForgeKind::Gitea - && watch - .host - .as_deref() - .map(str::trim) - .filter(|h| !h.is_empty()) - .is_none() - { - return Err(fail( - "gitea is self-hosted: set host to the instance base URL".to_owned(), - )); - } - if watch.resolve_token().is_none() { - return Err(fail(format!( - "no token: set token in the config or {}", - watch.forge.token_env() - ))); - } - } self.validate_calendars()?; Ok(()) } @@ -725,15 +634,7 @@ provider: anthropic model: claude-sonnet-4-5 listen: 192.168.1.10:7800 token: sekrit -watches: - - forge: github - repo: acme/widgets - - forge: gitea - repo: acme/barn - host: https://git.example.net - token: gitea-pat - - forge: gitlab - repo: group/subgroup/widgets +ingest_token: door-key "; let config: ClawConfig = serde_yaml::from_str(raw).expect("parses"); assert_eq!(config.poll_interval_secs, 30); @@ -750,20 +651,6 @@ watches: config.bootstrap_root.as_deref(), Some(std::path::Path::new("/var/lib/rhema/claw-bootstrap")) ); - assert_eq!(config.watches.len(), 3); - assert_eq!(config.watches[0].forge, ForgeKind::GitHub); - assert_eq!(config.watches[0].host, None); - assert_eq!(config.watches[1].forge, ForgeKind::Gitea); - assert_eq!( - config.watches[1].host.as_deref(), - Some("https://git.example.net") - ); - assert_eq!(config.watches[1].token.as_deref(), Some("gitea-pat")); - // GitLab's namespace path survives as the owner. - assert_eq!( - config.watches[2].repo_ref().expect("parses").path(), - "group/subgroup/widgets" - ); } #[test] @@ -774,19 +661,22 @@ watches: err.to_string().contains("listne") && err.to_string().contains("expected one of"), "the error names the key: {err}" ); - // The same strictness inside a watch. + // A config still carrying the old watches seed is refused + // outright: the table rules now, and a silent seed would look + // like it worked. let err = serde_yaml::from_str::( - "watches:\n - forge: github\n repo: acme/widgets\n hst: https://git.example.net\n", + "watches:\n - forge: github\n repo: acme/widgets\n", ) - .expect_err("a typo'd watch key is loud too"); - assert!(err.to_string().contains("hst"), "got: {err}"); + .expect_err("the old watch seed is not config anymore"); + assert!( + err.to_string().contains("watches"), + "the error names the retired key: {err}" + ); } #[test] fn defaults_fill_what_the_file_omits() { - let config: ClawConfig = - serde_yaml::from_str("watches:\n - forge: github\n repo: acme/widgets\n") - .expect("parses"); + let config: ClawConfig = serde_yaml::from_str("listen: 127.0.0.1:7799\n").expect("parses"); assert_eq!(config.poll_interval_secs, DEFAULT_POLL_INTERVAL_SECS); assert_eq!(config.max_turn_steps, DEFAULT_MAX_TURN_STEPS); assert_eq!(config.provider, None); @@ -1074,56 +964,6 @@ watches: ); } - #[test] - fn validation_fails_a_watch_loudly_and_specifically() { - let watch = |forge, repo: &str, host: Option<&str>, token: Option<&str>| Watch { - forge, - repo: repo.to_owned(), - host: host.map(str::to_owned), - token: token.map(str::to_owned), - }; - let config = |watches: Vec| ClawConfig { - watches, - ..ClawConfig::default() - }; - - // An empty list is a valid starting point now: the table is - // seeded from it, and the agent manages watches from chat. - config(vec![]).validate().expect("an empty list validates"); - - let err = config(vec![watch(ForgeKind::GitHub, "acme", None, Some("t"))]) - .validate() - .expect_err("ownerless repo is loud"); - assert!(err.to_string().contains("owner/name"), "got: {err}"); - - let err = config(vec![watch(ForgeKind::Gitea, "acme/barn", None, Some("t"))]) - .validate() - .expect_err("hostless gitea is loud"); - assert!(err.to_string().contains("self-hosted"), "got: {err}"); - - let err = config(vec![watch(ForgeKind::GitLab, "acme/barn", None, None)]) - .validate() - .expect_err("tokenless watch is loud"); - assert!(err.to_string().contains("GITLAB_TOKEN"), "got: {err}"); - - config(vec![watch( - ForgeKind::GitHub, - "acme/widgets", - None, - Some("t"), - )]) - .validate() - .expect("a complete github watch validates"); - config(vec![watch( - ForgeKind::Gitea, - "acme/barn", - Some("https://git.example.net"), - Some("t"), - )]) - .validate() - .expect("a complete gitea watch validates"); - } - #[test] fn a_non_loopback_surface_demands_a_token() { let config = |listen: Option<&str>, token: Option<&str>| ClawConfig { diff --git a/crates/rhema-claw/src/daemon.rs b/crates/rhema-claw/src/daemon.rs index 301889f..9865724 100644 --- a/crates/rhema-claw/src/daemon.rs +++ b/crates/rhema-claw/src/daemon.rs @@ -191,13 +191,6 @@ impl Daemon { // drives, jobs the jobs dispatcher answers. let jobs = Arc::new(crate::job_store::CodingJobStore::open(&db)?); - // The YAML watch list is the one-time seed; the table rules - // after that. - let seeded = watches.seed_from_config(&config.watches)?; - if seeded > 0 { - eprintln!("[claw] seeded {seeded} watch(es) from the config"); - } - let forge_dispatcher = Arc::new(Dispatcher::new( dispatch, Arc::new(SystemClock), diff --git a/crates/rhema-claw/src/lib.rs b/crates/rhema-claw/src/lib.rs index efc02b9..9db2a8f 100644 --- a/crates/rhema-claw/src/lib.rs +++ b/crates/rhema-claw/src/lib.rs @@ -100,7 +100,7 @@ pub use bootstrap::{ BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD, render_system, }; -pub use config::{ClawConfig, ConfigError, ForgeKind, Watch}; +pub use config::{ClawConfig, ConfigError, ForgeKind}; pub use daemon::{ClawError, Daemon, TickReport, run_once}; pub use environment::write_report; pub use extension::{ClawExtension, ExtensionDeps, Extensions, Lanes}; @@ -116,4 +116,4 @@ pub use settings::SettingsStore; pub use task_source::{TASKS_SOURCE, TaskScheduler}; pub use task_store::{TaskKind, TaskRecord, TaskStore, TaskStoreError}; pub use turn_store::{TurnStore, TurnStoreError}; -pub use watch_store::{ClawStore, WatchRecord, WatchStoreError}; +pub use watch_store::{ClawStore, Watch, WatchRecord, WatchStoreError}; diff --git a/crates/rhema-claw/src/main.rs b/crates/rhema-claw/src/main.rs index 05e7924..700e7fd 100644 --- a/crates/rhema-claw/src/main.rs +++ b/crates/rhema-claw/src/main.rs @@ -59,8 +59,7 @@ async fn run(args: Args) -> Result<(), ClawError> { resolve_backends(&config)? }; eprintln!( - "[claw] {} seeded watch(es), active {}/{}, db {}, http {}", - config.watches.len(), + "[claw] active {}/{}, db {}, http {}", backends.active().0, backends.active().1, config @@ -74,9 +73,7 @@ async fn run(args: Args) -> Result<(), ClawError> { // The approval posture is deliberate (spec §8): say it once at // startup so the operator reads it next to the config that // turns it on, not in a README three weeks in. - if !config.watches.is_empty() { - eprintln!("[claw] note: forge dispatches run tool calls without approval prompts"); - } + eprintln!("[claw] note: forge dispatches run tool calls without approval prompts"); let report = run_once(&config, backends.clone()).await?; // A cron pass reports its failures through the exit code. if report diff --git a/crates/rhema-claw/src/watch_store.rs b/crates/rhema-claw/src/watch_store.rs index b864b56..49dff72 100644 --- a/crates/rhema-claw/src/watch_store.rs +++ b/crates/rhema-claw/src/watch_store.rs @@ -4,10 +4,9 @@ //! Watches are data the daemon reconfigures at runtime — the chat //! surface's [`watch_repo`](crate::watch_tools) tool and the HTTP //! `POST /v1/watches` route both land here — so the table is read fresh -//! on every detect pass instead of living only in the YAML config. The -//! config's `watches:` list is a one-time seed ([`ClawStore:: -//! seed_from_config`]): imported when the table is still empty, ignored -//! once the table has rows. +//! on every detect pass instead of living only in a static list. The +//! config no longer carries watches at all — the table is the one +//! truth, managed over `/v1/watches` and (from chat) the watch tools. //! //! Two connections, one file: this store opens its own rusqlite //! connection to the same database as the dispatcher's store — WAL and a @@ -23,7 +22,54 @@ use rhema_forge_integration::RepoRef; use rusqlite::params; use serde::Serialize; -use crate::config::{ForgeKind, Watch}; +use crate::config::{ForgeKind, resolve_token}; + +/// One watched repository — the row's vocabulary, and the type the +/// routes parse into. It used to live in the config as the YAML seed's +/// shape; the config no longer carries watches (the table is managed +/// over `/v1/watches`), and the type moved here with the rest of the +/// forge state. Unknown keys are rejected: a typo'd key must not +/// silently read as unset. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct Watch { + /// Which forge this watch polls. + pub forge: ForgeKind, + /// The repository, `owner/name` (GitLab: `namespace/project`). + pub repo: String, + /// The instance base URL. Required for gitea (self-hosted only); + /// github and gitlab default to their production bases. + pub host: Option, + /// The forge token. Falls back to `GITHUB_TOKEN` / `GITEA_TOKEN` / + /// `GITLAB_TOKEN`; a blank either way counts as unset. + pub token: Option, +} + +impl Watch { + /// The repository coordinates, splitting `owner/name`. GitLab's owner + /// keeps its full namespace path, which is why the split is lazy: the + /// first `/` separates owner from name, never a deeper one. + pub fn repo_ref(&self) -> Result { + let (owner, name) = self + .repo + .split_once('/') + .ok_or_else(|| format!("repo must be owner/name, got {:?}", self.repo))?; + if owner.is_empty() || name.is_empty() { + return Err(format!("repo must be owner/name, got {:?}", self.repo)); + } + Ok(RepoRef::new(owner, name)) + } + + /// The token to poll with: the stored value, then the forge's env + /// variable; whitespace-only counts as unset everywhere. + pub fn resolve_token(&self) -> Option { + resolve_token( + |name| std::env::var(name), + self.forge, + self.token.as_deref(), + ) + } +} /// The watch table, and the account-watch table beside it: one row per /// identity whose forge notifications the daemon polls. An account watch @@ -214,31 +260,6 @@ impl ClawStore { Ok(rows) } - /// The config seed: imports `watches` when the table is empty — the - /// one-time bootstrap of a fresh deployment — and reports how many - /// landed. A populated table is never rewritten by the seed: once - /// any watch exists, the table rules and the config list is inert. - /// Only the config's own token is stored; the forge's environment - /// variable still serves at poll time when the stored one is blank. - pub fn seed_from_config(&self, watches: &[Watch]) -> Result { - if !self.list()?.is_empty() { - return Ok(0); - } - let mut seeded = 0; - for watch in watches { - let token = watch.token.as_deref().map(str::trim).unwrap_or(""); - if self.add( - watch.forge.name(), - &watch.repo, - watch.host.as_deref(), - token, - )? { - seeded += 1; - } - } - Ok(seeded) - } - /// Registers the account watch: the daemon polls `forge`'s /// notifications as the account. `false` when the account is already /// watched on that forge. The identity's own secret is *not* passed @@ -327,7 +348,7 @@ fn now_millis() -> i64 { } /// The shape checks every watch must pass, wherever it enters (the -/// config seed, the HTTP route, the agent tool): a parseable forge, an +/// HTTP route, the agent tool): a parseable forge, an /// `owner/name` repo, and — gitea being self-hosted only — a host for /// gitea. Tokens are not required here: a blank one falls back to the /// forge's environment variable at poll time. @@ -349,6 +370,29 @@ pub fn validate_watch(forge: ForgeKind, repo: &str, host: Option<&str>) -> Resul mod tests { use super::*; + #[test] + fn validation_fails_a_watch_loudly_and_specificly() { + assert!( + validate_watch(ForgeKind::GitHub, "acme", None) + .unwrap_err() + .contains("owner/name"), + "an ownerless repo is loud" + ); + assert!( + validate_watch(ForgeKind::Gitea, "acme/barn", None) + .unwrap_err() + .contains("self-hosted"), + "a hostless gitea watch is loud" + ); + validate_watch(ForgeKind::GitHub, "acme/widgets", None).expect("github validates"); + validate_watch( + ForgeKind::Gitea, + "acme/barn", + Some("https://git.example.net"), + ) + .expect("gitea validates"); + } + #[test] fn a_watch_round_trips_through_the_table() { let store = ClawStore::in_memory().expect("store opens"); @@ -414,75 +458,4 @@ mod tests { }; assert!(bad.repo_ref().is_err()); } - - #[test] - fn the_seed_imports_only_an_empty_table() { - let store = ClawStore::in_memory().expect("store opens"); - let watches = vec![Watch { - forge: ForgeKind::GitHub, - repo: "acme/widgets".to_owned(), - host: None, - token: Some(" ".to_owned()), - }]; - assert_eq!( - store.seed_from_config(&watches).expect("seeds"), - 1, - "the empty table takes the seed" - ); - assert_eq!( - store.list().expect("list reads")[0].token, - "", - "blank stays blank" - ); - assert_eq!( - store.seed_from_config(&watches).expect("skips"), - 0, - "a populated table ignores the seed" - ); - } - - #[test] - fn validation_checks_shape_and_giteas_host() { - assert!(validate_watch(ForgeKind::GitHub, "acme/widgets", None).is_ok()); - assert!(validate_watch(ForgeKind::GitHub, "ownerless", None).is_err()); - assert!(validate_watch(ForgeKind::GitHub, "acme/", None).is_err()); - assert!(validate_watch(ForgeKind::Gitea, "acme/barn", None).is_err()); - assert!(validate_watch(ForgeKind::Gitea, "acme/barn", Some(" ")).is_err()); - assert!( - validate_watch( - ForgeKind::Gitea, - "acme/barn", - Some("https://git.example.net") - ) - .is_ok() - ); - assert!( - validate_watch(ForgeKind::GitLab, "group/subgroup/widgets", None).is_ok(), - "gitlab has a production base" - ); - } - - #[test] - fn the_token_never_serializes() { - let store = ClawStore::in_memory().expect("store opens"); - store - .add("github", "acme/widgets", None, "sekrit") - .expect("add"); - let json = - serde_json::to_string(&store.list().expect("list reads")[0]).expect("serializes"); - assert!(!json.contains("sekrit"), "token leaked: {json}"); - assert!(json.contains("acme/widgets"), "{json}"); - } - - #[test] - fn two_connections_share_one_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("dispatch.db"); - let writer = ClawStore::open(&path).expect("first connection"); - let reader = ClawStore::open(&path).expect("second connection"); - writer.add("github", "acme/widgets", None, "").expect("add"); - let watches = reader.list().expect("the other connection sees it"); - assert_eq!(watches.len(), 1); - assert_eq!(watches[0].repo, "acme/widgets"); - } } diff --git a/crates/rhema-claw/tests/continuity.rs b/crates/rhema-claw/tests/continuity.rs index 5d26b56..7cf6e46 100644 --- a/crates/rhema-claw/tests/continuity.rs +++ b/crates/rhema-claw/tests/continuity.rs @@ -7,7 +7,17 @@ use std::sync::Arc; -use rhema_claw::{ClawConfig, ForgeKind, Watch, run_once}; +use rhema_claw::{ClawConfig, run_once}; + +/// Seeds the watch row straight into the table — the config stopped +/// carrying watches, and the test speaks to the daemon the way an +/// integration would: through the store the routes share. +fn seed_watch(dir: &tempfile::TempDir, host: &str) { + rhema_claw::ClawStore::open(dir.path().join("dispatch.db")) + .expect("the watch table opens") + .add("github", "acme/widgets", Some(host), "tok") + .expect("the seed row lands"); +} use rhema_core::{Message, MockProvider, ModelOutput, Role, Session, StopReason, Usage}; use rhema_rsd::{KeyState, Outcome, ResourceKey, SqliteStore, Store}; use serde_json::{Value, json}; @@ -55,7 +65,7 @@ fn reply(text: &str) -> ModelOutput { } } -fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig { +fn config(dir: &tempfile::TempDir) -> ClawConfig { ClawConfig { db: Some(dir.path().join("dispatch.db")), workspace_root: Some(dir.path().join("workspaces")), @@ -71,12 +81,6 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig { tools_bin: None, transcript: None, ingest_token: None, - watches: vec![Watch { - forge: ForgeKind::GitHub, - repo: "acme/widgets".to_owned(), - host: Some(server.uri()), - token: Some("tok".to_owned()), - }], calendars: Vec::new(), } } @@ -131,7 +135,8 @@ async fn the_second_dispatch_resumes_the_first_conversation() { .await; let dir = tempfile::tempdir().expect("tempdir"); - let config = config(&server, &dir); + let config = config(&dir); + seed_watch(&dir, &server.uri()); let key = ResourceKey::from("github:acme/widgets#7"); // Pass one: the opened event alone. The cron shape — a fresh process, diff --git a/crates/rhema-claw/tests/e2e.rs b/crates/rhema-claw/tests/e2e.rs index e35efef..5e983c6 100644 --- a/crates/rhema-claw/tests/e2e.rs +++ b/crates/rhema-claw/tests/e2e.rs @@ -5,7 +5,7 @@ use std::sync::Arc; -use rhema_claw::{ClawConfig, ForgeKind, Watch, run_once}; +use rhema_claw::{ClawConfig, run_once}; use rhema_core::{Message, MockProvider, ModelOutput, Role, StopReason, Usage}; use rhema_rsd::{ Dispatcher, DispatcherConfig, KeyState, Outcome, ResourceKey, SqliteStore, SystemClock, @@ -43,7 +43,7 @@ fn reply(text: &str) -> ModelOutput { /// The test's claw config: everything under one tempdir, the watch aimed /// at the mock server. The provider never resolves through it — the /// daemon library takes the built provider, and the test builds a mock. -fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig { +fn config(dir: &tempfile::TempDir) -> ClawConfig { ClawConfig { db: Some(dir.path().join("dispatch.db")), workspace_root: Some(dir.path().join("workspaces")), @@ -59,16 +59,20 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig { tools_bin: None, transcript: None, ingest_token: None, - watches: vec![Watch { - forge: ForgeKind::GitHub, - repo: "acme/widgets".to_owned(), - host: Some(server.uri()), - token: Some("tok".to_owned()), - }], calendars: Vec::new(), } } +/// Seeds the watch row straight into the table — the config stopped +/// carrying watches, and the test speaks to the daemon the way an +/// integration would: through the store the routes share. +fn seed_watch(dir: &tempfile::TempDir, host: &str) { + rhema_claw::ClawStore::open(dir.path().join("dispatch.db")) + .expect("the watch table opens") + .add("github", "acme/widgets", Some(host), "tok") + .expect("the seed row lands"); +} + #[tokio::test] async fn one_pass_polls_reasons_and_posts() { let server = MockServer::start().await; @@ -103,7 +107,8 @@ async fn one_pass_polls_reasons_and_posts() { .await; let dir = tempfile::tempdir().expect("tempdir"); - let config = config(&server, &dir); + let config = config(&dir); + seed_watch(&dir, &server.uri()); let provider = Arc::new(MockProvider::new([reply("on it")])); let report = run_once(&config, mock_registry(provider)) .await diff --git a/crates/rhema-claw/tests/http.rs b/crates/rhema-claw/tests/http.rs index ec5677d..58d9717 100644 --- a/crates/rhema-claw/tests/http.rs +++ b/crates/rhema-claw/tests/http.rs @@ -13,7 +13,7 @@ use std::time::Duration; use rhema_autonomous_agent::Allowlist; use rhema_claw::watch_tools; -use rhema_claw::{ClawConfig, Daemon, ForgeKind, Watch}; +use rhema_claw::{ClawConfig, Daemon}; use rhema_core::{Content, Message, MockProvider, ModelOutput, Role, Session, StopReason, Usage}; use rhema_rsd::{ResourceKey, SqliteStore, Store}; use serde_json::{Value, json}; @@ -65,7 +65,6 @@ fn config(dir: &tempfile::TempDir, token: Option<&str>) -> ClawConfig { tools_bin: None, ingest_token: None, transcript: None, - watches: Vec::new(), calendars: Vec::new(), } } @@ -894,70 +893,6 @@ async fn a_configured_token_guards_v1_and_leaves_healthz_open() { assert_eq!(whole["choices"][0]["message"]["content"], "on it"); } -/// The watch list as the HTTP route serves it for `daemon`. -async fn list_watches(daemon: &Daemon) -> Value { - let base = serve(daemon).await; - reqwest::Client::new() - .get(format!("{base}/v1/watches")) - .send() - .await - .expect("the list reads") - .json() - .await - .expect("the list parses") -} - -/// The seed rule, through the daemon's assembly: an empty table takes -/// the config's watches; a populated table ignores the seed, so a -/// restart with a different YAML never rewrites what the table holds. -#[tokio::test] -async fn the_seed_imports_into_an_empty_table_and_then_the_table_rules() { - let dir = tempfile::tempdir().expect("tempdir"); - let yaml_seed = || ClawConfig { - watches: vec![Watch { - forge: ForgeKind::GitHub, - repo: "acme/seeded".to_owned(), - host: None, - token: Some("tok".to_owned()), - }], - ..config(&dir, None) - }; - let changed_seed = || ClawConfig { - watches: vec![Watch { - forge: ForgeKind::GitLab, - repo: "group/other".to_owned(), - host: None, - token: Some("tok".to_owned()), - }], - ..config(&dir, None) - }; - - // First boot: the empty table takes the seed. - let first = Daemon::build( - &yaml_seed(), - mock_registry(Arc::new(MockProvider::new(Vec::new()))), - rhema_rsd::DispatcherConfig::default(), - ) - .expect("the first daemon assembles"); - let listed = list_watches(&first).await; - assert_eq!(listed["watches"][0]["repo"], "acme/seeded", "{listed}"); - - // Second boot, different YAML, populated table: the table rules. - let second = Daemon::build( - &changed_seed(), - mock_registry(Arc::new(MockProvider::new(Vec::new()))), - rhema_rsd::DispatcherConfig::default(), - ) - .expect("the second daemon assembles"); - let listed = list_watches(&second).await; - assert_eq!( - listed["watches"].as_array().map(Vec::len), - Some(1), - "the seed never rewrites a populated table: {listed}" - ); - assert_eq!(listed["watches"][0]["repo"], "acme/seeded", "{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