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:
1 parent
f96f8c4c1d
commit
c6a3ed8c97
9 files changed
+119
-372
No files matched your search
@@ -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")
|
||||
}
|
||||
|
||||
+11
-171
@@ -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<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
|
||||
/// 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<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
|
||||
/// 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::<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");
|
||||
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<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]
|
||||
fn a_non_loopback_surface_demands_a_token() {
|
||||
let config = |listen: Option<&str>, token: Option<&str>| ClawConfig {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
let report = run_once(&config, backends.clone()).await?;
|
||||
// A cron pass reports its failures through the exit code.
|
||||
if report
|
||||
|
||||
@@ -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<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
|
||||
/// 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<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
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user