feat: a "Run now" button for the memory lints — one pass, for whoever asked
Nightly Build / build (push) Successful in 7m33s
Nightly Build / build (push) Successful in 7m33s
The two memory lints run weekly, which is right for maintenance and wrong for the moment somebody has just reorganised their notes and wants to know what the lint makes of them. Each agent's tab now carries a button that starts one pass immediately, for the caller. It runs as the caller — their pool, their sessions, their hub — so the report lands with the person who asked. The shared lint is the interesting case: its scheduled pass runs as the admin because the shared store belongs to nobody, but a member pressing the button reads the same store and gets the report themselves, which is coherent with shared memory being readable by every member anyway. Two settings are treated differently on purpose. Due-ness is skipped, exactly as manual /compact skips the compactor's token threshold: the interval answers *when*, and a human asking is a good enough answer to that. The Enabled switch is honoured: it answers *whether*, and that one is the admin's. The conversation review gets no button (AgentScope::PerSubject): it is about somebody else and picks its own subjects, so "run it for me" has no meaning. The frontend reads that from the agent's scope, not from a list of ids. A second starter breaks an invariant the scheduler used to hold for free. system_agent_runs::start sweeps any leftover `running` row of the same agent to `failed` before inserting, which was safe only because one sequential loop was the only thing that ever started a pass; a manual run overlapping a scheduled one would have marked a healthy run as interrupted and duplicated its work. So the agent list moves out of the scheduler and onto Skald as SystemAgents, which holds the registry plus an in-flight guard both paths claim through — keyed on what the pass is *about*, so an instance-wide agent is one slot no matter who runs it, and a per-subject review is keyed on the subject rather than on the supervisor lending the runtime. has_work is answered synchronously, before anything is spawned: it leaves no run row, so without that the button would say "started" over a log that never gains a row. Everything after it is spawned — a pass is an LLM turn, and no HTTP request should be held open for one. The run row exists before the browser is answered, so the log itself is the progress surface; the page polls it quietly until the pass leaves `running`.
This commit is contained in:
@@ -38,6 +38,7 @@ use crate::provider::ProviderRegistry;
|
|||||||
use crate::run_context::RunContextManager;
|
use crate::run_context::RunContextManager;
|
||||||
use crate::secrets::SecretsStore;
|
use crate::secrets::SecretsStore;
|
||||||
use crate::session::manager::ChatSessionManager;
|
use crate::session::manager::ChatSessionManager;
|
||||||
|
use crate::system_agents::{AgentRunCtx, AgentScope, ManualRun, ManualRunError, SystemAgents};
|
||||||
use crate::tool_catalog::ToolCatalog;
|
use crate::tool_catalog::ToolCatalog;
|
||||||
use crate::tools::ToolRegistry;
|
use crate::tools::ToolRegistry;
|
||||||
use crate::transcribe::TranscribeManager;
|
use crate::transcribe::TranscribeManager;
|
||||||
@@ -303,6 +304,91 @@ impl Skald {
|
|||||||
pub fn latex_compiler(&self) -> &LatexCompiler { &self.infra.latex_compiler }
|
pub fn latex_compiler(&self) -> &LatexCompiler { &self.infra.latex_compiler }
|
||||||
pub fn location_manager(&self) -> &Arc<LocationManager> { &self.infra.location_manager }
|
pub fn location_manager(&self) -> &Arc<LocationManager> { &self.infra.location_manager }
|
||||||
pub fn remote(&self) -> &Arc<RwLock<Option<Arc<dyn RemoteAccess>>>> { &self.infra.remote }
|
pub fn remote(&self) -> &Arc<RwLock<Option<Arc<dyn RemoteAccess>>>> { &self.infra.remote }
|
||||||
|
|
||||||
|
// System agents
|
||||||
|
pub fn system_agents(&self) -> &Arc<SystemAgents> { &self.system_agents }
|
||||||
|
|
||||||
|
/// Start one system-agent pass **now**, for `user_id`, because a human asked.
|
||||||
|
///
|
||||||
|
/// The schedule answers *when* a pass runs, and the button is a person saying
|
||||||
|
/// "now" — so due-ness is skipped, exactly as manual `/compact` skips the
|
||||||
|
/// compactor's token threshold. The instance-wide **Enabled** switch is a
|
||||||
|
/// different kind of setting and is honoured: it says *whether* the agent runs
|
||||||
|
/// at all, and that is the admin's answer, not the caller's.
|
||||||
|
///
|
||||||
|
/// The pass always runs **as the caller** — their pool, their sessions, their
|
||||||
|
/// hub — so a member triggering the shared-memory lint gets their own report
|
||||||
|
/// over the shared store, and their own run row. One consequence worth naming:
|
||||||
|
/// the attempt is marked in the file the pass ran in, so a member's manual run
|
||||||
|
/// of an instance-wide agent does not move the admin's scheduled clock. The two
|
||||||
|
/// clocks were always per file; this only makes it visible.
|
||||||
|
///
|
||||||
|
/// Returns as soon as the work is **scheduled**, not when it finishes: a pass is
|
||||||
|
/// an LLM turn and no HTTP request should be held open for it. The run log is
|
||||||
|
/// the progress surface — the `running` row exists before this returns to the
|
||||||
|
/// browser. The one thing answered synchronously is
|
||||||
|
/// [`SystemAgent::has_work`], which is cheap by contract and whose `false`
|
||||||
|
/// leaves no row at all: without it the button would report "started" and the
|
||||||
|
/// log would stay empty forever.
|
||||||
|
pub async fn run_system_agent_now(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
agent_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<ManualRun, ManualRunError> {
|
||||||
|
let agent = self.system_agents.get(agent_id).ok_or(ManualRunError::UnknownAgent)?.clone();
|
||||||
|
|
||||||
|
// A per-subject pass is about somebody else and picks its own subjects;
|
||||||
|
// "run it for me" has no meaning for it.
|
||||||
|
if agent.scope() == AgentScope::PerSubject {
|
||||||
|
return Err(ManualRunError::Unsupported);
|
||||||
|
}
|
||||||
|
if !agent.is_enabled().await {
|
||||||
|
return Err(ManualRunError::Disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = self.user_context(user_id).await.ok_or(ManualRunError::Locked)?;
|
||||||
|
|
||||||
|
// Taken before `has_work` so that two quick clicks cannot both look, both
|
||||||
|
// find work, and both start.
|
||||||
|
let claim = self
|
||||||
|
.system_agents
|
||||||
|
.claim(agent.id(), &SystemAgents::target_of(agent.as_ref(), user_id))
|
||||||
|
.ok_or(ManualRunError::AlreadyRunning)?;
|
||||||
|
|
||||||
|
let run_ctx = AgentRunCtx {
|
||||||
|
user_id,
|
||||||
|
pool: &ctx.pool,
|
||||||
|
sessions: &ctx.sessions,
|
||||||
|
hub: &ctx.chat_hub,
|
||||||
|
subject: None,
|
||||||
|
run_id: None,
|
||||||
|
};
|
||||||
|
if !agent.has_work(&run_ctx).await.map_err(ManualRunError::Failed)? {
|
||||||
|
return Ok(ManualRun::NothingToDo);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = user_id.to_string();
|
||||||
|
self.rt.supervisor.spawn("system-agent-manual", async move {
|
||||||
|
// Released when the task ends, whichever way it ends.
|
||||||
|
let _claim = claim;
|
||||||
|
let run_ctx = AgentRunCtx {
|
||||||
|
user_id: &user_id,
|
||||||
|
pool: &ctx.pool,
|
||||||
|
sessions: &ctx.sessions,
|
||||||
|
hub: &ctx.chat_hub,
|
||||||
|
subject: None,
|
||||||
|
run_id: None,
|
||||||
|
};
|
||||||
|
if let Err(e) = crate::system_agents::run_and_record(agent.as_ref(), &run_ctx).await {
|
||||||
|
// The failure is already recorded on the run row, which is where
|
||||||
|
// the person who pressed the button will look for it.
|
||||||
|
tracing::warn!(agent = agent.id(), user = %user_id, error = %e,
|
||||||
|
"system-agents: manual pass failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(ManualRun::Started)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UserChannelApi ────────────────────────────────────────────────────────────
|
// ── UserChannelApi ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ pub struct Skald {
|
|||||||
/// Per-user Docker containers (blueprint §6): the execution sandbox. Docker is a
|
/// Per-user Docker containers (blueprint §6): the execution sandbox. Docker is a
|
||||||
/// hard requirement — `new()` fails if the daemon is unreachable.
|
/// hard requirement — `new()` fails if the daemon is unreachable.
|
||||||
container: ContainerManager,
|
container: ContainerManager,
|
||||||
|
/// The background agents this instance runs (blueprint §13), held here rather
|
||||||
|
/// than inside the scheduler because they now have two starters: the timer and
|
||||||
|
/// the "Run now" button. One list, one in-flight guard.
|
||||||
|
system_agents: Arc<crate::system_agents::SystemAgents>,
|
||||||
/// Per-user owner-bound runtimes (chat/hub/cron/interaction), built lazily on
|
/// Per-user owner-bound runtimes (chat/hub/cron/interaction), built lazily on
|
||||||
/// first use after a user's pool is unlocked. The global bundles above still
|
/// first use after a user's pool is unlocked. The global bundles above still
|
||||||
/// serve deferred subsystems and the not-yet-migrated call sites.
|
/// serve deferred subsystems and the not-yet-migrated call sites.
|
||||||
@@ -81,6 +85,15 @@ impl Skald {
|
|||||||
let conversation = Conversation::build(&rt, &models, &media, &tools, &integrations, &interaction, config).await?;
|
let conversation = Conversation::build(&rt, &models, &media, &tools, &integrations, &interaction, config).await?;
|
||||||
let infra = Infra::build();
|
let infra = Infra::build();
|
||||||
|
|
||||||
|
// Built here rather than inside the scheduler: the "Run now" button starts
|
||||||
|
// the same agents through the same in-flight guard (blueprint §13).
|
||||||
|
let system_agents = crate::system_agents::SystemAgents::new(
|
||||||
|
config.event_triage.clone(),
|
||||||
|
Arc::clone(&rt.config),
|
||||||
|
Arc::clone(&rt.db),
|
||||||
|
Arc::clone(&rt.system_bus),
|
||||||
|
);
|
||||||
|
|
||||||
// Resolve construction cycles, then start background tasks.
|
// Resolve construction cycles, then start background tasks.
|
||||||
wire(&tasks, &conversation, &integrations, &interaction);
|
wire(&tasks, &conversation, &integrations, &interaction);
|
||||||
spawn_background(&rt, &tasks, &conversation, &integrations, config);
|
spawn_background(&rt, &tasks, &conversation, &integrations, config);
|
||||||
@@ -99,6 +112,7 @@ impl Skald {
|
|||||||
let skald = Arc::new(Skald {
|
let skald = Arc::new(Skald {
|
||||||
rt, models, media, tools, integrations, tasks, conversation, interaction, infra,
|
rt, models, media, tools, integrations, tasks, conversation, interaction, infra,
|
||||||
container,
|
container,
|
||||||
|
system_agents,
|
||||||
user_contexts,
|
user_contexts,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,7 +127,7 @@ impl Skald {
|
|||||||
|
|
||||||
// Likewise the system-agent scheduler: it resolves a per-user runtime for
|
// Likewise the system-agent scheduler: it resolves a per-user runtime for
|
||||||
// each user it runs an agent for (blueprint §13).
|
// each user it runs an agent for (blueprint §13).
|
||||||
spawn_system_agents(&skald, config.event_triage.clone());
|
spawn_system_agents(&skald);
|
||||||
|
|
||||||
Ok(skald)
|
Ok(skald)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use std::time::Duration;
|
|||||||
use core_api::system_bus::{RecvError, SystemEvent};
|
use core_api::system_bus::{RecvError, SystemEvent};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::config::{CoreConfig, EventTriageConfig};
|
use crate::config::CoreConfig;
|
||||||
use crate::elicitation::ElicitationBridge;
|
use crate::elicitation::ElicitationBridge;
|
||||||
use crate::system_agents::{self, AgentRunCtx, AgentScope, SystemAgent};
|
use crate::system_agents::{self, AgentRunCtx, AgentScope, SystemAgent};
|
||||||
|
|
||||||
@@ -202,20 +202,17 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
|
|||||||
/// Spawned after `Skald` is fully built, like [`spawn_user_lifecycle`] and for
|
/// Spawned after `Skald` is fully built, like [`spawn_user_lifecycle`] and for
|
||||||
/// the same reason: it resolves each user's runtime through `Skald::user_context`.
|
/// the same reason: it resolves each user's runtime through `Skald::user_context`.
|
||||||
/// The back-reference is [`std::sync::Weak`].
|
/// The back-reference is [`std::sync::Weak`].
|
||||||
pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>, event_triage_config: EventTriageConfig) {
|
pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>) {
|
||||||
let weak = Arc::downgrade(skald);
|
let weak = Arc::downgrade(skald);
|
||||||
let shutdown = skald.rt.shutdown_token.clone();
|
let shutdown = skald.rt.shutdown_token.clone();
|
||||||
let mut sys_rx = skald.rt.system_bus.subscribe();
|
let mut sys_rx = skald.rt.system_bus.subscribe();
|
||||||
|
|
||||||
// Adding an agent is one line in `system_agents::registry` plus a
|
// Adding an agent is one line in `system_agents::registry` plus a
|
||||||
// `SystemAgent` impl — no loop of its own, which is the whole point: a second
|
// `SystemAgent` impl — no loop of its own, which is the whole point: a second
|
||||||
// scheduler would be a fourth global bus in disguise.
|
// scheduler would be a fourth global bus in disguise. The list is the
|
||||||
let agents = system_agents::registry(
|
// instance's (`Skald::system_agents`), not this loop's: the "Run now" button
|
||||||
event_triage_config,
|
// starts the very same agents, and both go through one in-flight guard.
|
||||||
Arc::clone(&skald.rt.config),
|
let agents: Vec<Arc<dyn SystemAgent>> = skald.system_agents.all().to_vec();
|
||||||
Arc::clone(&skald.rt.db),
|
|
||||||
Arc::clone(&skald.rt.system_bus),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Interval keys, so a change in the UI cuts the current wait short for
|
// Interval keys, so a change in the UI cuts the current wait short for
|
||||||
// whichever agent it belongs to.
|
// whichever agent it belongs to.
|
||||||
@@ -422,6 +419,14 @@ async fn subject_pass(skald: &Arc<super::Skald>, agent: &dyn SystemAgent) {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keyed on the **subject**, not the supervisor who lends the runtime: the
|
||||||
|
// pass is about them, and two supervisors must not review one person twice.
|
||||||
|
let Some(_claim) = skald.system_agents.claim(agent.id(), &subject_id) else {
|
||||||
|
info!(agent = agent.id(), user = %subject_id,
|
||||||
|
"system-agents: skipped — a review of this person is already in progress");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let run_ctx = AgentRunCtx {
|
let run_ctx = AgentRunCtx {
|
||||||
user_id: &host,
|
user_id: &host,
|
||||||
pool: &ctx.pool,
|
pool: &ctx.pool,
|
||||||
@@ -485,6 +490,18 @@ async fn run_one(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Held for the whole pass. The scheduler alone never needed it — it is one
|
||||||
|
// sequential loop — but the "Run now" button starts the same agents, and two
|
||||||
|
// live passes would have the second one's `start` mark the first's row as
|
||||||
|
// interrupted. Losing the race here simply means the work is already being
|
||||||
|
// done.
|
||||||
|
let target = system_agents::SystemAgents::target_of(agent, user_id);
|
||||||
|
let Some(_claim) = skald.system_agents.claim(agent.id(), &target) else {
|
||||||
|
info!(agent = agent.id(), user = %user_id,
|
||||||
|
"system-agents: skipped — a run of this agent is already in progress");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let run_ctx = AgentRunCtx {
|
let run_ctx = AgentRunCtx {
|
||||||
user_id,
|
user_id,
|
||||||
pool: &ctx.pool,
|
pool: &ctx.pool,
|
||||||
|
|||||||
@@ -26,15 +26,19 @@
|
|||||||
//! run log stops being a history and becomes a heartbeat.
|
//! run log stops being a history and becomes a heartbeat.
|
||||||
//! 3. **Open the run row, then work.** The `start`/`finish` split means a crash
|
//! 3. **Open the run row, then work.** The `start`/`finish` split means a crash
|
||||||
//! mid-pass leaves a visible `running` row, swept to `failed` by the next
|
//! mid-pass leaves a visible `running` row, swept to `failed` by the next
|
||||||
//! `start` for that agent — safe only because the scheduler is sequential and
|
//! `start` for that agent — safe only because no two passes of one agent over
|
||||||
//! single-instance.
|
//! one target are ever live at once. That used to be a property of the
|
||||||
|
//! scheduler being a single sequential loop; since the **Run now** button it is
|
||||||
|
//! enforced explicitly, by [`SystemAgents::claim`], which every starter goes
|
||||||
|
//! through.
|
||||||
|
|
||||||
pub mod conversation_review;
|
pub mod conversation_review;
|
||||||
pub mod memory_lint;
|
pub mod memory_lint;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::fmt;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -211,6 +215,140 @@ pub fn config_sets() -> Vec<ConfigSet> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The instance's agents, built once and shared by everything that can start a
|
||||||
|
/// pass.
|
||||||
|
///
|
||||||
|
/// There are two such things now — the scheduler and the **Run now** button — and
|
||||||
|
/// that is the whole reason this type exists. As long as the scheduler was the
|
||||||
|
/// only starter, "sequential and single-instance" was a property of one loop and
|
||||||
|
/// needed no enforcement; a manual trigger breaks it, and the breakage is not
|
||||||
|
/// cosmetic: [`system_agent_runs::start`] sweeps any leftover `running` row of the
|
||||||
|
/// same agent to `failed` before inserting, so a second pass beginning while the
|
||||||
|
/// first is alive would mark a perfectly healthy run as *interrupted* and then
|
||||||
|
/// duplicate its work.
|
||||||
|
///
|
||||||
|
/// So the invariant moves out of the loop and into [`claim`](Self::claim), which
|
||||||
|
/// both paths go through. It is a plain [`std::sync::Mutex`]: nothing is awaited
|
||||||
|
/// while it is held, and the guard has to be released from [`Drop`], where an
|
||||||
|
/// async lock could not be.
|
||||||
|
pub struct SystemAgents {
|
||||||
|
agents: Vec<Arc<dyn SystemAgent>>,
|
||||||
|
/// `(agent_id, target)` of every pass currently in flight.
|
||||||
|
active: Mutex<HashSet<(&'static str, String)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target of an [`AgentScope::Instance`] pass. Not a user id: the shared
|
||||||
|
/// store belongs to nobody, so two people asking for it at once must still be one
|
||||||
|
/// pass, not one each.
|
||||||
|
const INSTANCE_TARGET: &str = "@instance";
|
||||||
|
|
||||||
|
impl SystemAgents {
|
||||||
|
pub fn new(
|
||||||
|
event_triage_config: crate::config::EventTriageConfig,
|
||||||
|
config_store: Arc<GlobalConfigManager>,
|
||||||
|
registry_pool: Arc<SqlitePool>,
|
||||||
|
system_bus: Arc<core_api::system_bus::SystemEventBus>,
|
||||||
|
) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
agents: registry(event_triage_config, config_store, registry_pool, system_bus),
|
||||||
|
active: Mutex::new(HashSet::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every agent, in pass order.
|
||||||
|
pub fn all(&self) -> &[Arc<dyn SystemAgent>] { &self.agents }
|
||||||
|
|
||||||
|
pub fn get(&self, agent_id: &str) -> Option<&Arc<dyn SystemAgent>> {
|
||||||
|
self.agents.iter().find(|a| a.id() == agent_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a pass of `agent` acting as `user_id` is *about* — the key passes are
|
||||||
|
/// serialised on. Per-user work is per user; instance work is one thing no
|
||||||
|
/// matter who runs it.
|
||||||
|
pub fn target_of(agent: &dyn SystemAgent, user_id: &str) -> String {
|
||||||
|
match agent.scope() {
|
||||||
|
AgentScope::Instance => INSTANCE_TARGET.to_string(),
|
||||||
|
_ => user_id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim the right to run `agent` over `target`. `None` means a pass is
|
||||||
|
/// already in flight and this one must not start — held until the returned
|
||||||
|
/// [`RunClaim`] is dropped, including on panic or early return.
|
||||||
|
pub fn claim(self: &Arc<Self>, agent_id: &'static str, target: &str) -> Option<RunClaim> {
|
||||||
|
let key = (agent_id, target.to_string());
|
||||||
|
let mut active = self.active.lock().unwrap();
|
||||||
|
if !active.insert(key.clone()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(RunClaim { owner: Arc::clone(self), key })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live claim on one `(agent, target)` pair. Releasing it is [`Drop`]'s job so
|
||||||
|
/// that no early return can leak the slot and wedge an agent for the rest of the
|
||||||
|
/// process's life.
|
||||||
|
pub struct RunClaim {
|
||||||
|
owner: Arc<SystemAgents>,
|
||||||
|
key: (&'static str, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RunClaim {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut active) = self.owner.active.lock() {
|
||||||
|
active.remove(&self.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a manual trigger did.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ManualRun {
|
||||||
|
/// The pass is running in the background; the run log is where it reports.
|
||||||
|
Started,
|
||||||
|
/// [`SystemAgent::has_work`] said there was nothing to look at, so no run was
|
||||||
|
/// opened — the same silence a scheduled idle pass leaves behind. Answered
|
||||||
|
/// before spawning anything, so the button can say so straight away instead
|
||||||
|
/// of leaving the person watching a log that will never gain a row.
|
||||||
|
NothingToDo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a manual trigger could not start. Each variant is a different thing to
|
||||||
|
/// tell the person who pressed the button, which is why this is not one string.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ManualRunError {
|
||||||
|
UnknownAgent,
|
||||||
|
/// [`AgentScope::PerSubject`]: the pass is about somebody else, so "run it for
|
||||||
|
/// me" has no meaning. Supervisors triggering a review of one subject would be
|
||||||
|
/// a different button, with a subject to pick.
|
||||||
|
Unsupported,
|
||||||
|
/// Switched off instance-wide. Deliberately **not** overridden by a manual
|
||||||
|
/// trigger, unlike due-ness: the interval says *when*, and a human asking is a
|
||||||
|
/// good enough answer to that — the switch says *whether*, and only the admin
|
||||||
|
/// who set it gets to answer that one.
|
||||||
|
Disabled,
|
||||||
|
AlreadyRunning,
|
||||||
|
/// The caller's database is locked (§9), so there is nothing to read and
|
||||||
|
/// nowhere to record the run.
|
||||||
|
Locked,
|
||||||
|
/// `has_work` itself failed — the pass never started.
|
||||||
|
Failed(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ManualRunError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::UnknownAgent => write!(f, "no such system agent"),
|
||||||
|
Self::Unsupported => write!(f, "this agent runs about another person, not about you, \
|
||||||
|
and cannot be started by hand"),
|
||||||
|
Self::Disabled => write!(f, "this agent is disabled for the whole instance"),
|
||||||
|
Self::AlreadyRunning => write!(f, "a run of this agent is already in progress"),
|
||||||
|
Self::Locked => write!(f, "session expired — please log in again"),
|
||||||
|
Self::Failed(e) => write!(f, "{e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 configured interval.
|
||||||
///
|
///
|
||||||
@@ -523,6 +661,51 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Constructing the agents touches no table — the pool is only a handle they
|
||||||
|
/// hold on to — so an empty database is enough.
|
||||||
|
async fn test_agents() -> Arc<SystemAgents> {
|
||||||
|
let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap());
|
||||||
|
let bus = Arc::new(core_api::system_bus::SystemEventBus::new());
|
||||||
|
let cfg = Arc::new(GlobalConfigManager::new(Arc::clone(&pool), Arc::clone(&bus)));
|
||||||
|
SystemAgents::new(Default::default(), cfg, pool, bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_claim_is_exclusive_per_target_and_ends_with_its_guard() {
|
||||||
|
let agents = test_agents().await;
|
||||||
|
|
||||||
|
let alice = agents.claim(memory_lint::PRIVATE_AGENT, "alice").expect("nothing in flight");
|
||||||
|
// The scheduler waking up mid-manual-run, or a second click.
|
||||||
|
assert!(agents.claim(memory_lint::PRIVATE_AGENT, "alice").is_none());
|
||||||
|
// Somebody else's pass of the same agent is unrelated work.
|
||||||
|
assert!(agents.claim(memory_lint::PRIVATE_AGENT, "bob").is_some());
|
||||||
|
// As is the same person's pass of a different agent.
|
||||||
|
assert!(agents.claim(memory_lint::SHARED_AGENT, "alice").is_some());
|
||||||
|
|
||||||
|
drop(alice);
|
||||||
|
assert!(agents.claim(memory_lint::PRIVATE_AGENT, "alice").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_instance_agent_is_one_slot_whoever_runs_it() {
|
||||||
|
let agents = test_agents().await;
|
||||||
|
|
||||||
|
// Two members pressing "Run now" on the shared store must be one pass, not
|
||||||
|
// one each — the store they read is the same one.
|
||||||
|
let shared = agents.get(memory_lint::SHARED_AGENT).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
SystemAgents::target_of(shared.as_ref(), "alice"),
|
||||||
|
SystemAgents::target_of(shared.as_ref(), "bob"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A per-user agent is the opposite: two people, two independent passes.
|
||||||
|
let private = agents.get(memory_lint::PRIVATE_AGENT).unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
SystemAgents::target_of(private.as_ref(), "alice"),
|
||||||
|
SystemAgents::target_of(private.as_ref(), "bob"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_agent_declares_its_interval_key_among_its_properties() {
|
fn every_agent_declares_its_interval_key_among_its_properties() {
|
||||||
// The scheduler watches `interval_key()` for live changes; a key that is
|
// The scheduler watches `interval_key()` for live changes; a key that is
|
||||||
|
|||||||
+14
-1
@@ -44,7 +44,7 @@ There are two of them because the two stores are not the same job.
|
|||||||
|
|
||||||
**Shared memory lint** runs once over the group's shared store, and looks for one extra thing that only exists there: **a note that fails the table rule** — one person's private business written somewhere every member can read. The rule is that something belongs in shared memory only if you would say it out loud with every member in the room; health, school results, money, worries and one member's opinion of another do not. When it finds one it says *which note* and *what kind of problem*, without repeating the sensitive content — restating it in a notification would spread it further, which is exactly the harm being flagged.
|
**Shared memory lint** runs once over the group's shared store, and looks for one extra thing that only exists there: **a note that fails the table rule** — one person's private business written somewhere every member can read. The rule is that something belongs in shared memory only if you would say it out loud with every member in the room; health, school results, money, worries and one member's opinion of another do not. When it finds one it says *which note* and *what kind of problem*, without repeating the sensitive content — restating it in a notification would spread it further, which is exactly the harm being flagged.
|
||||||
|
|
||||||
The shared store belongs to nobody in particular, so that pass runs **as the admin** and its report goes to them. That is about who can act on it, not about privacy: everything in shared memory is already readable by every member.
|
The shared store belongs to nobody in particular, so the *scheduled* pass runs **as the admin** and its report goes to them. That is about who can act on it, not about privacy: everything in shared memory is already readable by every member — which is also why any member can press **Run now** on it and get the report themselves.
|
||||||
|
|
||||||
## Conversation review
|
## Conversation review
|
||||||
|
|
||||||
@@ -93,6 +93,19 @@ Clicking a row opens the conversation the run happened in, for anyone who wants
|
|||||||
|
|
||||||
A run appears **only when there was something to look at**. Long gaps mean quiet connectors or an untouched memory store, not a broken agent.
|
A run appears **only when there was something to look at**. Long gaps mean quiet connectors or an untouched memory store, not a broken agent.
|
||||||
|
|
||||||
|
### Running one now
|
||||||
|
|
||||||
|
Each agent's tab has a **Run now** button, next to its description. It starts one pass immediately, for **you**, without waiting for the schedule — useful after tidying up a lot of notes, or when someone wants to see what an agent actually does instead of reading about it.
|
||||||
|
|
||||||
|
- It runs **as you**, over your own things, and reports to you. The shared memory lint is the interesting case: pressed by a member it reads the same shared store the admin's nightly pass reads, and the report simply goes to the member who asked instead of the admin.
|
||||||
|
- **"Nothing to look at right now"** is a normal answer and arrives straight away — an empty memory store or an empty event queue starts no run at all, exactly like a scheduled pass that finds nothing.
|
||||||
|
- The pass then runs in the background: the row appears in the log below as *running*, and the notification arrives when it is done. Leaving the page does not stop it.
|
||||||
|
- Pressing it again while it is still going does nothing — one pass of one agent at a time, so a manual run and the nightly one can never collide.
|
||||||
|
- Running it by hand **counts as that person's pass**: the next scheduled one is then a full interval away, rather than arriving an hour later.
|
||||||
|
- An agent an admin has switched **off** cannot be started this way. The schedule is a question of *when*, which the button answers; enabled is a question of *whether*, which stays the admin's.
|
||||||
|
|
||||||
|
The conversation review has no button: it is about somebody else and picks its own subjects, so "run it for me" would not mean anything.
|
||||||
|
|
||||||
**The run history is personal.** Each run is written into that user's own encrypted database, so every user — the admin included — sees their own runs and nobody else's. There is no instance-wide view.
|
**The run history is personal.** Each run is written into that user's own encrypted database, so every user — the admin included — sees their own runs and nobody else's. There is no instance-wide view.
|
||||||
|
|
||||||
## What the admin can change
|
## What the admin can change
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ pub fn router() -> Router<Arc<Skald>> {
|
|||||||
// the agent list (settings included only for an admin).
|
// the agent list (settings included only for an admin).
|
||||||
.route("/system-agents", get(system_agents::list_agents))
|
.route("/system-agents", get(system_agents::list_agents))
|
||||||
.route("/system-agents/runs", get(system_agents::list_runs))
|
.route("/system-agents/runs", get(system_agents::list_runs))
|
||||||
|
// "Run now": one pass for the caller, off-schedule.
|
||||||
|
.route("/system-agents/{agent_id}/run", post(system_agents::run_now))
|
||||||
// First-run setup
|
// First-run setup
|
||||||
.route("/setup/status", get(setup::status))
|
.route("/setup/status", get(setup::status))
|
||||||
.route("/setup/profiles", get(setup::profiles))
|
.route("/setup/profiles", get(setup::profiles))
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension, Json,
|
Extension, Json,
|
||||||
extract::{Query, State},
|
extract::{Path, Query, State},
|
||||||
};
|
};
|
||||||
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;
|
||||||
use skald_core::skald::Skald;
|
use skald_core::skald::Skald;
|
||||||
|
use skald_core::system_agents::{AgentScope, ManualRun, ManualRunError};
|
||||||
|
|
||||||
use super::guard::AuthUser;
|
use super::guard::AuthUser;
|
||||||
use super::{ApiError, caps, config, require_context};
|
use super::{ApiError, caps, config, require_context};
|
||||||
@@ -50,6 +51,16 @@ pub async fn list_agents(
|
|||||||
.filter(|s| s.owner.is_some())
|
.filter(|s| s.owner.is_some())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Whether this agent's tab gets a "Run now" button. Read from the agent's own
|
||||||
|
// scope rather than a list of ids here, so a future agent is classified by
|
||||||
|
// what it is: a pass about somebody else has no "for me" to run.
|
||||||
|
let can_run_now = |set: &core_api::ConfigSet| {
|
||||||
|
set.owner
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| skald.system_agents().get(id))
|
||||||
|
.is_some_and(|a| a.scope() != AgentScope::PerSubject)
|
||||||
|
};
|
||||||
|
|
||||||
// Values and options are resolved only for an admin. A member gets no
|
// 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
|
// 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
|
// they could do with them, and shipping them would leak the instance's
|
||||||
@@ -66,6 +77,7 @@ pub async fn list_agents(
|
|||||||
"name": set.name,
|
"name": set.name,
|
||||||
"description": set.description,
|
"description": set.description,
|
||||||
"config": view,
|
"config": view,
|
||||||
|
"can_run_now": can_run_now(set),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -78,6 +90,7 @@ pub async fn list_agents(
|
|||||||
"name": set.name,
|
"name": set.name,
|
||||||
"description": set.description,
|
"description": set.description,
|
||||||
"config": Value::Null,
|
"config": Value::Null,
|
||||||
|
"can_run_now": can_run_now(set),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -140,3 +153,34 @@ pub async fn list_runs(
|
|||||||
"per_page": per_page,
|
"per_page": per_page,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `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
|
||||||
|
/// runs in the caller's own runtime over their own data and reports to them
|
||||||
|
/// alone, so there is nothing here an admin should have to approve — the person
|
||||||
|
/// who would read the report is the person asking for it. What stays the admin's
|
||||||
|
/// is the *schedule* and the on/off switch, which this endpoint does not touch:
|
||||||
|
/// a disabled agent answers `409` rather than running once for whoever asked.
|
||||||
|
///
|
||||||
|
/// Returns as soon as the pass is scheduled; `nothing_to_do` is the honest answer
|
||||||
|
/// for a store with nothing in it, and leaves no run row behind — exactly what an
|
||||||
|
/// idle scheduled pass does.
|
||||||
|
pub async fn run_now(
|
||||||
|
State(skald): State<Arc<Skald>>,
|
||||||
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
Path(agent_id): Path<String>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
match skald.run_system_agent_now(&agent_id, &auth.user_id).await {
|
||||||
|
Ok(ManualRun::Started) => Ok(Json(json!({ "status": "started" }))),
|
||||||
|
Ok(ManualRun::NothingToDo) => Ok(Json(json!({ "status": "nothing_to_do" }))),
|
||||||
|
Err(e) => Err(match e {
|
||||||
|
ManualRunError::UnknownAgent => ApiError::not_found(e.to_string()),
|
||||||
|
ManualRunError::Unsupported => ApiError::bad_request(e.to_string()),
|
||||||
|
ManualRunError::Disabled => ApiError::conflict(e.to_string()),
|
||||||
|
ManualRunError::AlreadyRunning => ApiError::conflict(e.to_string()),
|
||||||
|
ManualRunError::Locked => ApiError::unauthorized(e.to_string()),
|
||||||
|
ManualRunError::Failed(err) => ApiError::from(err),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ const PER_PAGE = 20;
|
|||||||
/** The overview tab: every agent's runs, interleaved. */
|
/** The overview tab: every agent's runs, interleaved. */
|
||||||
const ALL_TAB = '__all__';
|
const ALL_TAB = '__all__';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a manually started pass is followed: the run row appears immediately (the
|
||||||
|
* server opens it before the work), so the log itself is the progress bar and
|
||||||
|
* nothing else has to be invented. Refreshing quietly — without the table's
|
||||||
|
* loading state — is what keeps that from flickering every few seconds.
|
||||||
|
*/
|
||||||
|
const POLL_MS = 4000;
|
||||||
|
const POLL_MAX_MIN = 15;
|
||||||
|
|
||||||
function formatDate(iso) {
|
function formatDate(iso) {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
@@ -59,6 +68,8 @@ export class SystemAgentsPage extends LightElement {
|
|||||||
_page: { state: true },
|
_page: { state: true },
|
||||||
_loading: { state: true },
|
_loading: { state: true },
|
||||||
_error: { state: true },
|
_error: { state: true },
|
||||||
|
_running: { state: true },
|
||||||
|
_runMsg: { state: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -72,6 +83,11 @@ export class SystemAgentsPage extends LightElement {
|
|||||||
this._page = 1;
|
this._page = 1;
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
|
/** The agent whose manual run we are waiting on, if any. */
|
||||||
|
this._running = null;
|
||||||
|
/** `{ agent, text, error }` — the answer to the last press. */
|
||||||
|
this._runMsg = null;
|
||||||
|
this._poll = null;
|
||||||
this._form = new ConfigFormController(() => this.requestUpdate());
|
this._form = new ConfigFormController(() => this.requestUpdate());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,11 +99,15 @@ export class SystemAgentsPage extends LightElement {
|
|||||||
this._open = e.detail.page === PAGE_ID;
|
this._open = e.detail.page === PAGE_ID;
|
||||||
this.style.display = this._open ? 'flex' : 'none';
|
this.style.display = this._open ? 'flex' : 'none';
|
||||||
if (this._open) this._loadAll();
|
if (this._open) this._loadAll();
|
||||||
|
// Navigating away stops the polling; the pass keeps running server-side
|
||||||
|
// and its row is waiting on the next visit.
|
||||||
|
else this._stopPolling();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||||
|
this._stopPolling();
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +133,9 @@ export class SystemAgentsPage extends LightElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _fetch(page) {
|
/** `quiet` skips the loading state, so a poll does not blank the table. */
|
||||||
this._loading = true;
|
async _fetch(page, quiet = false) {
|
||||||
|
if (!quiet) this._loading = true;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ page, per_page: PER_PAGE });
|
const params = new URLSearchParams({ page, per_page: PER_PAGE });
|
||||||
@@ -134,11 +155,59 @@ export class SystemAgentsPage extends LightElement {
|
|||||||
|
|
||||||
_selectTab(id) {
|
_selectTab(id) {
|
||||||
if (this._tab === id) return;
|
if (this._tab === id) return;
|
||||||
this._tab = id;
|
this._tab = id;
|
||||||
this._page = 1;
|
this._page = 1;
|
||||||
|
this._runMsg = null;
|
||||||
this._fetch(1);
|
this._fetch(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start one pass now, for me.
|
||||||
|
*
|
||||||
|
* The button asks and stops there: the server answers as soon as the pass is
|
||||||
|
* *scheduled*, because a pass is an LLM turn and nothing good comes of holding
|
||||||
|
* a request open for it. "Nothing to do" is a real answer and arrives straight
|
||||||
|
* away — it leaves no run row, so without it the table would simply never
|
||||||
|
* change and the press would look lost.
|
||||||
|
*/
|
||||||
|
async _runNow(agent) {
|
||||||
|
if (this._running) return;
|
||||||
|
this._running = agent.id;
|
||||||
|
this._runMsg = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/system-agents/${encodeURIComponent(agent.id)}/run`,
|
||||||
|
{ method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'nothing_to_do') {
|
||||||
|
this._runMsg = { agent: agent.id, text: t('system_agents.run.nothing') };
|
||||||
|
} else {
|
||||||
|
this._runMsg = { agent: agent.id, text: t('system_agents.run.started') };
|
||||||
|
await this._fetch(1, true);
|
||||||
|
this._startPolling();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this._runMsg = { agent: agent.id, text: e.message, error: true };
|
||||||
|
} finally {
|
||||||
|
this._running = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh the log until the pass leaves `running`, then stop. */
|
||||||
|
_startPolling() {
|
||||||
|
this._stopPolling();
|
||||||
|
const until = Date.now() + POLL_MAX_MIN * 60_000;
|
||||||
|
this._poll = setInterval(async () => {
|
||||||
|
await this._fetch(this._page, true);
|
||||||
|
const live = this._items.some(r => r.status === 'running');
|
||||||
|
if (!live || Date.now() > until) this._stopPolling();
|
||||||
|
}, POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopPolling() {
|
||||||
|
if (this._poll) { clearInterval(this._poll); this._poll = null; }
|
||||||
|
}
|
||||||
|
|
||||||
_openSession(id) {
|
_openSession(id) {
|
||||||
if (id != null) window.location.hash = `session/${id}`;
|
if (id != null) window.location.hash = `session/${id}`;
|
||||||
}
|
}
|
||||||
@@ -185,15 +254,43 @@ export class SystemAgentsPage extends LightElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The selected agent's description, plus its settings when the caller is an admin. */
|
/**
|
||||||
|
* The selected agent's description and its **Run now** button, plus its
|
||||||
|
* settings when the caller is an admin.
|
||||||
|
*
|
||||||
|
* The button sits with the description rather than in the page header because
|
||||||
|
* it acts on *this* agent, not on the page: the header's Refresh reloads
|
||||||
|
* whatever is on screen, and a "Run" next to it would read as running all of
|
||||||
|
* them. Agents that work on somebody else (the conversation review) have no
|
||||||
|
* "for me" to run and say so with `can_run_now: false`.
|
||||||
|
*/
|
||||||
_renderAgentPanel() {
|
_renderAgentPanel() {
|
||||||
const agent = this._currentAgent;
|
const agent = this._currentAgent;
|
||||||
if (!agent) return nothing;
|
if (!agent) return nothing;
|
||||||
const label = this._agentLabel(agent);
|
const label = this._agentLabel(agent);
|
||||||
|
const busy = this._running === agent.id;
|
||||||
|
const msg = this._runMsg?.agent === agent.id ? this._runMsg : null;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="sa-agent-panel">
|
<div class="sa-agent-panel">
|
||||||
<p class="sa-agent-desc">${label.description}</p>
|
<p class="sa-agent-desc">${label.description}</p>
|
||||||
|
${agent.can_run_now ? html`
|
||||||
|
<div class="sa-agent-actions">
|
||||||
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
?disabled=${busy}
|
||||||
|
@click=${() => this._runNow(agent)}>
|
||||||
|
${busy
|
||||||
|
? html`<span class="spinner-border spinner-border-sm" role="status"></span>`
|
||||||
|
: html`<i class="bi bi-play-fill"></i>`}
|
||||||
|
${t('system_agents.run.now')}
|
||||||
|
</button>
|
||||||
|
<small class="sa-run-hint">${t('system_agents.run.hint')}</small>
|
||||||
|
${msg ? html`
|
||||||
|
<span class="sa-run-msg ${msg.error ? 'sa-run-msg--error' : ''}">
|
||||||
|
<i class="bi ${msg.error ? 'bi-exclamation-circle' : 'bi-info-circle'}"></i>
|
||||||
|
${msg.text}
|
||||||
|
</span>` : nothing}
|
||||||
|
</div>` : nothing}
|
||||||
${this._canCfg && agent.config ? html`
|
${this._canCfg && agent.config ? html`
|
||||||
<div class="config-set sa-agent-config">
|
<div class="config-set sa-agent-config">
|
||||||
<div class="config-set-header">
|
<div class="config-set-header">
|
||||||
|
|||||||
@@ -61,6 +61,31 @@
|
|||||||
}
|
}
|
||||||
.sa-agent-config { margin-bottom: 0; }
|
.sa-agent-config { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Run now: the button, its one-line explanation, and the answer to the press —
|
||||||
|
on one row, wrapping on a narrow viewport rather than pushing the table down. */
|
||||||
|
.sa-agent-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.sa-run-hint {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.sa-run-msg {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
}
|
||||||
|
.sa-run-msg--error { color: var(--bs-danger); }
|
||||||
|
|
||||||
/* ── Run table ─────────────────────────────────────────────────────────────── */
|
/* ── Run table ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.sa-table-wrap {
|
.sa-table-wrap {
|
||||||
|
|||||||
@@ -977,6 +977,11 @@ export default {
|
|||||||
'system_agents.tab.all': 'All',
|
'system_agents.tab.all': 'All',
|
||||||
'system_agents.settings': 'Settings',
|
'system_agents.settings': 'Settings',
|
||||||
|
|
||||||
|
'system_agents.run.now': 'Run now',
|
||||||
|
'system_agents.run.hint': 'Runs one pass for you, straight away, without waiting for the schedule.',
|
||||||
|
'system_agents.run.started': 'Started — it appears in the log below and reports when it is done.',
|
||||||
|
'system_agents.run.nothing': 'Nothing to look at right now, so no run was started.',
|
||||||
|
|
||||||
'system_agents.agent.event-triage.name': 'Event triage',
|
'system_agents.agent.event-triage.name': 'Event triage',
|
||||||
'system_agents.agent.event-triage.desc': 'Reads the events your connectors receive — new mail, calendar changes, incoming messages — decides which of them are worth your attention, and notifies you about those. It runs for one person at a time and reads only that person\'s events.',
|
'system_agents.agent.event-triage.desc': 'Reads the events your connectors receive — new mail, calendar changes, incoming messages — decides which of them are worth your attention, and notifies you about those. It runs for one person at a time and reads only that person\'s events.',
|
||||||
'system_agents.agent.memory-lint-private.name': 'Private memory lint',
|
'system_agents.agent.memory-lint-private.name': 'Private memory lint',
|
||||||
|
|||||||
@@ -967,6 +967,11 @@ export default {
|
|||||||
'system_agents.tab.all': 'Tous',
|
'system_agents.tab.all': 'Tous',
|
||||||
'system_agents.settings': 'Paramètres',
|
'system_agents.settings': 'Paramètres',
|
||||||
|
|
||||||
|
'system_agents.run.now': 'Exécuter maintenant',
|
||||||
|
'system_agents.run.hint': 'Lance immédiatement une passe pour vous, sans attendre la planification.',
|
||||||
|
'system_agents.run.started': 'Lancé — l\'exécution apparaît dans le journal ci-dessous et vous prévient une fois terminée.',
|
||||||
|
'system_agents.run.nothing': 'Rien à examiner pour le moment, aucune exécution n\'a donc été lancée.',
|
||||||
|
|
||||||
'system_agents.agent.event-triage.name': 'Tri des événements',
|
'system_agents.agent.event-triage.name': 'Tri des événements',
|
||||||
'system_agents.agent.event-triage.desc': 'Lit les événements reçus par vos connecteurs — nouveaux e-mails, changements d\'agenda, messages entrants — décide lesquels méritent votre attention et ne vous signale que ceux-là. Il s\'exécute pour une personne à la fois et ne lit que les événements de cette personne.',
|
'system_agents.agent.event-triage.desc': 'Lit les événements reçus par vos connecteurs — nouveaux e-mails, changements d\'agenda, messages entrants — décide lesquels méritent votre attention et ne vous signale que ceux-là. Il s\'exécute pour une personne à la fois et ne lit que les événements de cette personne.',
|
||||||
'system_agents.agent.memory-lint-private.name': 'Entretien de la mémoire privée',
|
'system_agents.agent.memory-lint-private.name': 'Entretien de la mémoire privée',
|
||||||
|
|||||||
@@ -967,6 +967,11 @@ export default {
|
|||||||
'system_agents.tab.all': 'Tutti',
|
'system_agents.tab.all': 'Tutti',
|
||||||
'system_agents.settings': 'Impostazioni',
|
'system_agents.settings': 'Impostazioni',
|
||||||
|
|
||||||
|
'system_agents.run.now': 'Esegui ora',
|
||||||
|
'system_agents.run.hint': 'Esegue subito una passata per te, senza aspettare la pianificazione.',
|
||||||
|
'system_agents.run.started': 'Avviato — compare nel registro qui sotto e ti avvisa quando ha finito.',
|
||||||
|
'system_agents.run.nothing': 'Al momento non c\'è nulla da esaminare, quindi non è stata avviata nessuna esecuzione.',
|
||||||
|
|
||||||
'system_agents.agent.event-triage.name': 'Triage eventi',
|
'system_agents.agent.event-triage.name': 'Triage eventi',
|
||||||
'system_agents.agent.event-triage.desc': 'Legge gli eventi che arrivano dai tuoi connettori — nuove email, modifiche al calendario, messaggi in arrivo — decide quali meritano la tua attenzione e ti avvisa solo di quelli. Viene eseguito per una persona alla volta e legge solo gli eventi di quella persona.',
|
'system_agents.agent.event-triage.desc': 'Legge gli eventi che arrivano dai tuoi connettori — nuove email, modifiche al calendario, messaggi in arrivo — decide quali meritano la tua attenzione e ti avvisa solo di quelli. Viene eseguito per una persona alla volta e legge solo gli eventi di quella persona.',
|
||||||
'system_agents.agent.memory-lint-private.name': 'Manutenzione memoria privata',
|
'system_agents.agent.memory-lint-private.name': 'Manutenzione memoria privata',
|
||||||
|
|||||||
Reference in New Issue
Block a user