Release 0.2.0 #4
@@ -132,7 +132,7 @@ Two rules keep the boundary real, and both are enforced by the compiler:
|
|||||||
|
|
||||||
The schema is split into two buckets (§5.1), and the split is the point:
|
The schema is split into two buckets (§5.1), and the split is the point:
|
||||||
|
|
||||||
- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `plugin_access` + `plugin_user_configs`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`, `projects` + `project_members`, `supervision`, `system_agent_coverage`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two pairs are junction-backed membership: `shared_folder_members` (accessor `db/shared_folders.rs`) for the on-disk shared folders (§6), `project_members` (accessor `db/project_members.rs`) for projects (see the Projects section) — both let a member be read-only (`can_write`) and both drive the container mount topology + the fs routing. Their FKs are registry→registry (same file), which is allowed — unlike an owner→registry key.
|
- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `plugin_access` + `plugin_user_configs`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`, `projects` + `project_members`, `supervision`, `system_agent_coverage`, `system_agent_user_settings`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two pairs are junction-backed membership: `shared_folder_members` (accessor `db/shared_folders.rs`) for the on-disk shared folders (§6), `project_members` (accessor `db/project_members.rs`) for projects (see the Projects section) — both let a member be read-only (`can_write`) and both drive the container mount topology + the fs routing. Their FKs are registry→registry (same file), which is allowed — unlike an owner→registry key.
|
||||||
- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `system_agent_runs`, `system_agent_state`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `user_config`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`), `reports`. `user_config` is the per-user twin of the registry `config` table and deliberately does **not** share its name: the two hold different namespaces (instance settings the admin owns vs. one member's own preferences, the notification home being the first), and a same-named table in both files would turn a wrong-pool call into a silent read of the other scope — instead of the "no such table: config" that revealed `/sethome` writing owner state through `db::config` against a `{userid}.db`, which also had the notification consumer dropping every batch it ever built. `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. (`projects`/`project_tickets` were owner tables in the single-user past: projects are shareable now, so `projects` + `project_members` are registry tables and `project_tickets` is gone.)
|
- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `system_agent_runs`, `system_agent_state`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `user_config`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`), `reports`. `user_config` is the per-user twin of the registry `config` table and deliberately does **not** share its name: the two hold different namespaces (instance settings the admin owns vs. one member's own preferences, the notification home being the first), and a same-named table in both files would turn a wrong-pool call into a silent read of the other scope — instead of the "no such table: config" that revealed `/sethome` writing owner state through `db::config` against a `{userid}.db`, which also had the notification consumer dropping every batch it ever built. `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. (`projects`/`project_tickets` were owner tables in the single-user past: projects are shareable now, so `projects` + `project_members` are registry tables and `project_tickets` is gone.)
|
||||||
|
|
||||||
**The schema is no longer greenfield** (see the production note at the top): a full recreate is not an option anymore. `db::ensure_column` — `ALTER TABLE … ADD COLUMN` swallowing the "duplicate column" error, a no-op on a fresh DB where the `CREATE TABLE` already carries it — is therefore not a convenience for dev boxes anymore but the **only** change shape that is currently safe, and additive-with-a-default is the shape to design towards. Used for the OAuth columns on `mcp_catalog` / `mcp_user_servers`. Anything destructive waits for real versioning.
|
**The schema is no longer greenfield** (see the production note at the top): a full recreate is not an option anymore. `db::ensure_column` — `ALTER TABLE … ADD COLUMN` swallowing the "duplicate column" error, a no-op on a fresh DB where the `CREATE TABLE` already carries it — is therefore not a convenience for dev boxes anymore but the **only** change shape that is currently safe, and additive-with-a-default is the shape to design towards. Used for the OAuth columns on `mcp_catalog` / `mcp_user_servers`. Anything destructive waits for real versioning.
|
||||||
@@ -302,6 +302,8 @@ A **system agent** runs on a user's behalf without being asked. There are three
|
|||||||
|
|
||||||
**Interval units are per-agent**: event triage in minutes, the lints in days (`interval_from_config` takes the unit). Asking an admin to type `10080` for "weekly" would be a worse version of the same field.
|
**Interval units are per-agent**: event triage in minutes, the lints in days (`interval_from_config` takes the unit). Asking an admin to type `10080` for "weekly" would be a worse version of the same field.
|
||||||
|
|
||||||
|
**The cadence is per user for exactly one agent, and the trait says so in two methods, not one.** Event triage fires on *inbound* events, so how often it has work is a property of the person — someone on a dozen mailing lists triggers it on nearly every tick from the same setting that leaves a quiet account idle for a day. So `SystemAgent` gained `interval_secs_for(user_id)` (what `is_due` measures against) beside the instance-wide `interval_secs`, both defaulting to the latter so every other agent implements nothing. The second method is the non-obvious half: `base_tick` sleeps for the shortest interval any enabled agent asks for, so an agent whose overrides can go *below* its instance value must also implement `shortest_interval_secs` — without it the wake-up never comes round often enough and the override works when it lengthens and silently does nothing when it shortens. Storage is the registry table `system_agent_user_settings(agent_id, user_id, interval_secs)` (accessor + `interval_for_user`/`shortest_interval_for` helpers in `system_agents/mod.rs`, both failing **open** onto the instance value): **a row is an override, its absence is inheritance** — no sentinel value, no row written at user creation, and clearing the field deletes the row. Registry rather than the user's own `user_config` for a reason that is not about scope: the writer is the **admin**, on `#users/{id}`, and a member's file is unreadable unless they happen to be logged in (§9) — a setting that could only be changed while its subject has a live session would not be a setting. Endpoints `GET/PUT /api/users/{id}/event-triage` (admin-gated, minutes on the wire, `null` = inherit), rendered as one section on that person's page next to the grants. **Nothing rides the bus**: the scheduler re-reads the interval every tick and due-ness is measured from the user's own last attempt, so a change lands on the next wake-up with no push and no subscriber — the `ConfigKeyUpdated` reschedule stays for the *instance* key only. Keyed by `agent_id` though only one agent uses it, because 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.
|
||||||
|
|
||||||
### Where the settings live
|
### Where the settings live
|
||||||
|
|
||||||
`ConfigSet` gained `owner: Option<String>` (core-api): `None` renders on the general Config page, `Some(agent_id)` is claimed by the surface that owns it. Placement is **data on the set**, not a filter that knows set names, so a new owned set lands in the right place without touching either page. `system_agents::registry()` and `::config_sets()` are the single enumeration of the agents — `registry_and_config_sets_agree` is the test that stops the scheduler's list and the settings surface from drifting.
|
`ConfigSet` gained `owner: Option<String>` (core-api): `None` renders on the general Config page, `Some(agent_id)` is claimed by the surface that owns it. Placement is **data on the set**, not a filter that knows set names, so a new owned set lands in the right place without touching either page. `system_agents::registry()` and `::config_sets()` are the single enumeration of the agents — `registry_and_config_sets_agree` is the test that stops the scheduler's list and the settings surface from drifting.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub mod supervision;
|
|||||||
pub mod system_agent_coverage;
|
pub mod system_agent_coverage;
|
||||||
pub mod system_agent_runs;
|
pub mod system_agent_runs;
|
||||||
pub mod system_agent_state;
|
pub mod system_agent_state;
|
||||||
|
pub mod system_agent_user_settings;
|
||||||
pub mod tool_permission_groups;
|
pub mod tool_permission_groups;
|
||||||
pub mod user_config;
|
pub mod user_config;
|
||||||
pub mod users;
|
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
|
// Instance-wide, readable without any user key: the directory you must open
|
||||||
// before you know who exists. Nothing here is scoped to one user.
|
// 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(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS llm_providers (
|
"CREATE TABLE IF NOT EXISTS llm_providers (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -760,6 +761,34 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,8 +38,8 @@ use crate::config_store::GlobalConfigManager;
|
|||||||
use crate::db::mcp_events;
|
use crate::db::mcp_events;
|
||||||
use crate::system_agents::{
|
use crate::system_agents::{
|
||||||
AgentOutcome, AgentRunCtx, AgentScope, SystemAgent, configured_run_context,
|
AgentOutcome, AgentRunCtx, AgentScope, SystemAgent, configured_run_context,
|
||||||
enabled_from_config, enabled_property, interval_from_config, run_ephemeral_turn,
|
enabled_from_config, enabled_property, interval_for_user, interval_from_config,
|
||||||
security_group_property,
|
run_ephemeral_turn, security_group_property, shortest_interval_for,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The chat `source` the ephemeral triage sessions carry. Kept distinct from the
|
/// The chat `source` the ephemeral triage sessions carry. Kept distinct from the
|
||||||
@@ -77,7 +77,9 @@ pub fn config_set() -> ConfigSet {
|
|||||||
name: "Check interval (minutes)".into(),
|
name: "Check interval (minutes)".into(),
|
||||||
description: "How long between passes for each user, in minutes. Counted per \
|
description: "How long between passes for each user, in minutes. Counted per \
|
||||||
person from their own last pass. Leave empty to use the value from \
|
person from their own last pass. Leave empty to use the value from \
|
||||||
config.yml (event_triage.interval_secs)."
|
config.yml (event_triage.interval_secs). This is the default: a \
|
||||||
|
single user can be put on a slower (or faster) cadence from their \
|
||||||
|
own page under Users."
|
||||||
.into(),
|
.into(),
|
||||||
property_type: PropertyType::Int,
|
property_type: PropertyType::Int,
|
||||||
default_value: Some("15".into()),
|
default_value: Some("15".into()),
|
||||||
@@ -174,6 +176,19 @@ impl SystemAgent for EventTriageManager {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This user's own cadence, if an admin set one on their page.
|
||||||
|
async fn interval_secs_for(&self, user_id: &str) -> u64 {
|
||||||
|
let instance = self.interval_secs().await;
|
||||||
|
interval_for_user(&self.registry_pool, EVENT_TRIAGE_AGENT, user_id, instance).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shortest cadence anybody is on, so the scheduler's wake-up is frequent
|
||||||
|
/// enough to honour an override *below* the instance interval.
|
||||||
|
async fn shortest_interval_secs(&self) -> u64 {
|
||||||
|
let instance = self.interval_secs().await;
|
||||||
|
shortest_interval_for(&self.registry_pool, EVENT_TRIAGE_AGENT, instance).await
|
||||||
|
}
|
||||||
|
|
||||||
/// No pending events means no pass at all — and no row. The batch is re-read
|
/// No pending events means no pass at all — and no row. The batch is re-read
|
||||||
/// in [`EventTriageManager::triage`]; it is one indexed query on a small
|
/// in [`EventTriageManager::triage`]; it is one indexed query on a small
|
||||||
/// table, and paying it twice is cheaper than a trait shaped around carrying
|
/// table, and paying it twice is cheaper than a trait shaped around carrying
|
||||||
|
|||||||
@@ -395,6 +395,11 @@ pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>) {
|
|||||||
/// not to spin. The floor keeps a misconfigured one-minute interval from turning
|
/// not to spin. The floor keeps a misconfigured one-minute interval from turning
|
||||||
/// into a busy loop; the ceiling keeps a box that runs only weekly agents from
|
/// into a busy loop; the ceiling keeps a box that runs only weekly agents from
|
||||||
/// sleeping so long that a freshly changed setting takes hours to be noticed.
|
/// sleeping so long that a freshly changed setting takes hours to be noticed.
|
||||||
|
///
|
||||||
|
/// It asks each agent for its *shortest* interval rather than its instance one,
|
||||||
|
/// because a per-user override can be shorter than the instance setting and
|
||||||
|
/// would otherwise be rounded up to it — an override that works when it
|
||||||
|
/// lengthens and quietly does nothing when it shortens.
|
||||||
async fn base_tick(agents: &[Arc<dyn SystemAgent>]) -> Duration {
|
async fn base_tick(agents: &[Arc<dyn SystemAgent>]) -> Duration {
|
||||||
const FLOOR_SECS: u64 = 60;
|
const FLOOR_SECS: u64 = 60;
|
||||||
const CEIL_SECS: u64 = 15 * 60;
|
const CEIL_SECS: u64 = 15 * 60;
|
||||||
@@ -402,7 +407,7 @@ async fn base_tick(agents: &[Arc<dyn SystemAgent>]) -> Duration {
|
|||||||
let mut shortest = CEIL_SECS;
|
let mut shortest = CEIL_SECS;
|
||||||
for agent in agents {
|
for agent in agents {
|
||||||
if agent.is_enabled().await {
|
if agent.is_enabled().await {
|
||||||
shortest = shortest.min(agent.interval_secs().await);
|
shortest = shortest.min(agent.shortest_interval_secs().await);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Duration::from_secs(shortest.clamp(FLOOR_SECS, CEIL_SECS))
|
Duration::from_secs(shortest.clamp(FLOOR_SECS, CEIL_SECS))
|
||||||
@@ -621,7 +626,7 @@ async fn run_one(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if !system_agents::is_due(agent, &ctx.pool).await {
|
if !system_agents::is_due(agent, &ctx.pool, user_id).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,9 +157,36 @@ pub trait SystemAgent: Send + Sync {
|
|||||||
/// Instance-wide on/off switch, re-read every pass.
|
/// Instance-wide on/off switch, re-read every pass.
|
||||||
async fn is_enabled(&self) -> bool;
|
async fn is_enabled(&self) -> bool;
|
||||||
|
|
||||||
/// How long between passes **for one user**, in seconds.
|
/// How long between passes **for one user**, in seconds — the instance-wide
|
||||||
|
/// setting, which stands for anyone with no override of their own.
|
||||||
async fn interval_secs(&self) -> u64;
|
async fn interval_secs(&self) -> u64;
|
||||||
|
|
||||||
|
/// The same, for one named user: the effective cadence [`is_due`] measures
|
||||||
|
/// against.
|
||||||
|
///
|
||||||
|
/// Defaults to [`Self::interval_secs`], so an agent whose schedule is the
|
||||||
|
/// same for everybody implements nothing. Only event triage differs today,
|
||||||
|
/// and for a reason that does not generalise on its own: it fires on inbound
|
||||||
|
/// events, so its cadence is a property of *how much mail a person gets*
|
||||||
|
/// rather than of the instance — someone on a dozen mailing lists is triaged
|
||||||
|
/// on almost every tick, which is a per-person problem and wants a per-person
|
||||||
|
/// answer.
|
||||||
|
async fn interval_secs_for(&self, _user_id: &str) -> u64 {
|
||||||
|
self.interval_secs().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shortest interval this agent could ask for, over every user.
|
||||||
|
///
|
||||||
|
/// The scheduler sleeps for the shortest interval any enabled agent wants, so
|
||||||
|
/// an agent whose per-user overrides can go *below* its instance setting has
|
||||||
|
/// to say so here — otherwise the wake-up never comes round often enough and
|
||||||
|
/// the override silently only works in one direction. Defaults to
|
||||||
|
/// [`Self::interval_secs`] alongside the method above, so the two stay
|
||||||
|
/// consistent for an agent that implements neither.
|
||||||
|
async fn shortest_interval_secs(&self) -> u64 {
|
||||||
|
self.interval_secs().await
|
||||||
|
}
|
||||||
|
|
||||||
/// Cheap look at whether this pass would do anything, before a run row is
|
/// Cheap look at whether this pass would do anything, before a run row is
|
||||||
/// opened. `false` means "nothing to do" and leaves no trace behind.
|
/// opened. `false` means "nothing to do" and leaves no trace behind.
|
||||||
async fn has_work(&self, ctx: &AgentRunCtx<'_>) -> Result<bool>;
|
async fn has_work(&self, ctx: &AgentRunCtx<'_>) -> Result<bool>;
|
||||||
@@ -350,12 +377,12 @@ impl fmt::Display for ManualRunError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Is `agent` due for this user? `true` when it has never run here, or when the
|
/// Is `agent` due for this user? `true` when it has never run here, or when the
|
||||||
/// last attempt is older than the configured interval.
|
/// last attempt is older than the interval **that user** is on.
|
||||||
///
|
///
|
||||||
/// Read from the database rather than an in-memory deadline, which is what makes
|
/// Read from the database rather than an in-memory deadline, which is what makes
|
||||||
/// a weekly agent survive a restart — see the `system_agent_state` table comment.
|
/// a weekly agent survive a restart — see the `system_agent_state` table comment.
|
||||||
pub async fn is_due(agent: &dyn SystemAgent, pool: &SqlitePool) -> bool {
|
pub async fn is_due(agent: &dyn SystemAgent, pool: &SqlitePool, user_id: &str) -> bool {
|
||||||
let interval = agent.interval_secs().await as i64;
|
let interval = agent.interval_secs_for(user_id).await as i64;
|
||||||
match system_agent_state::seconds_since_attempt(pool, agent.id()).await {
|
match system_agent_state::seconds_since_attempt(pool, agent.id()).await {
|
||||||
Ok(Some(elapsed)) => elapsed >= interval,
|
Ok(Some(elapsed)) => elapsed >= interval,
|
||||||
// Never attempted here — due now.
|
// Never attempted here — due now.
|
||||||
@@ -563,6 +590,42 @@ pub async fn interval_from_config(
|
|||||||
default_secs
|
default_secs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `user_id`'s own interval for `agent_id`, falling back to `instance_secs` when
|
||||||
|
/// they have no override.
|
||||||
|
///
|
||||||
|
/// Fails **open**, onto the instance value: an unreadable registry must not turn
|
||||||
|
/// into an agent that stops running for someone, and the instance setting is the
|
||||||
|
/// answer that was correct before overrides existed.
|
||||||
|
pub async fn interval_for_user(
|
||||||
|
registry_pool: &SqlitePool,
|
||||||
|
agent_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
instance_secs: u64,
|
||||||
|
) -> u64 {
|
||||||
|
match crate::db::system_agent_user_settings::interval_secs(registry_pool, agent_id, user_id).await {
|
||||||
|
Ok(Some(secs)) if secs > 0 => secs as u64,
|
||||||
|
Ok(_) => instance_secs,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(agent = agent_id, user = %user_id, error = %e,
|
||||||
|
"system-agents: cannot read the per-user interval, using the instance one");
|
||||||
|
instance_secs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shortest cadence `agent_id` is on anywhere: the instance setting, or a
|
||||||
|
/// shorter override if some user holds one. For [`SystemAgent::shortest_interval_secs`].
|
||||||
|
pub async fn shortest_interval_for(
|
||||||
|
registry_pool: &SqlitePool,
|
||||||
|
agent_id: &str,
|
||||||
|
instance_secs: u64,
|
||||||
|
) -> u64 {
|
||||||
|
match crate::db::system_agent_user_settings::shortest_interval_secs(registry_pool, agent_id).await {
|
||||||
|
Ok(Some(secs)) if secs > 0 => instance_secs.min(secs as u64),
|
||||||
|
_ => instance_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The on/off switch every system agent has.
|
/// The on/off switch every system agent has.
|
||||||
pub fn enabled_property(key: &str, description: &str) -> ConfigProperty {
|
pub fn enabled_property(key: &str, description: &str) -> ConfigProperty {
|
||||||
ConfigProperty {
|
ConfigProperty {
|
||||||
@@ -661,6 +724,62 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The event-triage agent, over a real registry so the per-user interval has
|
||||||
|
/// somewhere to be read from.
|
||||||
|
async fn triage_over_registry() -> (Arc<dyn SystemAgent>, Arc<SqlitePool>) {
|
||||||
|
let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap());
|
||||||
|
crate::db::create_registry_tables(&pool).await.unwrap();
|
||||||
|
crate::db::roles::seed_admin(&pool).await.unwrap();
|
||||||
|
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('alice','alice','admin',0)")
|
||||||
|
.execute(pool.as_ref()).await.unwrap();
|
||||||
|
|
||||||
|
let bus = Arc::new(core_api::system_bus::SystemEventBus::new());
|
||||||
|
let cfg = Arc::new(GlobalConfigManager::new(Arc::clone(&pool), Arc::clone(&bus)));
|
||||||
|
let agent = registry(Default::default(), cfg, Arc::clone(&pool), bus)
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.id() == crate::event_triage::EVENT_TRIAGE_AGENT)
|
||||||
|
.unwrap();
|
||||||
|
(agent, pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn with_no_override_a_user_is_on_the_instance_interval() {
|
||||||
|
let (agent, _pool) = triage_over_registry().await;
|
||||||
|
let instance = agent.interval_secs().await;
|
||||||
|
assert_eq!(agent.interval_secs_for("alice").await, instance);
|
||||||
|
assert_eq!(agent.shortest_interval_secs().await, instance);
|
||||||
|
// Somebody with no row at all — the ordinary case for every other agent.
|
||||||
|
assert_eq!(agent.interval_secs_for("nobody").await, instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_override_moves_only_that_user() {
|
||||||
|
let (agent, pool) = triage_over_registry().await;
|
||||||
|
let instance = agent.interval_secs().await;
|
||||||
|
crate::db::system_agent_user_settings::set_interval_secs(
|
||||||
|
&pool, crate::event_triage::EVENT_TRIAGE_AGENT, "alice", 3600,
|
||||||
|
).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(agent.interval_secs_for("alice").await, 3600);
|
||||||
|
assert_eq!(agent.interval_secs_for("bob").await, instance);
|
||||||
|
// Longer than the instance value, so the scheduler's wake-up must not move.
|
||||||
|
assert_eq!(agent.shortest_interval_secs().await, instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The direction that would silently do nothing if `base_tick` asked for the
|
||||||
|
/// instance interval: an override *below* it has to pull the wake-up down.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_shorter_override_pulls_the_wake_up_down() {
|
||||||
|
let (agent, pool) = triage_over_registry().await;
|
||||||
|
let instance = agent.interval_secs().await;
|
||||||
|
crate::db::system_agent_user_settings::set_interval_secs(
|
||||||
|
&pool, crate::event_triage::EVENT_TRIAGE_AGENT, "alice", 120,
|
||||||
|
).await.unwrap();
|
||||||
|
|
||||||
|
assert!(instance > 120, "the shipped default is 15 minutes");
|
||||||
|
assert_eq!(agent.shortest_interval_secs().await, 120);
|
||||||
|
}
|
||||||
|
|
||||||
/// Constructing the agents touches no table — the pool is only a handle they
|
/// Constructing the agents touches no table — the pool is only a handle they
|
||||||
/// hold on to — so an empty database is enough.
|
/// hold on to — so an empty database is enough.
|
||||||
async fn test_agents() -> Arc<SystemAgents> {
|
async fn test_agents() -> Arc<SystemAgents> {
|
||||||
|
|||||||
@@ -116,6 +116,20 @@ Each agent's tab carries the same three settings, visible only to an admin:
|
|||||||
|
|
||||||
- **Enabled** — turns that agent on or off for the whole instance, for everyone.
|
- **Enabled** — turns that agent on or off for the whole instance, for everyone.
|
||||||
- **Interval** — how long between passes for each person. Event triage is in minutes, the lints in days. The conversation review has **Run at (hour)** instead: it runs once a day, after that hour, local time — 4am by default, so the report is waiting in the morning.
|
- **Interval** — how long between passes for each person. Event triage is in minutes, the lints in days. The conversation review has **Run at (hour)** instead: it runs once a day, after that hour, local time — 4am by default, so the report is waiting in the morning.
|
||||||
|
For event triage this is a **default**, not a rule: see below.
|
||||||
- **Security group** — which tools the agent may use during a run. It is re-checked against each user's own role: if their role does not allow that group, their run uses their role's default group instead. Nobody's background agent gets more access than their role would give them. (The conversation review ignores this in practice: it is given no tools whatsoever, so there is nothing for a group to permit.)
|
- **Security group** — which tools the agent may use during a run. It is re-checked against each user's own role: if their role does not allow that group, their run uses their role's default group instead. Nobody's background agent gets more access than their role would give them. (The conversation review ignores this in practice: it is given no tools whatsoever, so there is nothing for a group to permit.)
|
||||||
|
|
||||||
For the first three there is no per-user on/off switch: if the agent is enabled, it runs for everyone who has logged in. The conversation review is the opposite — it runs for **nobody** until an admin creates a supervision link, and that link is what turns it on for one person.
|
For the first three there is no per-user on/off switch: if the agent is enabled, it runs for everyone who has logged in. The conversation review is the opposite — it runs for **nobody** until an admin creates a supervision link, and that link is what turns it on for one person.
|
||||||
|
|
||||||
|
### Event triage: a different interval for one person
|
||||||
|
|
||||||
|
Event triage is the one agent whose right cadence depends on **who** it is running for, because it fires on things arriving from outside. Somebody on a dozen mailing lists has something waiting on nearly every pass; somebody who gets three messages a week has something waiting almost never. One number for the whole household serves one of them badly.
|
||||||
|
|
||||||
|
So that interval can be set per person: sidebar → **Users** → click the person → the **Event triage** section.
|
||||||
|
|
||||||
|
- **Leave the field empty and they follow the instance setting**, whatever it is now and whatever it becomes later. That is the normal state, and nobody has a row until an admin types one.
|
||||||
|
- **Type a number of minutes and it applies to that person only.** Longer is the usual reason — someone who was being interrupted too often gets an hour instead of fifteen minutes — but shorter works too.
|
||||||
|
- The change takes effect at the next scheduled wake-up, within a few minutes. It never affects anyone else, and clearing the field puts them straight back on the shared setting.
|
||||||
|
- It is a question of *when*, not of *whether*: an agent an admin has switched off stays off for everybody, whatever any individual interval says.
|
||||||
|
|
||||||
|
The other three agents have no per-person version of this. The lints read a store only its owner edits, and the review is pinned to an hour of the night — neither has a cadence that depends on the person.
|
||||||
|
|||||||
@@ -219,6 +219,10 @@ pub fn router() -> Router<Arc<Skald>> {
|
|||||||
// page answers "what does Marco have?" instead of N connector/plugin pages.
|
// 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}/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))
|
.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.
|
// Shared on-disk folders (blueprint §6) — admin-curated, capability-gated.
|
||||||
.route("/shared-folders", get(shared_folders::list).post(shared_folders::create))
|
.route("/shared-folders", get(shared_folders::list).post(shared_folders::create))
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
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::skald::Skald;
|
||||||
use skald_core::system_agents::{AgentScope, ManualRun, ManualRunError};
|
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.
|
/// `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
|
/// **Not admin-gated, and that is the same decision the run log makes.** A pass
|
||||||
|
|||||||
@@ -46,11 +46,14 @@ export class UsersPage extends LightElement {
|
|||||||
_connQ: { state: true },
|
_connQ: { state: true },
|
||||||
_noIcon: { state: true }, // connector names whose icon failed to load
|
_noIcon: { state: true }, // connector names whose icon failed to load
|
||||||
_plugs: { state: true }, // working copy of the user's plugin grants
|
_plugs: { state: true }, // working copy of the user's plugin grants
|
||||||
|
_triage: { state: true }, // { interval_minutes, default_interval_minutes } | null
|
||||||
|
_triageIn: { state: true }, // the input's own string ('' = follow the instance default)
|
||||||
_busy: { state: true },
|
_busy: { state: true },
|
||||||
_dSaved: { state: true }, // "saved" ticks, one per section
|
_dSaved: { state: true }, // "saved" ticks, one per section
|
||||||
_pwSaved: { state: true },
|
_pwSaved: { state: true },
|
||||||
_connSaved: { state: true },
|
_connSaved: { state: true },
|
||||||
_plugSaved: { state: true },
|
_plugSaved: { state: true },
|
||||||
|
_trgSaved: { state: true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +76,14 @@ export class UsersPage extends LightElement {
|
|||||||
this._conns = null;
|
this._conns = null;
|
||||||
this._connQ = '';
|
this._connQ = '';
|
||||||
this._plugs = null;
|
this._plugs = null;
|
||||||
|
this._triage = null;
|
||||||
|
this._triageIn = '';
|
||||||
this._busy = false;
|
this._busy = false;
|
||||||
this._dSaved = false;
|
this._dSaved = false;
|
||||||
this._pwSaved = false;
|
this._pwSaved = false;
|
||||||
this._connSaved = false;
|
this._connSaved = false;
|
||||||
this._plugSaved = false;
|
this._plugSaved = false;
|
||||||
|
this._trgSaved = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
@@ -153,6 +159,17 @@ export class UsersPage extends LightElement {
|
|||||||
this._conns = await cRes.json();
|
this._conns = await cRes.json();
|
||||||
this._plugs = await pRes.json();
|
this._plugs = await pRes.json();
|
||||||
} catch (e) { this._error = e.message; }
|
} catch (e) { this._error = e.message; }
|
||||||
|
|
||||||
|
// Admin-only, unlike the two above (which a role holding `plugin.manage` can
|
||||||
|
// also reach). A refusal hides the section rather than reddening the page:
|
||||||
|
// there is nothing wrong, this reader simply has no business with schedules.
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/event-triage`);
|
||||||
|
if (res.ok) {
|
||||||
|
this._triage = await res.json();
|
||||||
|
this._triageIn = this._triage.interval_minutes == null ? '' : String(this._triage.interval_minutes);
|
||||||
|
}
|
||||||
|
} catch { /* section stays hidden */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
_openUser(u) {
|
_openUser(u) {
|
||||||
@@ -294,6 +311,34 @@ export class UsersPage extends LightElement {
|
|||||||
finally { this._busy = false; }
|
finally { this._busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Detail: event-triage schedule ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async _saveTriage() {
|
||||||
|
const u = this._user;
|
||||||
|
const raw = this._triageIn.trim();
|
||||||
|
// Empty is a value, not a missing one: it clears the override and puts this
|
||||||
|
// person back on the instance schedule. Hence `null` rather than an omitted
|
||||||
|
// field, and hence no "use default" checkbox — the empty box says it.
|
||||||
|
let interval_minutes = null;
|
||||||
|
if (raw !== '') {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isInteger(n) || n < 1) { this._error = t('users.triage.invalid'); return; }
|
||||||
|
interval_minutes = n;
|
||||||
|
}
|
||||||
|
this._busy = true; this._error = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/event-triage`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ interval_minutes }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
this._triage = await res.json();
|
||||||
|
this._trgSaved = true;
|
||||||
|
} catch (e) { this._error = e.message; }
|
||||||
|
finally { this._busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Detail: security ──────────────────────────────────────────────────────────
|
// ── Detail: security ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async _resetPassword() {
|
async _resetPassword() {
|
||||||
@@ -428,6 +473,7 @@ export class UsersPage extends LightElement {
|
|||||||
${this._renderProfile(u)}
|
${this._renderProfile(u)}
|
||||||
${this._renderConnectors(u)}
|
${this._renderConnectors(u)}
|
||||||
${this._renderPlugins(u)}
|
${this._renderPlugins(u)}
|
||||||
|
${this._renderTriage(u)}
|
||||||
${this._renderSecurity(u)}
|
${this._renderSecurity(u)}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -634,6 +680,40 @@ export class UsersPage extends LightElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The one *schedule* on this page, and the only agent that gets one: event
|
||||||
|
// triage fires on inbound events, so how often it runs is a fact about the
|
||||||
|
// person, not about the instance. Someone on a dozen mailing lists is triaged
|
||||||
|
// on nearly every tick.
|
||||||
|
_renderTriage(u) {
|
||||||
|
if (!this._triage) return nothing; // not an admin, or the fetch failed
|
||||||
|
const def = this._triage.default_interval_minutes;
|
||||||
|
return html`
|
||||||
|
<div class="ud-section">
|
||||||
|
<h3 class="ud-section-title"><i class="bi bi-clock-history me-2"></i>${t('users.detail.triage')}</h3>
|
||||||
|
<div class="connector-card">
|
||||||
|
<div class="form-text mb-2" style="font-size:.75rem">${t('users.triage.hint')}</div>
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">${t('users.triage.interval')}</label>
|
||||||
|
<input type="number" min="1" max="1440" class="form-control form-control-sm"
|
||||||
|
placeholder=${t('users.triage.placeholder', { n: def })}
|
||||||
|
.value=${this._triageIn}
|
||||||
|
@input=${(e) => { this._triageIn = e.target.value; this._trgSaved = false; }} />
|
||||||
|
<div class="form-text">${this._triageIn.trim() === ''
|
||||||
|
? t('users.triage.using_default', { n: def })
|
||||||
|
: t('users.triage.using_override')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-flex align-items-center gap-2 pb-4">
|
||||||
|
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._saveTriage()}>
|
||||||
|
<i class="bi bi-check-lg me-1"></i>${t('users.modal.save_btn')}
|
||||||
|
</button>
|
||||||
|
${this._trgSaved ? html`<span class="ud-saved"><i class="bi bi-check2"></i>${t('users.detail.saved')}</span>` : nothing}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
_renderSecurity(u) {
|
_renderSecurity(u) {
|
||||||
return html`
|
return html`
|
||||||
<div class="ud-section">
|
<div class="ud-section">
|
||||||
|
|||||||
@@ -1112,6 +1112,7 @@ export default {
|
|||||||
'users.detail.profile': 'Profile',
|
'users.detail.profile': 'Profile',
|
||||||
'users.detail.connectors': 'Connectors',
|
'users.detail.connectors': 'Connectors',
|
||||||
'users.detail.plugins': 'Plugins',
|
'users.detail.plugins': 'Plugins',
|
||||||
|
'users.detail.triage': 'Event triage',
|
||||||
'users.detail.security': 'Security',
|
'users.detail.security': 'Security',
|
||||||
'users.detail.saved': 'Saved',
|
'users.detail.saved': 'Saved',
|
||||||
'users.detail.delete_hint': 'Deletes this user, their database and all conversation history.',
|
'users.detail.delete_hint': 'Deletes this user, their database and all conversation history.',
|
||||||
@@ -1128,6 +1129,13 @@ export default {
|
|||||||
'users.plug.empty': 'No plugins available.',
|
'users.plug.empty': 'No plugins available.',
|
||||||
'users.plug.admin_note': 'Admins can use every enabled plugin, whatever is ticked here.',
|
'users.plug.admin_note': 'Admins can use every enabled plugin, whatever is ticked here.',
|
||||||
|
|
||||||
|
'users.triage.hint': 'Event triage reads the events this person\'s connectors pushed and notifies them about the ones worth an interruption. Someone who receives a lot of mail or messages triggers it on almost every pass; give them a slower cadence here.',
|
||||||
|
'users.triage.interval': 'Check interval (minutes)',
|
||||||
|
'users.triage.placeholder': 'Default ({n})',
|
||||||
|
'users.triage.using_default': 'Empty: follows the instance setting ({n} min).',
|
||||||
|
'users.triage.using_override': 'This user only. Clear the field to follow the instance setting again.',
|
||||||
|
'users.triage.invalid': 'Enter a whole number of minutes, or leave the field empty.',
|
||||||
|
|
||||||
'users.modal.create_title': 'New user',
|
'users.modal.create_title': 'New user',
|
||||||
'users.modal.username': 'Username',
|
'users.modal.username': 'Username',
|
||||||
'users.modal.display_name': 'Display name',
|
'users.modal.display_name': 'Display name',
|
||||||
|
|||||||
@@ -1099,6 +1099,7 @@ export default {
|
|||||||
'users.detail.profile': 'Profil',
|
'users.detail.profile': 'Profil',
|
||||||
'users.detail.connectors': 'Connecteurs',
|
'users.detail.connectors': 'Connecteurs',
|
||||||
'users.detail.plugins': 'Plugins',
|
'users.detail.plugins': 'Plugins',
|
||||||
|
'users.detail.triage': "Tri des événements",
|
||||||
'users.detail.security': 'Sécurité',
|
'users.detail.security': 'Sécurité',
|
||||||
'users.detail.saved': 'Enregistré',
|
'users.detail.saved': 'Enregistré',
|
||||||
'users.detail.delete_hint': 'Supprime cet utilisateur, sa base de données et tout l\'historique des conversations.',
|
'users.detail.delete_hint': 'Supprime cet utilisateur, sa base de données et tout l\'historique des conversations.',
|
||||||
@@ -1115,6 +1116,13 @@ export default {
|
|||||||
'users.plug.empty': 'Aucun plugin disponible.',
|
'users.plug.empty': 'Aucun plugin disponible.',
|
||||||
'users.plug.admin_note': "Les administrateurs peuvent utiliser tout plugin activé, quelles que soient les cases cochées ici.",
|
'users.plug.admin_note': "Les administrateurs peuvent utiliser tout plugin activé, quelles que soient les cases cochées ici.",
|
||||||
|
|
||||||
|
'users.triage.hint': "Le tri des événements lit les événements poussés par les connecteurs de cette personne et lui signale ceux qui méritent une interruption. Quelqu'un qui reçoit beaucoup de courrier ou de messages le déclenche à presque chaque passage : donnez-lui ici une cadence plus lente.",
|
||||||
|
'users.triage.interval': "Intervalle de vérification (minutes)",
|
||||||
|
'users.triage.placeholder': "Par défaut ({n})",
|
||||||
|
'users.triage.using_default': "Vide : suit le réglage de l'instance ({n} min).",
|
||||||
|
'users.triage.using_override': "Pour cet utilisateur uniquement. Videz le champ pour revenir au réglage de l'instance.",
|
||||||
|
'users.triage.invalid': "Saisissez un nombre entier de minutes, ou laissez le champ vide.",
|
||||||
|
|
||||||
'users.modal.create_title': 'Nouvel utilisateur',
|
'users.modal.create_title': 'Nouvel utilisateur',
|
||||||
'users.modal.username': 'Nom d\'utilisateur',
|
'users.modal.username': 'Nom d\'utilisateur',
|
||||||
'users.modal.display_name': 'Nom d\'affichage',
|
'users.modal.display_name': 'Nom d\'affichage',
|
||||||
|
|||||||
@@ -1099,6 +1099,7 @@ export default {
|
|||||||
'users.detail.profile': 'Profilo',
|
'users.detail.profile': 'Profilo',
|
||||||
'users.detail.connectors': 'Connettori',
|
'users.detail.connectors': 'Connettori',
|
||||||
'users.detail.plugins': 'Plugin',
|
'users.detail.plugins': 'Plugin',
|
||||||
|
'users.detail.triage': 'Triage eventi',
|
||||||
'users.detail.security': 'Sicurezza',
|
'users.detail.security': 'Sicurezza',
|
||||||
'users.detail.saved': 'Salvato',
|
'users.detail.saved': 'Salvato',
|
||||||
'users.detail.delete_hint': 'Elimina questo utente, il suo database e tutta la cronologia delle conversazioni.',
|
'users.detail.delete_hint': 'Elimina questo utente, il suo database e tutta la cronologia delle conversazioni.',
|
||||||
@@ -1115,6 +1116,13 @@ export default {
|
|||||||
'users.plug.empty': 'Nessun plugin disponibile.',
|
'users.plug.empty': 'Nessun plugin disponibile.',
|
||||||
'users.plug.admin_note': 'Gli amministratori possono usare qualsiasi plugin abilitato, indipendentemente da ciò che è selezionato qui.',
|
'users.plug.admin_note': 'Gli amministratori possono usare qualsiasi plugin abilitato, indipendentemente da ciò che è selezionato qui.',
|
||||||
|
|
||||||
|
'users.triage.hint': 'Il triage eventi legge gli eventi arrivati dai connettori di questa persona e le segnala quelli che meritano un\'interruzione. Chi riceve molta posta o molti messaggi lo fa partire quasi a ogni passaggio: qui puoi dargli una cadenza più lenta.',
|
||||||
|
'users.triage.interval': 'Intervallo di controllo (minuti)',
|
||||||
|
'users.triage.placeholder': 'Predefinito ({n})',
|
||||||
|
'users.triage.using_default': 'Vuoto: segue l\'impostazione dell\'istanza ({n} min).',
|
||||||
|
'users.triage.using_override': 'Solo per questo utente. Svuota il campo per tornare all\'impostazione dell\'istanza.',
|
||||||
|
'users.triage.invalid': 'Inserisci un numero intero di minuti, oppure lascia il campo vuoto.',
|
||||||
|
|
||||||
'users.modal.create_title': 'Nuovo utente',
|
'users.modal.create_title': 'Nuovo utente',
|
||||||
'users.modal.username': 'Nome utente',
|
'users.modal.username': 'Nome utente',
|
||||||
'users.modal.display_name': 'Nome visualizzato',
|
'users.modal.display_name': 'Nome visualizzato',
|
||||||
|
|||||||
Reference in New Issue
Block a user