feat(event-triage): per-user check interval, overriding the instance one
Nightly Build / build (push) Successful in 5m4s
Nightly Build / build (push) Successful in 5m4s
Event triage is the one system agent whose right cadence depends on who it
runs for: it fires on inbound events, so someone on a dozen mailing lists
has something waiting on nearly every tick while a quiet account has
something waiting almost never. A single instance-wide interval serves one
of them badly, and the observed failure is the first: the agent starts on
practically every pass.
An admin can now set a per-person interval on that user's page (Users ->
the person -> Event triage). Empty means "follow the instance setting",
which stays the state nobody has a row for.
- New registry table `system_agent_user_settings(agent_id, user_id,
interval_secs)`. A row is an override and its absence is inheritance --
no sentinel value, no row seeded at user creation, clearing the field
deletes the row. Registry rather than the user's own file because the
writer is the admin and a member's database is unreadable unless they
happen to be logged in; a setting that could only be changed during its
subject's session would not be a setting. Keyed by agent_id though only
one agent uses it, so a future agent's schedule is not a schema change.
- `SystemAgent` gains `interval_secs_for(user_id)`, which `is_due` now
measures against, and `shortest_interval_secs()`. Both default to the
existing `interval_secs`, so every other agent implements nothing. The
second is the non-obvious half: `base_tick` sleeps for the shortest
interval any enabled agent asks for, so without it an override below the
instance value would be rounded up to it -- an override that works when
it lengthens and silently does nothing when it shortens.
- `GET/PUT /api/users/{id}/event-triage`, admin-gated, minutes on the
wire, null to clear. Nothing rides the bus: the scheduler re-reads the
interval every tick and due-ness is counted from the user's own last
attempt, so a change lands on the next wake-up with no push.
Both helpers fail open onto the instance value -- an unreadable registry
must not turn into an agent that stops running for someone.
Docs: docs/system-agents.md gains the per-person section and no longer
reads as if the interval were one number for everybody.
This commit is contained in:
@@ -35,6 +35,7 @@ pub mod supervision;
|
||||
pub mod system_agent_coverage;
|
||||
pub mod system_agent_runs;
|
||||
pub mod system_agent_state;
|
||||
pub mod system_agent_user_settings;
|
||||
pub mod tool_permission_groups;
|
||||
pub mod user_config;
|
||||
pub mod users;
|
||||
@@ -192,7 +193,7 @@ async fn ensure_column(pool: &SqlitePool, table: &str, column: &str, decl: &str)
|
||||
// Instance-wide, readable without any user key: the directory you must open
|
||||
// before you know who exists. Nothing here is scoped to one user.
|
||||
|
||||
async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
pub(crate) async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS llm_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -760,6 +761,34 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Per-user overrides of a system agent's schedule. **A row is an override and
|
||||
// nothing else** — its absence means "use the instance-wide setting", which is
|
||||
// why there is no `inherit` flag and no row written at user creation.
|
||||
//
|
||||
// Registry rather than owner, and not for the reason `system_agent_coverage`
|
||||
// is: this one is written *by the admin about a member*, on the Users page,
|
||||
// and a member's own file is unreadable unless they happen to be logged in
|
||||
// (§9). A setting an admin can only change while its subject has a live
|
||||
// session would not be a setting. It is admin-readable, like the rest of the
|
||||
// directory metadata next to it, and holds no content — a number of seconds.
|
||||
//
|
||||
// `agent_id` is bare TEXT with no `system_agent_*` table to reference (the
|
||||
// agents are code, not rows), and is kept in the key even though only event
|
||||
// triage uses it today: the alternative is a column per agent on `users`, and
|
||||
// "a fourth agent is a trait impl plus one registry line" would stop being
|
||||
// true the moment its schedule needed a schema change.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS system_agent_user_settings (
|
||||
agent_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
interval_secs INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (agent_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Accessor for `system_agent_user_settings` — per-user overrides of a system
|
||||
//! agent's schedule.
|
||||
//!
|
||||
//! The whole contract is in the absence of a row: **no row means the instance
|
||||
//! setting applies**, so every read here answers `Option` and every caller falls
|
||||
//! back rather than defaulting. Clearing an override therefore [`clear`]s the row
|
||||
//! instead of writing a sentinel — a `0` or a `-1` standing for "inherit" would
|
||||
//! be a second way to say what the empty table already says, and the two would
|
||||
//! eventually disagree.
|
||||
//!
|
||||
//! Registry table: written by an admin about a member, from the Users page. See
|
||||
//! the table comment in [`super::create_registry_tables`] for why it cannot live
|
||||
//! in the member's own file.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// One user's override of `agent_id`'s interval, in seconds, or `None` when they
|
||||
/// have none and the instance-wide setting stands.
|
||||
pub async fn interval_secs(
|
||||
pool: &SqlitePool,
|
||||
agent_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<i64>> {
|
||||
let secs = sqlx::query_scalar::<_, Option<i64>>(
|
||||
"SELECT interval_secs FROM system_agent_user_settings
|
||||
WHERE agent_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(secs)
|
||||
}
|
||||
|
||||
/// Set `user_id`'s override for `agent_id`.
|
||||
pub async fn set_interval_secs(
|
||||
pool: &SqlitePool,
|
||||
agent_id: &str,
|
||||
user_id: &str,
|
||||
secs: i64,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO system_agent_user_settings (agent_id, user_id, interval_secs)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(agent_id, user_id) DO UPDATE SET
|
||||
interval_secs = excluded.interval_secs,
|
||||
updated_at = datetime('now')",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(user_id)
|
||||
.bind(secs)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop `user_id`'s override, so they follow the instance setting again.
|
||||
pub async fn clear(pool: &SqlitePool, agent_id: &str, user_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM system_agent_user_settings WHERE agent_id = ? AND user_id = ?")
|
||||
.bind(agent_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The shortest override anyone holds for `agent_id`, or `None` when nobody
|
||||
/// overrides it.
|
||||
///
|
||||
/// Exists for the scheduler's wake-up: it sleeps for the shortest interval any
|
||||
/// enabled agent asks for, and an override *below* the instance value would
|
||||
/// otherwise be rounded up to it — silently, and only in that direction, which is
|
||||
/// the kind of half-working setting that is worse than one that does nothing.
|
||||
pub async fn shortest_interval_secs(pool: &SqlitePool, agent_id: &str) -> Result<Option<i64>> {
|
||||
let secs = sqlx::query_scalar::<_, Option<i64>>(
|
||||
"SELECT MIN(interval_secs) FROM system_agent_user_settings
|
||||
WHERE agent_id = ? AND interval_secs IS NOT NULL",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(secs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AGENT: &str = "event-triage";
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
crate::db::create_registry_tables(&pool).await.unwrap();
|
||||
crate::db::roles::seed_admin(&pool).await.unwrap();
|
||||
for id in ["alice", "bob"] {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, 'admin', 0)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_row_means_inherit() {
|
||||
let pool = pool().await;
|
||||
assert_eq!(interval_secs(&pool, AGENT, "alice").await.unwrap(), None);
|
||||
assert_eq!(shortest_interval_secs(&pool, AGENT).await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_override_is_set_then_replaced_then_cleared() {
|
||||
let pool = pool().await;
|
||||
set_interval_secs(&pool, AGENT, "alice", 3600).await.unwrap();
|
||||
assert_eq!(interval_secs(&pool, AGENT, "alice").await.unwrap(), Some(3600));
|
||||
|
||||
set_interval_secs(&pool, AGENT, "alice", 1800).await.unwrap();
|
||||
assert_eq!(interval_secs(&pool, AGENT, "alice").await.unwrap(), Some(1800));
|
||||
let rows = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM system_agent_user_settings")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 1, "setting an override must upsert, not accumulate");
|
||||
|
||||
clear(&pool, AGENT, "alice").await.unwrap();
|
||||
assert_eq!(interval_secs(&pool, AGENT, "alice").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_and_agents_do_not_share_a_row() {
|
||||
let pool = pool().await;
|
||||
set_interval_secs(&pool, AGENT, "alice", 3600).await.unwrap();
|
||||
assert_eq!(interval_secs(&pool, AGENT, "bob").await.unwrap(), None);
|
||||
assert_eq!(interval_secs(&pool, "memory-lint", "alice").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_shortest_override_is_the_scheduler_floor() {
|
||||
let pool = pool().await;
|
||||
set_interval_secs(&pool, AGENT, "alice", 3600).await.unwrap();
|
||||
set_interval_secs(&pool, AGENT, "bob", 120).await.unwrap();
|
||||
assert_eq!(shortest_interval_secs(&pool, AGENT).await.unwrap(), Some(120));
|
||||
// Another agent's overrides must not drag this one's wake-up down.
|
||||
set_interval_secs(&pool, "memory-lint", "alice", 60).await.unwrap();
|
||||
assert_eq!(shortest_interval_secs(&pool, AGENT).await.unwrap(), Some(120));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_user_takes_their_overrides() {
|
||||
let pool = pool().await;
|
||||
set_interval_secs(&pool, AGENT, "alice", 3600).await.unwrap();
|
||||
sqlx::query("DELETE FROM users WHERE id = 'alice'").execute(&pool).await.unwrap();
|
||||
assert_eq!(interval_secs(&pool, AGENT, "alice").await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user