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
+80
View File
@@ -46,11 +46,14 @@ export class UsersPage extends LightElement {
_connQ: { state: true },
_noIcon: { state: true }, // connector names whose icon failed to load
_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 },
_dSaved: { state: true }, // "saved" ticks, one per section
_pwSaved: { state: true },
_connSaved: { state: true },
_plugSaved: { state: true },
_trgSaved: { state: true },
};
}
@@ -73,11 +76,14 @@ export class UsersPage extends LightElement {
this._conns = null;
this._connQ = '';
this._plugs = null;
this._triage = null;
this._triageIn = '';
this._busy = false;
this._dSaved = false;
this._pwSaved = false;
this._connSaved = false;
this._plugSaved = false;
this._trgSaved = false;
}
connectedCallback() {
@@ -153,6 +159,17 @@ export class UsersPage extends LightElement {
this._conns = await cRes.json();
this._plugs = await pRes.json();
} 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) {
@@ -294,6 +311,34 @@ export class UsersPage extends LightElement {
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 ──────────────────────────────────────────────────────────
async _resetPassword() {
@@ -428,6 +473,7 @@ export class UsersPage extends LightElement {
${this._renderProfile(u)}
${this._renderConnectors(u)}
${this._renderPlugins(u)}
${this._renderTriage(u)}
${this._renderSecurity(u)}
</div>
</div>`;
@@ -634,6 +680,40 @@ export class UsersPage extends LightElement {
</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) {
return html`
<div class="ud-section">