feat(claw): record every watch's poll health, and say so when a streak starts

A watch whose poll fails was invisible: `detect` turned the failure into
one `eprintln!` per tick and nothing else. A watch that had 401'd every
sixty seconds since the day it was created was indistinguishable — on
`/v1/watches`, in the agent's own `list_watches`, everywhere — from a
repository that had simply been quiet. That is how a dead credential
survives for weeks: the log line scrolls, and nothing else remembers.

`watch_health` is a third table beside `watches` and `account_watches`,
keyed by `(scope, forge, subject)`, holding `last_ok_at`, `last_error`,
`last_error_at` and `consecutive_failures`. Every detect pass records
each poll's outcome — repo watches as `repo`, account watches as
`account` — and the pass that *starts* a streak (the first failure after
a success, or after never having succeeded) pushes one device
notification, naming the watch and quoting the forge's error. The rest of
the streak stays quiet: the row carries the count, so the tick's log line
no longer has to.

Readable where the question gets asked: `/v1/watches` answers a `health`
array alongside the two watch lists, and the `list_watches` tool reports
`last_ok_at`, `last_error` and `consecutive_failures` per repo, so "is
this watch working?" is answerable without the daemon's stderr. A watch
that has never polled has no row and reports empty health, which is what
it is.
This commit is contained in:
kosmos authored and Valentin Haudiquet committed 2026-11-08 17:54:15 +01:00
1 parent 6468ae9638
commit 838f71738e
5 files changed
+397 -10

No files matched your search

