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 {
|
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
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user