system agents: generalise the scheduler and add the two memory lints
Nightly Build / build (push) Successful in 7m14s

Memory is kept as a maintained wiki, and a wiki nobody prunes rots. This adds
the scheduled maintenance pass, and generalises the machinery TIC had grown so
that a background agent is a trait impl rather than a loop of its own.

Two lint agents, not one. The private pass runs per user over `user-memory/`
and reports to them; the shared pass runs once over `shared-memory/`, where the
interesting defect is different — a note failing the table rule, i.e. private
business written where every member can read it. It names the note and the
category without repeating the content, since restating it spreads the very
thing being flagged. Both share `agents/common/memory-lint.md`.

Both are read-only, and that is enforced twice: the prompt says report-never-
repair, and `shared-memory/*` writes are already `@fs_write require`, so an
agent that tried to fix something would raise an approval card from an
unattended pass, which is auto-denied. Read-only is the only design that works
here, not merely the safe one.

One scheduler for cadences three orders of magnitude apart. TIC runs every few
minutes, a lint weekly — the case that tempts a second loop. It stays one
because the wake-up decides nothing: `base_tick` picks only how often to look,
and whether an agent runs for a user is `is_due` against persisted state.

Due-ness moves out of the run log into a new owner table, `system_agent_state`.
The two answer different questions: the run log skips idle ticks so it stays a
history rather than a heartbeat, while scheduling needs every attempt. Reading
due-ness off the log would re-run an idle agent on every tick and never bring a
weekly one due once its last productive run aged out. Persisting it is also
what makes a long interval survive a restart — an in-memory deadline is fine at
TIC's scale, but a weekly agent on a box rebooted every few days would have it
re-armed before it ever fired.

The shared store belongs to nobody, so `AgentScope::Instance` runs that pass as
the first unlocked admin. An ownerless run would write its trace into system.db,
which the runs endpoint shows to nobody by design, and its notify() would have
no recipient; attributing it to a user keeps the whole per-user surface working
unchanged.

Settings move to where the run log is. `ConfigSet` gains `owner`, so placement
is data on the set rather than a page that knows set names; the System agents
page grows one tab per agent holding its description, its settings (admin only)
and its runs — "why did this do nothing last night?" is half a schedule
question and half a log question. The form is shared with the Config page, and
writes still go through PUT /api/config/{key}.