+114 -3
View File
@@ -31,7 +31,7 @@ use rhema_forge_integration::{
use rhema_identity::{IdentityStore, SecretKind}; use rhema_identity::{IdentityStore, SecretKind};
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn, SystemClock}; use rhema_rsd::{Clock, Dispatcher, Event, KeyFn, SystemClock};
use crate::watch_store::{AccountWatchRecord, ClawStore, WatchRecord}; use crate::watch_store::{AccountWatchRecord, ClawStore, SCOPE_ACCOUNT, SCOPE_REPO, WatchRecord};
use rhema_forge_integration::{ForgeKind, resolve_token}; use rhema_forge_integration::{ForgeKind, resolve_token};
/// The credential a watch polls with: the value the forge takes as a /// The credential a watch polls with: the value the forge takes as a
@@ -315,7 +315,16 @@ impl Source for ForgeSource {
} }
}; };
let client = self.client_for(&record); let client = self.client_for(&record);
match self.watcher.poll(client.as_ref(), &repo).await { let polled = self.watcher.poll(client.as_ref(), &repo).await;
// Health first, whatever the outcome: a watch that has been
// failing since it was created must be able to say so.
self.record_poll(
SCOPE_REPO,
&record.forge,
&record.repo,
polled.as_ref().err().map(|err| err.to_string()),
);
match polled {
Ok(count) => fed += count, Ok(count) => fed += count,
// One dead watch (a lapsed token, a forge outage) must // One dead watch (a lapsed token, a forge outage) must
// not starve the kind's other watches either. // not starve the kind's other watches either.
@@ -335,7 +344,14 @@ impl Source for ForgeSource {
if watch.forge != self.name { if watch.forge != self.name {
continue; continue;
} }
match self.poll_account(&watch).await { let polled = self.poll_account(&watch).await;
self.record_poll(
SCOPE_ACCOUNT,
&watch.forge,
&watch.account_id,
polled.as_ref().err().map(|err| err.to_string()),
);
match polled {
Ok(count) => fed += count, Ok(count) => fed += count,
// One dead account (a revoked token, a forge outage) // One dead account (a revoked token, a forge outage)
// must not starve the kind's other watches either. // must not starve the kind's other watches either.
@@ -350,6 +366,47 @@ impl Source for ForgeSource {
} }
impl ForgeSource { impl ForgeSource {
/// Records one poll's outcome, and says something the first time a
/// streak begins: a watch refused on every tick looks exactly like a
/// quiet repository from the outside, and only the failing tick knows
/// the difference. One notification per streak — the health row
/// carries the count, so the log line does not have to.
fn record_poll(&self, scope: &str, forge: &str, subject: &str, failure: Option<String>) {
// The table stamps in i64 millis like every other column here;
// the clock speaks u64.
let at = SystemClock.now_millis() as i64;
let recorded = match &failure {
Some(error) => self
.store
.record_poll_failure(scope, forge, subject, error, at),
None => self.store.record_poll_ok(scope, forge, subject, at),
};
let health = match recorded {
Ok(health) => health,
Err(err) => {
// Losing the health row must not stop the poll loop.
eprintln!("[claw] warning: watch health for {subject}: {err}");
return;
}
};
if !health.opens_a_failure_streak() {
return;
}
let Some(error) = failure else {
return;
};
let title = format!("{forge} watch {subject} is failing");
let body = format!(
"{error}\n\nFirst failure of the streak: GET /v1/watches has the count and the error."
);
if let Err(err) =
self.notifications
.push(crate::notifications::DEFAULT_DEVICE, &title, &body)
{
eprintln!("[claw] warning: notifying {title} failed: {err}");
}
}
/// One account-watch poll: fetch the account's notifications as the /// One account-watch poll: fetch the account's notifications as the
/// account, triage each, and land the verdicts — agent dispatches /// account, triage each, and land the verdicts — agent dispatches
/// ingest as role-tagged thread events, surfaces hit the /// ingest as role-tagged thread events, surfaces hit the
@@ -654,6 +711,60 @@ mod tests {
); );
} }
/// A watch that cannot poll is not a quiet watch: the pass records
/// the failure in the health table and tells the device once per
/// streak, so a dead credential looks dead instead of quiet.
#[tokio::test]
async fn a_failing_poll_records_health_and_notifies_once() {
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
// Nothing listens on port 1: every poll fails on the connect.
store
.add("gitea", "acme/barn", Some("http://127.0.0.1:1"), "")
.expect("add");
let (dispatcher, accounts, notifications) = test_parts(store.clone());
let source = ForgeSource::new(
&dispatcher,
"gitea",
store.clone(),
accounts,
notifications.clone(),
);
source
.detect()
.await
.expect("a failed poll is not a pass error");
let health = store
.health(SCOPE_REPO, "gitea", "acme/barn")
.expect("health reads")
.expect("the failure is recorded");
assert_eq!(health.consecutive_failures, 1);
assert_eq!(health.last_ok_at, None, "it has never polled once");
assert!(health.last_error.is_some(), "the reason is kept");
let queued = notifications
.pending(crate::notifications::DEFAULT_DEVICE, 0)
.expect("the queue reads");
assert_eq!(queued.len(), 1, "one notification for the streak");
assert!(
queued[0].title.contains("acme/barn"),
"the title names the watch: {}",
queued[0].title
);
// The second failing pass extends the streak and stays quiet.
source.detect().await.expect("the second pass runs");
let health = store
.health(SCOPE_REPO, "gitea", "acme/barn")
.expect("health reads")
.expect("the failure is still recorded");
assert_eq!(health.consecutive_failures, 2);
let queued = notifications
.pending(crate::notifications::DEFAULT_DEVICE, 0)
.expect("the queue reads");
assert_eq!(queued.len(), 1, "the streak is not re-announced");
}
/// The client cache pays off when two watches share their /// The client cache pays off when two watches share their
/// coordinates: one client, not two. /// coordinates: one client, not two.
#[tokio::test] #[tokio::test]
+13 -3
View File
@@ -834,9 +834,19 @@ async fn watches_list(State(state): State<ClawState>) -> Result<Json<Value>, (St
.watches .watches
.list_account_watches() .list_account_watches()
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
Ok(Json( // The poll health rides along for the same reason the account
json!({ "watches": watches, "account_watches": account_watches }), // watches do: "has this ever polled?" is the first question a silent
)) // watch raises, and a tick that failed with a 401 answers it, while
// an empty table would say nothing either way.
let health = state
.watches
.list_health()
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
Ok(Json(json!({
"watches": watches,
"account_watches": account_watches,
"health": health,
})))
} }
/// `{"forge", "repo", "host"?, "token"?}` — the shape every watch entry /// `{"forge", "repo", "host"?, "token"?}` — the shape every watch entry
+236
View File
@@ -8,6 +8,12 @@
//! config no longer carries watches at all — the table is the one //! config no longer carries watches at all — the table is the one
//! truth, managed over `/v1/watches` and (from chat) the watch tools. //! truth, managed over `/v1/watches` and (from chat) the watch tools.
//! //!
//! Beside it, the poll health of every watch ([`WatchHealth`]): when it
//! last polled successfully, why it last failed and how many failures
//! that makes. A watch that has been refused by its forge on every tick
//! since it was created looks exactly like a quiet repository without
//! this — the difference has to be readable somewhere.
//!
//! 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
//! busy timeout, the pattern rhema-rsd's SQLite tests use — and the two //! busy timeout, the pattern rhema-rsd's SQLite tests use — and the two
@@ -98,6 +104,16 @@ CREATE TABLE IF NOT EXISTS account_watches (
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
UNIQUE(forge, account_id) UNIQUE(forge, account_id)
); );
CREATE TABLE IF NOT EXISTS watch_health (
scope TEXT NOT NULL,
forge TEXT NOT NULL,
subject TEXT NOT NULL,
last_ok_at INTEGER,
last_error TEXT,
last_error_at INTEGER,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (scope, forge, subject)
);
"; ";
/// Why a watch-table operation failed. /// Why a watch-table operation failed.
@@ -192,6 +208,45 @@ pub struct AccountWatchRecord {
pub created_at: i64, pub created_at: i64,
} }
/// The scope of a health row: a repository watch's thread feed.
pub const SCOPE_REPO: &str = "repo";
/// The scope of a health row: an account's notification feed.
pub const SCOPE_ACCOUNT: &str = "account";
/// One watch's poll health, as stored: the last success, the last
/// failure and its reason, and the streak length. `last_ok_at: None` with
/// `consecutive_failures: 0` is a watch that has not polled yet; a growing
/// `consecutive_failures` with no `last_ok_at` is a watch that has never
/// once succeeded — the shape a wrong or mislabelled credential leaves.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WatchHealth {
/// [`SCOPE_REPO`] or [`SCOPE_ACCOUNT`].
pub scope: String,
/// The forge's source name: `github`, `gitea`, or `gitlab`.
pub forge: String,
/// The repo (`owner/name`) for a repo watch, the account id for an
/// account watch.
pub subject: String,
/// When a poll last succeeded, millis since the Unix epoch.
pub last_ok_at: Option<i64>,
/// The last failure's reason, as the poll reported it.
pub last_error: Option<String>,
/// When that failure happened, millis since the Unix epoch.
pub last_error_at: Option<i64>,
/// Failures since the last success — what tells a caller to say
/// something once, not on every tick of a long outage.
pub consecutive_failures: i64,
}
impl WatchHealth {
/// Whether this is the first failure of a streak: the moment worth a
/// notification, before the tick's log line becomes wallpaper.
pub fn opens_a_failure_streak(&self) -> bool {
self.consecutive_failures == 1
}
}
impl ClawStore { impl ClawStore {
/// Opens (creating if needed) the watch table in the database at /// Opens (creating if needed) the watch table in the database at
/// `path` — the same file the dispatcher's store holds. WAL keeps the /// `path` — the same file the dispatcher's store holds. WAL keeps the
@@ -364,6 +419,142 @@ impl ClawStore {
.map_err(WatchStoreError::from)?; .map_err(WatchStoreError::from)?;
Ok(rows) Ok(rows)
} }
/// Records a successful poll: the streak resets, and the row stops
/// reading as broken.
pub fn record_poll_ok(
&self,
scope: &str,
forge: &str,
subject: &str,
at: i64,
) -> Result<WatchHealth, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute(
"INSERT INTO watch_health (scope, forge, subject, last_ok_at, consecutive_failures)
VALUES (?1, ?2, ?3, ?4, 0)
ON CONFLICT(scope, forge, subject) DO UPDATE SET
last_ok_at = excluded.last_ok_at,
consecutive_failures = 0",
params![scope, forge, subject, at],
)
.map_err(WatchStoreError::from)?;
read_health(&conn, scope, forge, subject)
}
/// Records a failed poll and the reason: the streak grows, and the
/// last success (if any) stays visible beside it.
pub fn record_poll_failure(
&self,
scope: &str,
forge: &str,
subject: &str,
error: &str,
at: i64,
) -> Result<WatchHealth, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute(
"INSERT INTO watch_health
(scope, forge, subject, last_error, last_error_at, consecutive_failures)
VALUES (?1, ?2, ?3, ?4, ?5, 1)
ON CONFLICT(scope, forge, subject) DO UPDATE SET
last_error = excluded.last_error,
last_error_at = excluded.last_error_at,
consecutive_failures = watch_health.consecutive_failures + 1",
params![scope, forge, subject, error, at],
)
.map_err(WatchStoreError::from)?;
read_health(&conn, scope, forge, subject)
}
/// Every health row, ordered for a stable answer.
pub fn list_health(&self) -> Result<Vec<WatchHealth>, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut statement = conn
.prepare(
"SELECT scope, forge, subject, last_ok_at, last_error, last_error_at,
consecutive_failures
FROM watch_health ORDER BY scope ASC, forge ASC, subject ASC",
)
.map_err(WatchStoreError::from)?;
let rows = statement
.query_map([], |row| {
Ok(WatchHealth {
scope: row.get(0)?,
forge: row.get(1)?,
subject: row.get(2)?,
last_ok_at: row.get(3)?,
last_error: row.get(4)?,
last_error_at: row.get(5)?,
consecutive_failures: row.get(6)?,
})
})
.map_err(WatchStoreError::from)?
.collect::<Result<Vec<_>, rusqlite::Error>>()
.map_err(WatchStoreError::from)?;
Ok(rows)
}
/// One watch's health, when it has polled at all.
pub fn health(
&self,
scope: &str,
forge: &str,
subject: &str,
) -> Result<Option<WatchHealth>, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut statement = conn
.prepare(
"SELECT scope, forge, subject, last_ok_at, last_error, last_error_at,
consecutive_failures
FROM watch_health WHERE scope = ?1 AND forge = ?2 AND subject = ?3",
)
.map_err(WatchStoreError::from)?;
let mut rows = statement
.query_map(params![scope, forge, subject], |row| {
Ok(WatchHealth {
scope: row.get(0)?,
forge: row.get(1)?,
subject: row.get(2)?,
last_ok_at: row.get(3)?,
last_error: row.get(4)?,
last_error_at: row.get(5)?,
consecutive_failures: row.get(6)?,
})
})
.map_err(WatchStoreError::from)?;
match rows.next() {
Some(row) => Ok(Some(row.map_err(WatchStoreError::from)?)),
None => Ok(None),
}
}
}
/// One health row by key: the upserts' return value, read back under the
/// connection they wrote with.
fn read_health(
conn: &rusqlite::Connection,
scope: &str,
forge: &str,
subject: &str,
) -> Result<WatchHealth, WatchStoreError> {
conn.query_row(
"SELECT scope, forge, subject, last_ok_at, last_error, last_error_at,
consecutive_failures
FROM watch_health WHERE scope = ?1 AND forge = ?2 AND subject = ?3",
params![scope, forge, subject],
|row| {
Ok(WatchHealth {
scope: row.get(0)?,
forge: row.get(1)?,
subject: row.get(2)?,
last_ok_at: row.get(3)?,
last_error: row.get(4)?,
last_error_at: row.get(5)?,
consecutive_failures: row.get(6)?,
})
},
)
.map_err(WatchStoreError::from)
} }
/// Milliseconds since the Unix epoch; stamps `created_at`. /// Milliseconds since the Unix epoch; stamps `created_at`.
@@ -401,6 +592,51 @@ pub fn validate_watch(
mod tests { mod tests {
use super::*; use super::*;
/// A watch's health is a streak, not a flag: the first failure opens
/// it, the next extends it, and a success closes it without losing
/// the last success's stamp.
#[test]
fn a_health_row_tracks_the_streak_and_survives_a_recovery() {
let store = ClawStore::in_memory().expect("store opens");
assert_eq!(
store
.health(SCOPE_REPO, "gitea", "acme/barn")
.expect("read"),
None,
"a watch that never polled has no row"
);
let first = store
.record_poll_failure(SCOPE_REPO, "gitea", "acme/barn", "401 denied", 1_000)
.expect("failure lands");
assert_eq!(first.consecutive_failures, 1);
assert!(first.opens_a_failure_streak());
assert_eq!(first.last_ok_at, None);
assert_eq!(first.last_error.as_deref(), Some("401 denied"));
let second = store
.record_poll_failure(SCOPE_REPO, "gitea", "acme/barn", "401 denied", 2_000)
.expect("failure lands");
assert_eq!(second.consecutive_failures, 2);
assert!(
!second.opens_a_failure_streak(),
"only the first failure of a streak is news"
);
assert_eq!(second.last_error_at, Some(2_000));
let recovered = store
.record_poll_ok(SCOPE_REPO, "gitea", "acme/barn", 3_000)
.expect("success lands");
assert_eq!(recovered.consecutive_failures, 0);
assert_eq!(recovered.last_ok_at, Some(3_000));
assert_eq!(
recovered.last_error.as_deref(),
Some("401 denied"),
"the last failure stays on the record after a recovery"
);
assert_eq!(store.list_health().expect("list reads").len(), 1);
}
#[test] #[test]
fn validation_fails_a_watch_loudly_and_specificly() { fn validation_fails_a_watch_loudly_and_specificly() {
assert_eq!( assert_eq!(
+29 -3
View File
@@ -18,7 +18,7 @@ use rhema_autonomous_agent::{ToolError, ToolOp, ToolRegistry, async_trait};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::watch_store::{ClawStore, validate_watch}; use crate::watch_store::{ClawStore, SCOPE_REPO, validate_watch};
use rhema_forge_integration::ForgeKind; use rhema_forge_integration::ForgeKind;
/// The compiled operations behind the manifests, by `run.builtin` id. /// The compiled operations behind the manifests, by `run.builtin` id.
@@ -161,13 +161,25 @@ struct ListWatches {
impl ToolOp for ListWatches { impl ToolOp for ListWatches {
async fn invoke(&self, _args: Value) -> Result<String, ToolError> { async fn invoke(&self, _args: Value) -> Result<String, ToolError> {
let watches = self.store.list().map_err(|err| err.to_string())?; let watches = self.store.list().map_err(|err| err.to_string())?;
let health = self.store.list_health().map_err(|err| err.to_string())?;
let rows: Vec<Value> = watches let rows: Vec<Value> = watches
.iter() .iter()
.map(|watch| { .map(|watch| {
// A watch that has never polled has no health row at all:
// it answers with every health field empty and a zero
// streak, which is exactly what it is.
let health = health.iter().find(|row| {
row.scope == SCOPE_REPO && row.forge == watch.forge && row.subject == watch.repo
});
json!({ json!({
"forge": watch.forge, "forge": watch.forge,
"repo": watch.repo, "repo": watch.repo,
"host": watch.host, "host": watch.host,
"last_ok_at": health.and_then(|row| row.last_ok_at),
"last_error": health.and_then(|row| row.last_error.clone()),
"consecutive_failures": health
.map(|row| row.consecutive_failures)
.unwrap_or(0),
}) })
}) })
.collect(); .collect();
@@ -363,8 +375,22 @@ mod tests {
assert_eq!( assert_eq!(
serde_json::from_str::<Value>(&listed.content).unwrap(), serde_json::from_str::<Value>(&listed.content).unwrap(),
json!({"watches": [ json!({"watches": [
{"forge": "github", "repo": "acme/widgets", "host": null}, {
{"forge": "gitlab", "repo": "group/prop", "host": null}, "forge": "github",
"repo": "acme/widgets",
"host": null,
"last_ok_at": null,
"last_error": null,
"consecutive_failures": 0,
},
{
"forge": "gitlab",
"repo": "group/prop",
"host": null,
"last_ok_at": null,
"last_error": null,
"consecutive_failures": 0,
},
]}), ]}),
"{}", "{}",
listed.content listed.content
@@ -2,7 +2,11 @@
# compiled builtin named under `run`. # compiled builtin named under `run`.
name: list_watches name: list_watches
description: >- description: >-
List the watched repositories: forge, repo, and host for each. List the watched repositories: forge, repo, and host for each, with its
poll health — last_ok_at is when it last polled successfully,
consecutive_failures is the streak it is on now, and last_error is why
the last attempt failed. A watch with failures and no last_ok_at has
never polled once.
replay_safety: safe replay_safety: safe
parameters: parameters:
type: object type: object