remove operator as target for scheduled prompts

This commit is contained in:
damocles 2026-09-11 22:36:40 +02:00 committed by mara
commit b4dc09ff93
7 changed files with 130 additions and 86 deletions

View file

@ -216,15 +216,15 @@ target drives `push_todo`'s own upsert-by-key dedup: a re-fire of the
*same schedule* against a target that hasn't reviewed the last one *same schedule* against a target that hasn't reviewed the last one
collapses into that one todo instead of stacking up. collapses into that one todo instead of stacking up.
**`operator` is the one exception** — it's a valid schedule target but `operator` is not a valid schedule target — she isn't a container with
has no in-container todo inbox, so it keeps the original broker a todo inbox to push a wake into, and this mechanism is for fanning a
`Message` path (the dashboard mirrors `to == operator` into its own prompt out to agents, not scheduling something at her. Rejected at
pane, same as before). submit/edit time (`ScheduledPrompts::submit`/`update`).
### Missing-target failure ### Missing-target failure
When a target name doesn't resolve to a known agent (container When a target name doesn't resolve to a known agent (container
destroyed, operator typo, etc.) the worker: destroyed, typo, etc.) the worker:
1. Records `last_result = "no such agent: <name>"` on the 1. Records `last_result = "no such agent: <name>"` on the
per-target row. per-target row.

View file

