feat(event-triage): per-user check interval, overriding the instance one
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:
Daniele
2026-08-14 13:07:45 +01:00
parent 402c9ffe50
commit e7c802f0d7
13 changed files with 573 additions and 12 deletions
+4
View File
@@ -219,6 +219,10 @@ pub fn router() -> Router<Arc<Skald>> {
// page answers "what does Marco have?" instead of N connector/plugin pages.
.route("/users/{id}/connectors", get(mcp::user_connectors_get).put(mcp::user_connectors_set))
.route("/users/{id}/plugins", get(plugins::user_plugins_get).put(plugins::user_plugins_set))
// Not a grant: this person's own event-triage cadence, overriding the
// instance one. Same page for the same reason — it is a question about a
// person, and the answer belongs where the person is.
.route("/users/{id}/event-triage", get(system_agents::user_event_triage_get).put(system_agents::user_event_triage_set))
// Shared on-disk folders (blueprint §6) — admin-curated, capability-gated.
.route("/shared-folders", get(shared_folders::list).post(shared_folders::create))
+109 -1
View File
@@ -27,7 +27,8 @@ use axum::{
use serde::Deserialize;
use serde_json::{Value, json};
use skald_core::db::system_agent_runs;
use skald_core::db::{system_agent_runs, system_agent_user_settings, users};
use skald_core::event_triage::EVENT_TRIAGE_AGENT;
use skald_core::skald::Skald;
use skald_core::system_agents::{AgentScope, ManualRun, ManualRunError};
@@ -154,6 +155,113 @@ pub async fn list_runs(
})))
}
// ── Per-user schedule (admin, from the Users page) ───────────────────────────
//
// Event triage only, deliberately, even though the table behind it is keyed by
// agent. It is the one agent whose cadence is a property of the *person* rather
// than of the instance: it fires on inbound events, so someone on a dozen
// mailing lists is triaged on nearly every tick while a quiet account is
// triaged once a day, from the same setting. The lints read a store that only
// its owner edits, and the review is pinned to an hour of the night — neither
// has a per-person version of that problem, and a field on a page is a question
// the admin then has to answer for everybody.
/// Minutes accepted for an override. The floor is the scheduler's own tick
/// floor — anything below it is a number the loop cannot honour and would only
/// mislead. The ceiling is a day, past which "every so often" has stopped being
/// triage.
const MIN_OVERRIDE_MINUTES: i64 = 1;
const MAX_OVERRIDE_MINUTES: i64 = 24 * 60;
/// `GET /api/users/{id}/event-triage` — that user's schedule for event triage.
///
/// `interval_minutes` is `null` when they have no override, which is the state
/// the form renders as "instance default" — never as the default's value, or
/// saving an untouched form would silently pin them to today's setting.
pub async fn user_event_triage_get(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(target): Path<String>,
) -> Result<Json<Value>, ApiError> {
caps::require_admin(&skald, &auth.user_id).await?;
require_user(&skald, &target).await?;
let override_secs =
system_agent_user_settings::interval_secs(skald.db(), EVENT_TRIAGE_AGENT, &target).await?;
Ok(Json(json!({
"interval_minutes": override_secs.map(|s| s / 60),
"default_interval_minutes": instance_interval_minutes(&skald).await,
})))
}
#[derive(Deserialize)]
pub struct UserEventTriageBody {
/// `None` (or a missing field) clears the override and returns the user to
/// the instance schedule.
#[serde(default)]
pub interval_minutes: Option<i64>,
}
/// `PUT /api/users/{id}/event-triage` — set or clear that user's override.
pub async fn user_event_triage_set(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(target): Path<String>,
Json(body): Json<UserEventTriageBody>,
) -> Result<Json<Value>, ApiError> {
caps::require_admin(&skald, &auth.user_id).await?;
require_user(&skald, &target).await?;
match body.interval_minutes {
Some(minutes) => {
if !(MIN_OVERRIDE_MINUTES..=MAX_OVERRIDE_MINUTES).contains(&minutes) {
return Err(ApiError::bad_request(format!(
"the interval must be between {MIN_OVERRIDE_MINUTES} and \
{MAX_OVERRIDE_MINUTES} minutes"
)));
}
system_agent_user_settings::set_interval_secs(
skald.db(),
EVENT_TRIAGE_AGENT,
&target,
minutes * 60,
)
.await?;
}
None => {
system_agent_user_settings::clear(skald.db(), EVENT_TRIAGE_AGENT, &target).await?;
}
}
// Nothing is pushed: the scheduler re-reads the interval on every tick, and
// due-ness is measured from the user's own last attempt — so a change lands
// on the next wake-up (at most the base tick away) with no event and no
// subscriber. The bus is for reconciliation of live state; this is a number
// read from the database each time it is needed.
Ok(Json(json!({
"interval_minutes": body.interval_minutes,
"default_interval_minutes": instance_interval_minutes(&skald).await,
})))
}
/// The instance-wide event-triage interval, in whole minutes, for the form's
/// "default" label. Read from the agent itself rather than the config key, so
/// the fallback to `config.yml` is the same one the scheduler makes.
async fn instance_interval_minutes(skald: &Skald) -> i64 {
match skald.system_agents().get(EVENT_TRIAGE_AGENT) {
Some(agent) => (agent.interval_secs().await / 60).max(1) as i64,
None => 0,
}
}
async fn require_user(skald: &Skald, user_id: &str) -> Result<(), ApiError> {
users::get(skald.db(), user_id)
.await?
.ok_or_else(|| ApiError::not_found("no such user"))?;
Ok(())
}
/// `POST /api/system-agents/{agent_id}/run` — run this agent **now**, for the caller.
///
/// **Not admin-gated, and that is the same decision the run log makes.** A pass