refactor(claw): the watch table is the one truth — the config stops carrying watches

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.
This commit is contained in:
vhaudiquet committed 2026-10-24 01:35:35 +02:00
1 parent f96f8c4c1d
commit c6a3ed8c97
9 files changed
+119 -372

No files matched your search

+1 -2
View File
@@ -960,8 +960,7 @@ mod tests {
fn test_config() -> crate::config::ClawConfig { fn test_config() -> crate::config::ClawConfig {
serde_json::from_value(serde_json::json!({ serde_json::from_value(serde_json::json!({
"poll_interval_secs": 60, "poll_interval_secs": 60
"watches": []
})) }))
.expect("a minimal claw config") .expect("a minimal claw config")
} }
+11 -171
View File
@@ -12,7 +12,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use rhema_forge_integration::RepoRef;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// The calendar feed types are the integration's: the reminder source // 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" "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 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 /// One calendar watch is unusable; the daemon refuses to start
/// rather than poll a half-configured calendar. /// rather than poll a half-configured calendar.
#[error("calendar #{index} ({name}): {reason}")] #[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<String>,
/// The forge token. Falls back to `GITHUB_TOKEN` / `GITEA_TOKEN` /
/// `GITLAB_TOKEN`; a blank either way counts as unset.
pub token: Option<String>,
}
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<RepoRef, String> {
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<String> {
// 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 /// Pure token resolution: the config value first, then the forge's env
/// variable; the first non-blank wins. Parameterized over the env lookup /// variable; the first non-blank wins. Parameterized over the env lookup
/// so tests can hold the environment still. /// 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 /// Unset closes the door to credentials; the human token can still
/// ingest. /// ingest.
pub ingest_token: Option<String>, pub ingest_token: Option<String>,
/// 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<Watch>,
/// The calendars to remind about. Config-file-only: a calendar is /// The calendars to remind about. Config-file-only: a calendar is
/// read-only ambient state, not an agent-managed resource, and a /// read-only ambient state, not an agent-managed resource, and a
/// change takes the daemon restart it takes to edit the file. /// change takes the daemon restart it takes to edit the file.
@@ -355,7 +292,6 @@ impl Default for ClawConfig {
tools_bin: None, tools_bin: None,
ingest_token: None, ingest_token: None,
transcript: None, transcript: None,
watches: Vec::new(),
calendars: Vec::new(), calendars: Vec::new(),
} }
} }
@@ -498,33 +434,6 @@ impl ClawConfig {
return Err(ConfigError::IngestTokenCollision); 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()?; self.validate_calendars()?;
Ok(()) Ok(())
} }
@@ -725,15 +634,7 @@ provider: anthropic
model: claude-sonnet-4-5 model: claude-sonnet-4-5
listen: 192.168.1.10:7800 listen: 192.168.1.10:7800
token: sekrit token: sekrit
watches: ingest_token: door-key
- forge: github
repo: acme/widgets
- forge: gitea
repo: acme/barn
host: https://git.example.net
token: gitea-pat
- forge: gitlab
repo: group/subgroup/widgets
"; ";
let config: ClawConfig = serde_yaml::from_str(raw).expect("parses"); let config: ClawConfig = serde_yaml::from_str(raw).expect("parses");
assert_eq!(config.poll_interval_secs, 30); assert_eq!(config.poll_interval_secs, 30);
@@ -750,20 +651,6 @@ watches:
config.bootstrap_root.as_deref(), config.bootstrap_root.as_deref(),
Some(std::path::Path::new("/var/lib/rhema/claw-bootstrap")) 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] #[test]
@@ -774,19 +661,22 @@ watches:
err.to_string().contains("listne") && err.to_string().contains("expected one of"), err.to_string().contains("listne") && err.to_string().contains("expected one of"),
"the error names the key: {err}" "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::<ClawConfig>( let err = serde_yaml::from_str::<ClawConfig>(
"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"); .expect_err("the old watch seed is not config anymore");
assert!(err.to_string().contains("hst"), "got: {err}"); assert!(
err.to_string().contains("watches"),
"the error names the retired key: {err}"
);
} }
#[test] #[test]
fn defaults_fill_what_the_file_omits() { fn defaults_fill_what_the_file_omits() {
let config: ClawConfig = let config: ClawConfig = serde_yaml::from_str("listen: 127.0.0.1:7799\n").expect("parses");
serde_yaml::from_str("watches:\n - forge: github\n repo: acme/widgets\n")
.expect("parses");
assert_eq!(config.poll_interval_secs, DEFAULT_POLL_INTERVAL_SECS); assert_eq!(config.poll_interval_secs, DEFAULT_POLL_INTERVAL_SECS);
assert_eq!(config.max_turn_steps, DEFAULT_MAX_TURN_STEPS); assert_eq!(config.max_turn_steps, DEFAULT_MAX_TURN_STEPS);
assert_eq!(config.provider, None); 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<Watch>| 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] #[test]
fn a_non_loopback_surface_demands_a_token() { fn a_non_loopback_surface_demands_a_token() {
let config = |listen: Option<&str>, token: Option<&str>| ClawConfig { let config = |listen: Option<&str>, token: Option<&str>| ClawConfig {
-7
View File
@@ -191,13 +191,6 @@ 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 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( let forge_dispatcher = Arc::new(Dispatcher::new(
dispatch, dispatch,
Arc::new(SystemClock), Arc::new(SystemClock),
+2 -2
View File
@@ -100,7 +100,7 @@ pub use bootstrap::{
BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD, BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD,
render_system, 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 daemon::{ClawError, Daemon, TickReport, run_once};
pub use environment::write_report; pub use environment::write_report;
pub use extension::{ClawExtension, ExtensionDeps, Extensions, Lanes}; 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_source::{TASKS_SOURCE, TaskScheduler};
pub use task_store::{TaskKind, TaskRecord, TaskStore, TaskStoreError}; pub use task_store::{TaskKind, TaskRecord, TaskStore, TaskStoreError};
pub use turn_store::{TurnStore, TurnStoreError}; pub use turn_store::{TurnStore, TurnStoreError};
pub use watch_store::{ClawStore, WatchRecord, WatchStoreError}; pub use watch_store::{ClawStore, Watch, WatchRecord, WatchStoreError};
+1 -4
View File
@@ -59,8 +59,7 @@ async fn run(args: Args) -> Result<(), ClawError> {
resolve_backends(&config)? resolve_backends(&config)?
}; };
eprintln!( eprintln!(
"[claw] {} seeded watch(es), active {}/{}, db {}, http {}", "[claw] active {}/{}, db {}, http {}",
config.watches.len(),
backends.active().0, backends.active().0,
backends.active().1, backends.active().1,
config config
@@ -74,9 +73,7 @@ async fn run(args: Args) -> Result<(), ClawError> {
// The approval posture is deliberate (spec §8): say it once at // The approval posture is deliberate (spec §8): say it once at
// startup so the operator reads it next to the config that // startup so the operator reads it next to the config that
// turns it on, not in a README three weeks in. // 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?; let report = run_once(&config, backends.clone()).await?;
// A cron pass reports its failures through the exit code. // A cron pass reports its failures through the exit code.
if report if report
+75 -102
View File
@@ -4,10 +4,9 @@
//! Watches are data the daemon reconfigures at runtime — the chat //! Watches are data the daemon reconfigures at runtime — the chat
//! surface's [`watch_repo`](crate::watch_tools) tool and the HTTP //! surface's [`watch_repo`](crate::watch_tools) tool and the HTTP
//! `POST /v1/watches` route both land here — so the table is read fresh //! `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 //! on every detect pass instead of living only in a static list. The
//! config's `watches:` list is a one-time seed ([`ClawStore:: //! config no longer carries watches at all — the table is the one
//! seed_from_config`]): imported when the table is still empty, ignored //! truth, managed over `/v1/watches` and (from chat) the watch tools.
//! once the table has rows.
//! //!
//! Two connections, one file: this store opens its own rusqlite //! Two connections, one file: this store opens its own rusqlite
//! connection to the same database as the dispatcher's store — WAL and a //! 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 rusqlite::params;
use serde::Serialize; 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<String>,
/// The forge token. Falls back to `GITHUB_TOKEN` / `GITEA_TOKEN` /
/// `GITLAB_TOKEN`; a blank either way counts as unset.
pub token: Option<String>,
}
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<RepoRef, String> {
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<String> {
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 /// The watch table, and the account-watch table beside it: one row per
/// identity whose forge notifications the daemon polls. An account watch /// identity whose forge notifications the daemon polls. An account watch
@@ -214,31 +260,6 @@ impl ClawStore {
Ok(rows) 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<usize, WatchStoreError> {
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 /// Registers the account watch: the daemon polls `forge`'s
/// notifications as the account. `false` when the account is already /// notifications as the account. `false` when the account is already
/// watched on that forge. The identity's own secret is *not* passed /// 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 /// 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 /// `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 /// gitea. Tokens are not required here: a blank one falls back to the
/// forge's environment variable at poll time. /// 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 { mod tests {
use super::*; 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] #[test]
fn a_watch_round_trips_through_the_table() { fn a_watch_round_trips_through_the_table() {
let store = ClawStore::in_memory().expect("store opens"); let store = ClawStore::in_memory().expect("store opens");
@@ -414,75 +458,4 @@ mod tests {
}; };
assert!(bad.repo_ref().is_err()); 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");
}
} }
+14 -9
View File
@@ -7,7 +7,17 @@
use std::sync::Arc; 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_core::{Message, MockProvider, ModelOutput, Role, Session, StopReason, Usage};
use rhema_rsd::{KeyState, Outcome, ResourceKey, SqliteStore, Store}; use rhema_rsd::{KeyState, Outcome, ResourceKey, SqliteStore, Store};
use serde_json::{Value, json}; 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 { ClawConfig {
db: Some(dir.path().join("dispatch.db")), db: Some(dir.path().join("dispatch.db")),
workspace_root: Some(dir.path().join("workspaces")), workspace_root: Some(dir.path().join("workspaces")),
@@ -71,12 +81,6 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
tools_bin: None, tools_bin: None,
transcript: None, transcript: None,
ingest_token: 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(), calendars: Vec::new(),
} }
} }
@@ -131,7 +135,8 @@ async fn the_second_dispatch_resumes_the_first_conversation() {
.await; .await;
let dir = tempfile::tempdir().expect("tempdir"); 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"); let key = ResourceKey::from("github:acme/widgets#7");
// Pass one: the opened event alone. The cron shape — a fresh process, // Pass one: the opened event alone. The cron shape — a fresh process,
+14 -9
View File
@@ -5,7 +5,7 @@
use std::sync::Arc; 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_core::{Message, MockProvider, ModelOutput, Role, StopReason, Usage};
use rhema_rsd::{ use rhema_rsd::{
Dispatcher, DispatcherConfig, KeyState, Outcome, ResourceKey, SqliteStore, SystemClock, 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 /// The test's claw config: everything under one tempdir, the watch aimed
/// at the mock server. The provider never resolves through it — the /// at the mock server. The provider never resolves through it — the
/// daemon library takes the built provider, and the test builds a mock. /// 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 { ClawConfig {
db: Some(dir.path().join("dispatch.db")), db: Some(dir.path().join("dispatch.db")),
workspace_root: Some(dir.path().join("workspaces")), workspace_root: Some(dir.path().join("workspaces")),
@@ -59,16 +59,20 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
tools_bin: None, tools_bin: None,
transcript: None, transcript: None,
ingest_token: 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(), 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] #[tokio::test]
async fn one_pass_polls_reasons_and_posts() { async fn one_pass_polls_reasons_and_posts() {
let server = MockServer::start().await; let server = MockServer::start().await;
@@ -103,7 +107,8 @@ async fn one_pass_polls_reasons_and_posts() {
.await; .await;
let dir = tempfile::tempdir().expect("tempdir"); 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 provider = Arc::new(MockProvider::new([reply("on it")]));
let report = run_once(&config, mock_registry(provider)) let report = run_once(&config, mock_registry(provider))
.await .await
+1 -66
View File
@@ -13,7 +13,7 @@ use std::time::Duration;
use rhema_autonomous_agent::Allowlist; use rhema_autonomous_agent::Allowlist;
use rhema_claw::watch_tools; 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_core::{Content, Message, MockProvider, ModelOutput, Role, Session, StopReason, Usage};
use rhema_rsd::{ResourceKey, SqliteStore, Store}; use rhema_rsd::{ResourceKey, SqliteStore, Store};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -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,
watches: Vec::new(),
calendars: 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"); 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 /// 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