Fixes an authorization gap found on the way: neither /api/config handler took
the caller into account, so any authenticated session could read and write
instance-wide config. The sidebar hiding the page is presentation, not access
control. Both are now admin-gated.
This commit is contained in:
2026-07-28 21:24:16 +01:00
parent 4b1affa600
commit 434e27d7c2
34 changed files with 2194 additions and 612 deletions
+27 -1
View File
@@ -1,10 +1,36 @@
//! Shared role-capability gate for API handlers.
use skald_core::db::role_capabilities;
use skald_core::db::{role_capabilities, roles::ADMIN_ROLE_ID, users};
use skald_core::skald::Skald;
use super::ApiError;
/// Fails with 403 unless the caller is an admin.
///
/// For instance-wide settings, which are admin-by-construction rather than
/// gated on a named capability: there is no meaningful role that should be able
/// to change the interface language or a background agent's schedule for
/// everybody without also being an admin.
///
/// Needed because the sidebar hiding a page is **not** access control — the
/// endpoints behind Config were reachable by any authenticated session.
pub async fn require_admin(skald: &Skald, user_id: &str) -> Result<(), ApiError> {
if is_admin(skald, user_id).await? {
Ok(())
} else {
Err(ApiError::forbidden("this setting is admin-only"))
}
}
/// Whether the caller is an admin. For handlers that serve everyone but reveal
/// more to an admin, rather than refusing outright.
pub async fn is_admin(skald: &Skald, user_id: &str) -> Result<bool, ApiError> {
let user = users::get(skald.db(), user_id)
.await?
.ok_or_else(|| ApiError::unauthorized("unknown user"))?;
Ok(user.role_id == ADMIN_ROLE_ID)
}
/// Fails with 403 unless the caller's role holds `cap` (admin holds everything).
pub async fn require_cap(skald: &Skald, user_id: &str, cap: &str) -> Result<(), ApiError> {
let user = skald_core::db::users::get(skald.db(), user_id).await?
+56 -10
View File
@@ -1,17 +1,32 @@
//! The instance's settings.
//!
//! **Admin-only, and enforced here.** Every key on this surface is instance-wide
//! — the interface language, which model summarises history, how often a
//! background agent runs for everybody — so there is no reading of it that makes
//! sense for a member. The sidebar has always hidden the page from non-admins,
//! which is presentation, not authorization: until these handlers took the
//! caller into account at all, any authenticated session could read *and write*
//! them.
//!
//! A set carrying a [`ConfigSet::owner`] is **not** served here: it belongs to
//! the page that owns it (see [`render_sets`], reused by that page so the two
//! render identically).
use std::sync::Arc;
use axum::{
Json,
Extension, Json,
extract::{Path, State},
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use core_api::PropertyType;
use core_api::{ConfigSet, PropertyType};
use skald_core::skald::Skald;
use super::ApiError;
use super::guard::AuthUser;
use super::{ApiError, caps};
// ── Response types ─────────────────────────────────────────────────────────────
@@ -38,7 +53,7 @@ struct PropertyView {
}
#[derive(Serialize)]
struct ConfigSetView {
pub struct ConfigSetView {
name: String,
description: String,
properties: Vec<PropertyView>,
@@ -47,8 +62,31 @@ struct ConfigSetView {
// ── GET /api/config ────────────────────────────────────────────────────────────
pub async fn list_properties(
State(skald): State<Arc<Skald>>,
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Value>, ApiError> {
caps::require_admin(&skald, &auth.user_id).await?;
// Owned sets are edited on the surface that owns them, not here.
let sets: Vec<&ConfigSet> = skald
.config_properties()
.iter()
.filter(|s| s.owner.is_none())
.collect();
Ok(Json(json!({ "sets": render_sets(&skald, &sets).await? })))
}
/// Resolve every property in `sets` to its current value plus, for the dropdown
/// types, the choices the backend owns.
///
/// Shared with the System agents page so an owned set renders exactly like one
/// on the Config page — same types, same options, same defaults. The caller is
/// responsible for authorization: this function assumes it has already happened.
pub async fn render_sets(
skald: &Skald,
sets: &[&ConfigSet],
) -> Result<Vec<ConfigSetView>, ApiError> {
// Option sources for the dropdown-style property types. Each custom
// `PropertyType` that renders as a `<select>` computes its choices here and
// ships them in `options`. To add a new one: build its `Vec<SelectOption>`
@@ -75,8 +113,8 @@ pub async fn list_properties(
})
.collect::<Vec<_>>();
let mut sets = Vec::with_capacity(skald.config_properties().len());
for set in skald.config_properties() {
let mut views = Vec::with_capacity(sets.len());
for set in sets {
let mut props = Vec::with_capacity(set.properties.len());
for prop in &set.properties {
let value = skald.config().get(&prop.key).await?;
@@ -99,14 +137,14 @@ pub async fn list_properties(
options,
});
}
sets.push(ConfigSetView {
views.push(ConfigSetView {
name: set.name.clone(),
description: set.description.clone(),
properties: props,
});
}
Ok(Json(json!({ "sets": sets })))
Ok(views)
}
// ── PUT /api/config/:key ────────────────────────────────────────────────────────
@@ -121,11 +159,19 @@ pub struct KeyPath {
pub key: String,
}
/// `PUT /api/config/{key}` — write one instance-wide setting.
///
/// The single write path for **every** config property, owned sets included: the
/// System agents page edits its tabs through this endpoint rather than one of
/// its own, so the admin gate and the known-key check exist in one place.
pub async fn set_property(
State(skald): State<Arc<Skald>>,
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<KeyPath>,
Json(body): Json<SetPropertyBody>,
) -> Result<StatusCode, ApiError> {
caps::require_admin(&skald, &auth.user_id).await?;
// Only allow keys that are registered as config properties.
let known = skald.config_properties().iter()
.flat_map(|s| &s.properties)
+3 -1
View File
@@ -53,7 +53,9 @@ pub fn router() -> Router<Arc<Skald>> {
// Custom slash commands (file-based, read-only listing for autocomplete + /help)
.route("/commands", get(commands::list))
.route("/sessions", get(sessions::list_sessions).post(sessions::create))
// System agents (TIC) — the caller's own run history
// System agents (TIC, memory lints) — the caller's own run history, plus
// the agent list (settings included only for an admin).
.route("/system-agents", get(system_agents::list_agents))
.route("/system-agents/runs", get(system_agents::list_runs))
// First-run setup
.route("/setup/status", get(setup::status))
+71 -8
View File
@@ -1,12 +1,22 @@
//! System agents — the background agents the instance runs on a user's behalf
//! (blueprint §13). Today that is TIC; the surface is written for more.
//! (blueprint §13): TIC and the two memory lints.
//!
//! **Scoped to the caller, with no admin override.** A run summarises what
//! arrived in someone's inbox, so it is stored in their own encrypted database
//! and read back through `require_context`, exactly like their sessions. There
//! is deliberately no "all users" view: the admin sees their own runs and
//! nobody else's, which is the same promise the rest of the private pool makes
//! (§2/§3).
//! **This page has two audiences, and the split is the whole design.**
//!
//! The run history is *the caller's own*, with no admin override: a run
//! summarises what arrived in someone's inbox, so it is stored in their own
//! encrypted database and read back through `require_context`, exactly like
//! their sessions. There is deliberately no "all users" view — the admin sees
//! their own runs and nobody else's, the same promise the rest of the private
//! pool makes (§2/§3). That is why the page is visible to everyone.
//!
//! The *settings* are instance-wide and therefore admin-only. They live here
//! rather than on the Config page because an agent's schedule and its run log
//! answer the same question — "why did this not do anything last night?" — and
//! splitting them across two pages made the answer require both. [`list_agents`]
//! serves the config half only to an admin; a member gets the descriptions and
//! nothing else, and the write path is `PUT /api/config/{key}`, which gates
//! again on its own.
use std::sync::Arc;
@@ -21,7 +31,60 @@ use skald_core::db::system_agent_runs;
use skald_core::skald::Skald;
use super::guard::AuthUser;
use super::{ApiError, require_context};
use super::{ApiError, caps, config, require_context};
/// `GET /api/system-agents` — the agents this instance runs, in pass order.
///
/// The list *is* the set of owned config sets: every system agent has one by
/// construction (`SystemAgent::config_set`), so there is no second registry to
/// keep in step with the scheduler.
pub async fn list_agents(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Value>, ApiError> {
let admin = caps::is_admin(&skald, &auth.user_id).await?;
let owned: Vec<&core_api::ConfigSet> = skald
.config_properties()
.iter()
.filter(|s| s.owner.is_some())
.collect();
// Values and options are resolved only for an admin. A member gets no
// settings at all rather than read-only ones: there is nothing on this page
// they could do with them, and shipping them would leak the instance's
// configuration to every session for the sake of a disabled form.
let items: Vec<Value> = if admin {
// `render_sets` preserves order, so zipping is safe.
let rendered = config::render_sets(&skald, &owned).await?;
owned
.iter()
.zip(rendered)
.map(|(set, view)| {
json!({
"id": set.owner,
"name": set.name,
"description": set.description,
"config": view,
})
})
.collect()
} else {
owned
.iter()
.map(|set| {
json!({
"id": set.owner,
"name": set.name,
"description": set.description,
"config": Value::Null,
})
})
.collect()
};
Ok(Json(json!({ "items": items, "can_configure": admin })))
}
#[derive(Deserialize)]
pub struct ListRunsQuery {