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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user