@ -238,22 +238,23 @@ function intervalSecondsFromFormData(fd, namePrefix) {
// Targets multi-select chip box — shared between the new-schedule // Targets multi-select chip box — shared between the new-schedule
// form and the edit-schedule form. Same DOM shape, same candidate // form and the edit-schedule form. Same DOM shape, same candidate
// list (containers + operator + root); only the chip element id // list (containers + root); only the chip element id
// prefix and checkbox field name vary. Used to be inlined twice in // prefix and checkbox field name vary. Used to be inlined twice in
// near-identical 18-line blocks; consolidated here so a future // near-identical 18-line blocks; consolidated here so a future
// change (new chip kind, candidate-list source swap, etc.) lives in // change (new chip kind, candidate-list source swap, etc.) lives in
// one place. Returns the wrapping `<label class="schedule-field">` // one place. Returns the wrapping `<label class="schedule-field">`
// ready to append to the form. // ready to append to the form.
function buildTargetChips({ idPrefix, fieldName, checked, extraNames = [] }) { function buildTargetChips({ idPrefix, fieldName, checked, extraNames = [] }) {
const candidates = ["operator"]; const candidates = [];
const containerNames = Array.from(containersState.values()) const containerNames = Array.from(containersState.values())
.map((c) => c.name) .map((c) => c.name)
.filter((n) => n !== "operator") .filter((n) => n !== "operator")
.sort(); .sort();
for (const n of containerNames) candidates.push(n); for (const n of containerNames) candidates.push(n);
// `extraNames` lets the edit form keep showing an already-active // `extraNames` lets the edit form keep showing an already-active
// target that's vanished from the live container list (operator's // target that's vanished from the live container list (container
// typo, container destroyed mid-schedule, etc.) so it's still // destroyed mid-schedule, a pre-existing schedule created before
// operator stopped being a valid target, etc.) so it's still
// explicitly uncheckable. New-schedule callers pass `[]`. // explicitly uncheckable. New-schedule callers pass `[]`.
for (const n of extraNames) if (!candidates.includes(n)) candidates.push(n); for (const n of extraNames) if (!candidates.includes(n)) candidates.push(n);
@ -606,12 +607,12 @@ async function submitNewScheduleInline(tr, submitBtn) {
} }
}); });
} }
// The set of agent columns in the schedules table: operator + root // The set of agent columns in the schedules table: root (manager) first,
// (manager) first, then live containers (sorted), then any extra names // then live containers (sorted), then any extra names that appear as a
// that appear as a schedule target but aren't in the live container list // schedule target but aren't in the live container list (container
// (operator typo, container destroyed mid-schedule, etc.) — same // destroyed mid-schedule, etc.) — same membership rule as
// membership rule as `buildTargetChips` so the table and the new-/ // `buildTargetChips` so the table and the new-/edit-form chip boxes
// edit-form chip boxes agree on what's addressable. // agree on what's addressable.
function schedulesTableAgentSet() { function schedulesTableAgentSet() {
const seen = new Set(); const seen = new Set();
const out = []; const out = [];
@ -621,7 +622,6 @@ function schedulesTableAgentSet() {
out.push(n); out.push(n);
} }
}; };
push("operator");
const containerNames = Array.from(containersState.values()) const containerNames = Array.from(containersState.values())
.map((c) => c.name) .map((c) => c.name)
.filter((n) => n !== "operator") .filter((n) => n !== "operator")
@ -643,14 +643,14 @@ function renderSchedulesTableHead(agents) {
el("th", {}, "owner"), el("th", {}, "owner"),
el("th", { class: "schedules-table-body-th" }, "body"), el("th", { class: "schedules-table-body-th" }, "body"),
); );
// 'operator' is always a valid target; every other column maps to a // Every column maps to a container, so flag any column whose agent is
// container, so flag any column whose agent is no longer in the live // no longer in the live roster — its past schedules linger in the
// roster — its past schedules linger in the table but the agent is gone. // table but the agent is gone.
const liveNames = new Set( const liveNames = new Set(
Array.from(containersState.values()).map((c) => c.name), Array.from(containersState.values()).map((c) => c.name),
); );
for (const a of agents) { for (const a of agents) {
const gone = a !== "operator" && !liveNames.has(a); const gone = !liveNames.has(a);
headerRow.append( headerRow.append(
el( el(
"th", "th",

View file

@ -196,9 +196,10 @@ pub struct UpdateMetaInputsArgs {
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RequestSchedulePromptArgs { pub struct RequestSchedulePromptArgs {
/// Recipient agents — one schedule fires to many inboxes at the /// Recipient agents — one schedule fires to many inboxes at the
/// scheduled time. `operator` is a legitimate target (mara: "we /// scheduled time. `operator` is not a valid target — she isn't a
/// want to get rid of the manager special case so yes manager /// container with a todo inbox to push a wake into, and this is
/// can be recipient" — the operator slot follows the same rule). /// for fanning a prompt out to agents, not scheduling something
/// at her.
pub targets: Vec<String>, pub targets: Vec<String>,
/// Message body delivered to each target's inbox at fire time. /// Message body delivered to each target's inbox at fire time.
/// Same size budget as `send` bodies. /// Same size budget as `send` bodies.

View file

@ -98,6 +98,14 @@ pub(super) async fn post_schedule_new(
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("schedule must have at least one target")); .with_detail("schedule must have at least one target"));
} }
if payload
.targets
.iter()
.any(|t| t == hive_sh4re::manager::OPERATOR_RECIPIENT)
{
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("operator is not a valid schedule target"));
}
if payload.body.trim().is_empty() { if payload.body.trim().is_empty() {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("schedule body must be non-empty")); .with_detail("schedule body must be non-empty"));

View file

@ -47,6 +47,15 @@ pub(super) fn handle_request_schedule_prompt(
message: "schedule must have at least one target".into(), message: "schedule must have at least one target".into(),
}; };
} }
if payload
.targets
.iter()
.any(|t| t == hive_sh4re::manager::OPERATOR_RECIPIENT)
{
return Response::Err {
message: "operator is not a valid schedule target".into(),
};
}
if payload.body.trim().is_empty() { if payload.body.trim().is_empty() {
return Response::Err { return Response::Err {
message: "schedule body must be non-empty".into(), message: "schedule body must be non-empty".into(),
@ -310,8 +319,7 @@ pub fn schedule_to_wire_public(
/// the dashboard's schedule table doesn't render ghost columns for /// the dashboard's schedule table doesn't render ghost columns for
/// destroyed agents. `live` is the set of logical agent names from the /// destroyed agents. `live` is the set of logical agent names from the
/// last `nixos-container list` scan (stopped agents included, destroyed /// last `nixos-container list` scan (stopped agents included, destroyed
/// ones absent); the `operator` pseudo-target is always retained since /// ones absent). Applied only to the dashboard wire paths
/// it isn't a container. Applied only to the dashboard wire paths
/// (`api_schedules` + the `SchedulesChanged` SSE emit) — `list_schedules` /// (`api_schedules` + the `SchedulesChanged` SSE emit) — `list_schedules`
/// doesn't apply this filter (it still returns every live target, ghost /// doesn't apply this filter (it still returns every live target, ghost
/// or not, on the schedules the requester is authorized to see) so an /// or not, on the schedules the requester is authorized to see) so an
@ -323,9 +331,7 @@ pub(crate) fn filter_ghost_schedule_targets(
live: &std::collections::HashSet<String>, live: &std::collections::HashSet<String>,
) { ) {
for s in schedules.iter_mut() { for s in schedules.iter_mut() {
s.targets.retain(|t| { s.targets.retain(|t| live.contains(&t.target));
t.target == hive_sh4re::manager::OPERATOR_RECIPIENT || live.contains(&t.target)
});
} }
} }
@ -417,19 +423,19 @@ mod tests {
} }
#[test] #[test]
fn ghost_filter_drops_dead_agents_keeps_live_and_operator() { fn ghost_filter_drops_dead_agents_keeps_live() {
let live: std::collections::HashSet<String> = ["iris".to_owned(), "damocles".to_owned()] let live: std::collections::HashSet<String> = ["iris".to_owned(), "damocles".to_owned()]
.into_iter() .into_iter()
.collect(); .collect();
let mut schedules = vec![schedule(&["iris", "ghost", "operator", "damocles"])]; let mut schedules = vec![schedule(&["iris", "ghost", "damocles"])];
filter_ghost_schedule_targets(&mut schedules, &live); filter_ghost_schedule_targets(&mut schedules, &live);
let kept: Vec<&str> = schedules[0] let kept: Vec<&str> = schedules[0]
.targets .targets
.iter() .iter()
.map(|t| t.target.as_str()) .map(|t| t.target.as_str())
.collect(); .collect();
// `ghost` (destroyed) dropped; live agents + operator pseudo-target kept. // `ghost` (destroyed) dropped; live agents kept.
assert_eq!(kept, vec!["iris", "operator", "damocles"]); assert_eq!(kept, vec!["iris", "damocles"]);
} }
#[test] #[test]
@ -437,8 +443,6 @@ mod tests {
let live: std::collections::HashSet<String> = std::collections::HashSet::new(); let live: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut schedules = vec![schedule(&["gone1", "gone2"])]; let mut schedules = vec![schedule(&["gone1", "gone2"])];
filter_ghost_schedule_targets(&mut schedules, &live); filter_ghost_schedule_targets(&mut schedules, &live);
// operator is never in the live set but is always retained; here
// there's no operator target, so everything drops.
assert!(schedules[0].targets.is_empty()); assert!(schedules[0].targets.is_empty());
} }
} }

View file

@ -220,11 +220,21 @@ impl ScheduledPrompts {
/// Insert a new schedule. Returns the new id. Empty `targets` is /// Insert a new schedule. Returns the new id. Empty `targets` is
/// rejected — a schedule with no recipients would silently /// rejected — a schedule with no recipients would silently
/// never fan out, masking caller bugs. /// never fan out, masking caller bugs. `operator` is rejected too
/// — it isn't a container with a todo inbox to push a wake into,
/// and scheduling a prompt at the operator herself doesn't fit the
/// "fan a prompt out to agent inboxes" shape this exists for.
pub fn submit(&self, new: &NewSchedule) -> Result<i64> { pub fn submit(&self, new: &NewSchedule) -> Result<i64> {
if new.targets.is_empty() { if new.targets.is_empty() {
bail!("schedule must have at least one target"); bail!("schedule must have at least one target");
} }
if new
.targets
.iter()
.any(|t| t == hive_sh4re::manager::OPERATOR_RECIPIENT)
{
bail!("operator is not a valid schedule target");
}
let mut conn = self.conn.lock().unwrap(); let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?; let tx = conn.transaction()?;
tx.execute( tx.execute(
@ -435,6 +445,12 @@ impl ScheduledPrompts {
if let Some(Some(0)) = patch.interval_seconds { if let Some(Some(0)) = patch.interval_seconds {
bail!("interval_seconds must be > 0 (use None for one-shot)"); bail!("interval_seconds must be > 0 (use None for one-shot)");
} }
if patch.targets_add.as_deref().is_some_and(|list| {
list.iter()
.any(|t| t == hive_sh4re::manager::OPERATOR_RECIPIENT)
}) {
bail!("operator is not a valid schedule target");
}
// Single transaction so a partial failure can't leave a // Single transaction so a partial failure can't leave a
// half-updated row. // half-updated row.
let tx = conn.transaction()?; let tx = conn.transaction()?;
@ -747,6 +763,39 @@ mod tests {
assert!(format!("{err:#}").contains("at least one target")); assert!(format!("{err:#}").contains("at least one target"));
} }
#[test]
fn submit_rejects_operator_as_a_target() {
let (_dir, db) = open();
let err = db
.submit(&NewSchedule {
owner: "operator".into(),
targets: vec!["operator".into()],
body: "wake".into(),
first_fire_at_unix: 100,
interval_seconds: None,
description: None,
source: ScheduleSource::Operator,
})
.unwrap_err();
assert!(format!("{err:#}").contains("operator is not a valid schedule target"));
}
#[test]
fn update_rejects_operator_in_targets_add() {
let (_dir, db) = open();
let id = submit_one_shot(&db, 100, &["alice"]);
let err = db
.update(
id,
UpdateSchedule {
targets_add: Some(vec!["operator".into()]),
..Default::default()
},
)
.unwrap_err();
assert!(format!("{err:#}").contains("operator is not a valid schedule target"));
}
#[test] #[test]
fn due_returns_only_past_active_rows() { fn due_returns_only_past_active_rows() {
let (_dir, db) = open(); let (_dir, db) = open();

View file

@ -102,11 +102,7 @@ async fn fire_schedule(coord: &Arc<Coordinator>, schedule: &Schedule, now: i64)
continue; continue;
} }
let target = &target_row.target; let target = &target_row.target;
// `operator` is a valid recipient (mara c4) — the operator has if !known.contains(target) {
// no in-container todo inbox, so it keeps the regular broker
// `Message` path; the dashboard mirrors `to == operator` into
// its own pane.
if target != hive_sh4re::manager::OPERATOR_RECIPIENT && !known.contains(target) {
let reason = format!("no such agent: {target}"); let reason = format!("no such agent: {target}");
if let Err(e) = if let Err(e) =
coord coord
@ -159,17 +155,16 @@ async fn fire_schedule(coord: &Arc<Coordinator>, schedule: &Schedule, now: i64)
} }
} }
/// Deliver `body` to a single already-known-live `target` — a broker /// Deliver `body` to a single already-known-live `target` via
/// `Message` when `target` is the operator (no in-container todo inbox /// `push_todo`, keyed on the schedule's own identity
/// to push into there), a `push_todo` otherwise, keyed on the /// (`schedule:{schedule_id}`) so a re-fire against a target that hasn't
/// schedule's own identity (`schedule:{schedule_id}`) so a re-fire /// reviewed the last one collapses via `push_todo`'s own upsert-by-key
/// against a target that hasn't reviewed the last one collapses via /// dedup — but `reopen_if_acked = true` means that collapse only holds
/// `push_todo`'s own upsert-by-key dedup — but `reopen_if_acked = true` /// while unreviewed; once acked, the next fire reopens regardless of
/// means that collapse only holds while unreviewed; once acked, the next /// whether the body changed. Shared by the periodic `fire_schedule`
/// fire reopens regardless of whether the body changed. Shared by the periodic /// tick and the manual `fire_now` dashboard action — the only
/// `fire_schedule` tick and the manual `fire_now` dashboard action — /// difference between them is what each caller does with the `Result`
/// the only difference between them is what each caller does with the /// (log/prefix and per-target `last_result`/`FireNowReport`
/// `Result` (log/prefix and per-target `last_result`/`FireNowReport`
/// bookkeeping), not the delivery choice itself. /// bookkeeping), not the delivery choice itself.
async fn deliver_to_target( async fn deliver_to_target(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
@ -177,18 +172,6 @@ async fn deliver_to_target(
target: &str, target: &str,
body: &str, body: &str,
) -> Result<(), String> { ) -> Result<(), String> {
if target == hive_sh4re::manager::OPERATOR_RECIPIENT {
let msg = Message {
from: hive_sh4re::manager::trusted_sender("scheduled"),
to: target.to_owned(),
body: body.to_owned(),
in_reply_to: None,
};
coord
.broker
.send(&msg)
.map_err(|e| format!("broker send failed: {e:#}"))
} else {
coord coord
.push_todo( .push_todo(
target, target,
@ -206,7 +189,6 @@ async fn deliver_to_target(
true, true,
) )
.await .await
}
} }
/// Send the operator a one-line advisory when a schedule fires /// Send the operator a one-line advisory when a schedule fires
@ -307,7 +289,7 @@ pub async fn fire_now(
continue; continue;
} }
let target = &target_row.target; let target = &target_row.target;
if target != hive_sh4re::manager::OPERATOR_RECIPIENT && !known.contains(target) { if !known.contains(target) {
let reason = format!("manual fire: no such agent: {target}"); let reason = format!("manual fire: no such agent: {target}");
if let Err(e) = if let Err(e) =
coord coord
@ -374,10 +356,10 @@ pub async fn fire_now(
/// Always seeds the manager name (which is always reachable); /// Always seeds the manager name (which is always reachable);
/// adds every live nspawn container that matches the `h-` prefix. /// adds every live nspawn container that matches the `h-` prefix.
/// On `lifecycle::list` failure the set stays at just the manager /// On `lifecycle::list` failure the set stays at just the manager
/// — fail-CLOSED, meaning every non-operator/non-manager target /// — fail-CLOSED, meaning every non-manager target looks missing
/// looks missing this tick and gets the same treatment as a /// this tick and gets the same treatment as a genuinely-destroyed
/// genuinely-destroyed agent: operator advisory + per-target /// agent: operator advisory + per-target `last_result` annotation +
/// `last_result` annotation + skipped delivery. Recurring /// skipped delivery. Recurring
/// schedules recover automatically on the next tick (the lifecycle /// schedules recover automatically on the next tick (the lifecycle
/// listing usually works); one-shots that land on this window /// listing usually works); one-shots that land on this window
/// lose their single delivery. Logged at `warn`, not propagated. /// lose their single delivery. Logged at `warn`, not propagated.