hive-c0re stops owning scheduled prompts; agents lose the schedule tools
Scheduled prompts are swarm-level now (swarmctl schedule, #4936). The hive keeps the rows it has only so the operator can copy them over. - hive-c0re no longer fires anything: the scheduled-prompts worker is gone, and with it every create/edit/cancel/pause/resume/fire path (dashboard endpoints, agent-socket verbs, the SchedulePrompt approval kind and its approve handler). The store keeps open + list; GET /api/schedules stays. - A v6 approvals migration drops schedule_prompt rows. - Nothing submits approvals any more, so the submit path goes too: Approvals::submit_kind (now a test fixture), the ApprovalAdded dashboard event and its emitter. - Coordinator::push_todo, whose only caller was the worker, goes, and with it hive_host_sock::agent_todo_socket and hive-c0re's hive-agent-sock / hive-sock-client deps. - The schedules_changed dashboard event goes; nothing mutates the rows. - Agents lose request_schedule_prompt, cancel_schedule, fire_schedule_now, edit_schedule and list_schedules, the `scheduling` tool group, their system-prompt line and their transcript formatters. `remind` is unchanged.
This commit is contained in:
parent
7d80f5146d
commit
c3a54bbdd0
31 changed files with 273 additions and 3124 deletions
|
|
@ -9,8 +9,8 @@ would hit. HTTP is the sole transport; there is no stdio mode here.
|
||||||
## When to use it
|
## When to use it
|
||||||
|
|
||||||
This is where the core hyperhive tool surface lives: `send`, `recv`,
|
This is where the core hyperhive tool surface lives: `send`, `recv`,
|
||||||
`remind`, `get_loose_ends`, `set_status`,
|
`remind`, `get_loose_ends`, `set_status`
|
||||||
`get_agent_meta`, scheduling, and the approval-request tools. Reach
|
and `get_agent_meta`. Reach
|
||||||
for this crate when you're adding or changing a built-in tool rather
|
for this crate when you're adding or changing a built-in tool rather
|
||||||
than an `extraMcpServers` add-on — those are separate stdio bridges
|
than an `extraMcpServers` add-on — those are separate stdio bridges
|
||||||
(see `hive-bash-mcp`, `hive-matrix-mcp`) that dial the harness socket
|
(see `hive-bash-mcp`, `hive-matrix-mcp`) that dial the harness socket
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ pub struct CompactArgs {
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Privileged tool arg types (lifecycle, approvals, scheduling)
|
// Privileged tool arg types (lifecycle, approvals)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
|
@ -123,89 +123,3 @@ pub struct CancelLooseEndArgs {
|
||||||
/// Row id from the matching `get_loose_ends` entry.
|
/// Row id from the matching `get_loose_ends` entry.
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
||||||
pub struct RequestSchedulePromptArgs {
|
|
||||||
/// Recipient agents — one schedule fires to many inboxes at the
|
|
||||||
/// scheduled time. `operator` is not a valid target — she isn't a
|
|
||||||
/// container with a todo inbox to push a wake into, and this is
|
|
||||||
/// for fanning a prompt out to agents, not scheduling something
|
|
||||||
/// at her.
|
|
||||||
pub targets: Vec<String>,
|
|
||||||
/// Message body delivered to each target's inbox at fire time.
|
|
||||||
/// Same size budget as `send` bodies.
|
|
||||||
pub body: String,
|
|
||||||
/// Absolute unix timestamp (seconds) for the FIRST fire. For
|
|
||||||
/// recurring schedules the worker re-arms in
|
|
||||||
/// `interval_seconds` steps from this point on.
|
|
||||||
pub first_fire_at_unix: i64,
|
|
||||||
/// `None` / absent = one-shot. `Some(n > 0)` = recurring every
|
|
||||||
/// `n` seconds. The worker clamps catch-up so a long downtime
|
|
||||||
/// fires ONCE on resume (skipped-cycle count surfaces in the
|
|
||||||
/// per-target `last_result`), not N delayed pulses in a row.
|
|
||||||
#[serde(default)]
|
|
||||||
pub interval_seconds: Option<u64>,
|
|
||||||
/// Optional description shown on the dashboard approval card +
|
|
||||||
/// preserved on the schedule row for later operator reference.
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
||||||
pub struct FireScheduleNowArgs {
|
|
||||||
/// Schedule id to fire out of band. Get this from a prior
|
|
||||||
/// `list_schedules` call or the approval-resolved event for
|
|
||||||
/// the originating `request_schedule_prompt`.
|
|
||||||
pub id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
||||||
pub struct CancelScheduleArgs {
|
|
||||||
/// Schedule id from a prior `list_schedules` call or the
|
|
||||||
/// approval-resolved event for a `request_schedule_prompt`.
|
|
||||||
pub id: i64,
|
|
||||||
/// Optional target list. `None` / empty = cancel the entire
|
|
||||||
/// schedule. `Some(["alice", "bob"])` = cancel just those
|
|
||||||
/// recipients (the schedule keeps firing for any remaining
|
|
||||||
/// active targets, and auto-cancels its parent row when every
|
|
||||||
/// target is gone).
|
|
||||||
#[serde(default)]
|
|
||||||
pub targets: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
||||||
pub struct EditScheduleArgs {
|
|
||||||
/// Schedule id from a prior `list_schedules` call or the
|
|
||||||
/// approval-resolved event for a `request_schedule_prompt`.
|
|
||||||
pub id: i64,
|
|
||||||
/// New body text. Omit to keep the existing one.
|
|
||||||
#[serde(default)]
|
|
||||||
pub body: Option<String>,
|
|
||||||
/// New description. Omit to keep the existing one. (To CLEAR
|
|
||||||
/// the description, use the dashboard PATCH endpoint
|
|
||||||
/// directly — the agent surface intentionally keeps the args
|
|
||||||
/// flat / non-nullable to dodge the doubly-wrapped Option
|
|
||||||
/// schemars quirk; clearing fields is rare and operator-side.)
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
/// Recurring interval in seconds. Omit to keep the existing
|
|
||||||
/// cadence; pass an explicit value to set a new one. Toggling
|
|
||||||
/// recurring↔one-shot (clearing the interval) is operator-only
|
|
||||||
/// for the same reason as `description` above.
|
|
||||||
#[serde(default)]
|
|
||||||
pub interval_seconds: Option<u64>,
|
|
||||||
/// New absolute unix timestamp for the next fire. Omit to
|
|
||||||
/// leave the schedule on its current cadence.
|
|
||||||
#[serde(default)]
|
|
||||||
pub next_fire_at_unix: Option<i64>,
|
|
||||||
/// Names of new targets to add. Replace-on-conflict: re-adding
|
|
||||||
/// a previously cancelled target resets its history (operator
|
|
||||||
/// intent on re-add = "this target is active again").
|
|
||||||
#[serde(default)]
|
|
||||||
pub targets_add: Option<Vec<String>>,
|
|
||||||
/// Names of targets to cancel. Tombstones preserve per-target
|
|
||||||
/// audit; when no active targets remain the schedule
|
|
||||||
/// auto-cancels.
|
|
||||||
#[serde(default)]
|
|
||||||
pub targets_remove: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,8 @@ mod args;
|
||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
pub use args::{
|
pub use args::{
|
||||||
AckUntilArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs, EditScheduleArgs,
|
AckUntilArgs, CancelLooseEndArgs, CompactArgs, GetAgentMetaArgs, MarkTodosDoneArgs, RecvArgs,
|
||||||
FireScheduleNowArgs, GetAgentMetaArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs,
|
RemindArgs, SendArgs, SetStatusArgs,
|
||||||
RequestSchedulePromptArgs, SendArgs, SetStatusArgs,
|
|
||||||
};
|
};
|
||||||
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
|
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
|
||||||
|
|
||||||
|
|
@ -519,159 +518,6 @@ impl AgentServer {
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
|
||||||
description = "Queue an approval to add a scheduled prompt — one body delivered to \
|
|
||||||
N agent inboxes at a target time, optionally recurring every `interval_seconds`. \
|
|
||||||
The operator approves; on approve hive-c0re inserts the schedule and the worker \
|
|
||||||
fans it out. Even self-targeted schedules go through this flow (the operator pays \
|
|
||||||
for the wake-up tokens); the existing `remind` MCP tool stays the quick \
|
|
||||||
no-approval self-wake path. \n\n\
|
|
||||||
Catch-up clamp: if hive-c0re is down across multiple intervals, only ONE delayed \
|
|
||||||
fire happens on resume (per recurring schedule). The skipped-cycle count surfaces \
|
|
||||||
in the per-target `last_result` for the operator's audit trail. \n\n\
|
|
||||||
Per-target failure: a target name that doesn't resolve to a live agent → operator \
|
|
||||||
gets a one-line advisory `Message` from `system`; the schedule keeps firing for \
|
|
||||||
the other (live) targets."
|
|
||||||
)]
|
|
||||||
async fn request_schedule_prompt(
|
|
||||||
&self,
|
|
||||||
Parameters(args): Parameters<RequestSchedulePromptArgs>,
|
|
||||||
) -> String {
|
|
||||||
let log = format!("{args:?}");
|
|
||||||
run_tool_envelope("request_schedule_prompt", log, async move {
|
|
||||||
let target_count = args.targets.len();
|
|
||||||
let (resp, retries) = self
|
|
||||||
.dispatch(hive_core_agent_sock::Request::RequestSchedulePrompt(
|
|
||||||
hive_sh4re::manager::SchedulePromptPayload {
|
|
||||||
targets: args.targets,
|
|
||||||
body: args.body,
|
|
||||||
first_fire_at_unix: args.first_fire_at_unix,
|
|
||||||
interval_seconds: args.interval_seconds,
|
|
||||||
description: args.description,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
annotate_retries(
|
|
||||||
format_ack(
|
|
||||||
resp,
|
|
||||||
"request_schedule_prompt",
|
|
||||||
format!("approval queued: {target_count} target(s)"),
|
|
||||||
),
|
|
||||||
retries,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(
|
|
||||||
description = "Fire a scheduled prompt out of band — runs the per-target fan-out \
|
|
||||||
once immediately without disturbing the schedule's cadence. Recurring schedules \
|
|
||||||
keep their next_fire_at unchanged (the manual fire is additive). One-shot \
|
|
||||||
schedules are CONSUMED by the manual fire (cancelled afterwards): the operator's \
|
|
||||||
intent on a one-shot is 'send this now, the scheduled time was wrong'. \n\n\
|
|
||||||
Authorization mirrors `cancel_schedule`: you can fire any schedule, whoever owns it."
|
|
||||||
)]
|
|
||||||
async fn fire_schedule_now(&self, Parameters(args): Parameters<FireScheduleNowArgs>) -> String {
|
|
||||||
let log = format!("{args:?}");
|
|
||||||
run_tool_envelope("fire_schedule_now", log, async move {
|
|
||||||
let id = args.id;
|
|
||||||
let (resp, retries) = self
|
|
||||||
.dispatch(hive_core_agent_sock::Request::FireScheduleNow { id })
|
|
||||||
.await;
|
|
||||||
annotate_retries(
|
|
||||||
format_ack(resp, "fire_schedule_now", format!("fired #{id} now")),
|
|
||||||
retries,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(
|
|
||||||
description = "Cancel a scheduled prompt. With no `targets` field, cancels the \
|
|
||||||
whole schedule (all recipients flipped). With a non-empty `targets` list, cancels \
|
|
||||||
just those recipients; the schedule keeps firing for any remaining active targets \
|
|
||||||
and auto-cancels its parent row when every target is cancelled. \n\n\
|
|
||||||
Authorization: you can cancel any schedule, whoever owns it."
|
|
||||||
)]
|
|
||||||
async fn cancel_schedule(&self, Parameters(args): Parameters<CancelScheduleArgs>) -> String {
|
|
||||||
let log = format!("{args:?}");
|
|
||||||
run_tool_envelope("cancel_schedule", log, async move {
|
|
||||||
let id = args.id;
|
|
||||||
let (resp, retries) = self
|
|
||||||
.dispatch(hive_core_agent_sock::Request::CancelSchedule {
|
|
||||||
id: args.id,
|
|
||||||
targets: args.targets,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
annotate_retries(
|
|
||||||
format_ack(resp, "cancel_schedule", format!("cancelled #{id}")),
|
|
||||||
retries,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(
|
|
||||||
description = "Edit an existing scheduled prompt's mutable fields. Pass only \
|
|
||||||
the fields you want to change — anything omitted keeps its current value. Editable: \
|
|
||||||
`body`, `description`, `interval_seconds` (positive only via this tool; flipping \
|
|
||||||
recurring→one-shot is operator-only via the dashboard), `next_fire_at_unix`, and \
|
|
||||||
the target set via `targets_add` / `targets_remove`. Both target lists are \
|
|
||||||
applied in the same transaction with removes-before-adds, so a single edit can \
|
|
||||||
swap a target atomically. Re-adding a previously-removed target starts a fresh \
|
|
||||||
per-target history (drops the tombstone). Draining all targets auto-cancels the \
|
|
||||||
parent schedule. \n\n\
|
|
||||||
Authorization mirrors `cancel_schedule` / `fire_schedule_now`: you can edit any \
|
|
||||||
schedule, whoever owns it. \
|
|
||||||
Refuses cancelled schedules (the row's terminal — submit a fresh one)."
|
|
||||||
)]
|
|
||||||
async fn edit_schedule(&self, Parameters(args): Parameters<EditScheduleArgs>) -> String {
|
|
||||||
let log = format!("{args:?}");
|
|
||||||
run_tool_envelope("edit_schedule", log, async move {
|
|
||||||
let id = args.id;
|
|
||||||
let (resp, retries) = self
|
|
||||||
.dispatch(hive_core_agent_sock::Request::EditSchedule {
|
|
||||||
id: args.id,
|
|
||||||
body: args.body,
|
|
||||||
description: args.description.map(Some),
|
|
||||||
interval_seconds: args.interval_seconds.map(Some),
|
|
||||||
next_fire_at_unix: args.next_fire_at_unix,
|
|
||||||
targets_add: args.targets_add,
|
|
||||||
targets_remove: args.targets_remove,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
annotate_retries(
|
|
||||||
format_ack(resp, "edit_schedule", format!("edited #{id}")),
|
|
||||||
retries,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(
|
|
||||||
description = "List every scheduled prompt in the hive, whoever owns it — active and \
|
|
||||||
cancelled-but-not-yet-reaped. Returns schedule id, owner, body, target set with \
|
|
||||||
per-target last_fired_at + last_result, next fire time, recurring interval. \
|
|
||||||
Use this to look up an id before calling `cancel_schedule`, or to audit what \
|
|
||||||
the hive is going to be woken up about next."
|
|
||||||
)]
|
|
||||||
async fn list_schedules(&self) -> String {
|
|
||||||
run_tool_envelope("list_schedules", String::new(), async move {
|
|
||||||
let (resp, retries) = self
|
|
||||||
.dispatch(hive_core_agent_sock::Request::ListSchedules)
|
|
||||||
.await;
|
|
||||||
let body = match resp {
|
|
||||||
Ok(hive_core_agent_sock::Response::Schedules { schedules }) => {
|
|
||||||
serde_json::to_string(&schedules)
|
|
||||||
.unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}"))
|
|
||||||
}
|
|
||||||
other => reply_err(other, "list_schedules"),
|
|
||||||
};
|
|
||||||
annotate_retries(body, retries)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool_handler(
|
#[tool_handler(
|
||||||
|
|
|
||||||
|
|
@ -643,12 +643,12 @@ mod tests {
|
||||||
Ok(hive_core_agent_sock::Response::OkWarn {
|
Ok(hive_core_agent_sock::Response::OkWarn {
|
||||||
warnings: vec!["name is reserved".to_owned(), "second thing".to_owned()],
|
warnings: vec!["name is reserved".to_owned(), "second thing".to_owned()],
|
||||||
}),
|
}),
|
||||||
"request_schedule_prompt",
|
"send",
|
||||||
"schedule approval queued".to_owned(),
|
"sent to iris".to_owned(),
|
||||||
);
|
);
|
||||||
// The operation HAPPENED — dropping the success line would read as a
|
// The operation HAPPENED — dropping the success line would read as a
|
||||||
// failure and invite a retry that queues a second approval.
|
// failure and invite a retry that sends a second message.
|
||||||
assert!(out.starts_with("schedule approval queued"));
|
assert!(out.starts_with("sent to iris"));
|
||||||
assert!(out.contains("⚠️ name is reserved"));
|
assert!(out.contains("⚠️ name is reserved"));
|
||||||
assert!(out.contains("⚠️ second thing"));
|
assert!(out.contains("⚠️ second thing"));
|
||||||
}
|
}
|
||||||
|
|
@ -659,10 +659,10 @@ mod tests {
|
||||||
// warning marker would pass the test above.
|
// warning marker would pass the test above.
|
||||||
let out = format_ack(
|
let out = format_ack(
|
||||||
Ok(hive_core_agent_sock::Response::Ok),
|
Ok(hive_core_agent_sock::Response::Ok),
|
||||||
"request_schedule_prompt",
|
"send",
|
||||||
"queued".to_owned(),
|
"sent".to_owned(),
|
||||||
);
|
);
|
||||||
assert_eq!(out, "queued");
|
assert_eq!(out, "sent");
|
||||||
assert!(!out.contains('⚠'));
|
assert!(!out.contains('⚠'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ Tools (hyperhive surface). Full signature + behavior for each comes from the too
|
||||||
|
|
||||||
- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at.
|
- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at.
|
||||||
- **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time.
|
- **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time.
|
||||||
- **Scheduling** (_requires `scheduling` tool group_): `request_schedule_prompt` (queues an approval), `cancel_schedule`, `fire_schedule_now`, `edit_schedule`, `list_schedules` (these four don't need approval — you can manage any schedule, whoever owns it).
|
|
||||||
|
|
||||||
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — open a PR on your `agent-configs/<name>` repo, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits.
|
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — open a PR on your `agent-configs/<name>` repo, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits.
|
||||||
|
|
||||||
|
|
@ -16,7 +15,7 @@ Messages from sender `system` are hyperhive helper events (JSON body, `event` fi
|
||||||
|
|
||||||
- `needs_update` — agent's flake rev is stale. Ask the operator to rebuild it.
|
- `needs_update` — agent's flake rev is stale. Ask the operator to rebuild it.
|
||||||
- `container_crash` — ask the operator to start it again; if it keeps crashing, say so with what you saw.
|
- `container_crash` — ask the operator to start it again; if it keeps crashing, say so with what you saw.
|
||||||
- `approval_resolved` — one of your own submitted approvals (a scheduled prompt, …) was approved, denied, or failed; the body carries the resolution.
|
- `approval_resolved` — one of your own submitted approvals was approved, denied, or failed; the body carries the resolution.
|
||||||
|
|
||||||
Lifecycle notices that don't need an immediate turn — a new agent spawned, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them.
|
Lifecycle notices that don't need an immediate turn — a new agent spawned, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@
|
||||||
//! `agent-schedules` KV bucket. This task reads that key every
|
//! `agent-schedules` KV bucket. This task reads that key every
|
||||||
//! [`POLL_INTERVAL`] with the one subject the agent's own queue credential is
|
//! [`POLL_INTERVAL`] with the one subject the agent's own queue credential is
|
||||||
//! granted there (`swarm_queue_client::agent_schedules::read_own`), and fires
|
//! granted there (`swarm_queue_client::agent_schedules::read_own`), and fires
|
||||||
//! each due schedule as the same `schedule`-subsystem todo, keyed
|
//! each due schedule as a `schedule`-subsystem todo, keyed `schedule:<id>`.
|
||||||
//! `schedule:<id>`, that hive-c0re's schedule worker pushes. The wake path
|
//! The wake path is the todo store's.
|
||||||
//! is the todo store's.
|
|
||||||
//!
|
//!
|
||||||
//! **Fire times come from the schedule alone**: `anchor + k * interval`
|
//! **Fire times come from the schedule alone**: `anchor + k * interval`
|
||||||
//! ([`Schedule::latest_slot`]). Rewriting the list, or changing a body,
|
//! ([`Schedule::latest_slot`]). Rewriting the list, or changing a body,
|
||||||
|
|
@ -43,7 +42,7 @@ use crate::todos::Todos;
|
||||||
/// longest a fire can lag its slot, and a change can take to reach the agent.
|
/// longest a fire can lag its slot, and a change can take to reach the agent.
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
/// The todo subsystem and source hive-c0re's schedule worker uses.
|
/// The todo subsystem and source a schedule fire carries.
|
||||||
const SUBSYSTEM: &str = "schedule";
|
const SUBSYSTEM: &str = "schedule";
|
||||||
const SOURCE: &str = "scheduled";
|
const SOURCE: &str = "scheduled";
|
||||||
|
|
||||||
|
|
@ -161,7 +160,7 @@ fn tick(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push `schedule` as the todo hive-c0re's worker pushes, and wake the turn
|
/// Push `schedule` as a todo, and wake the turn
|
||||||
/// loop when it is new or reopened.
|
/// loop when it is new or reopened.
|
||||||
fn push_todo(todos: &Todos, wake: &Notify, schedule: &Schedule) -> bool {
|
fn push_todo(todos: &Todos, wake: &Notify, schedule: &Schedule) -> bool {
|
||||||
let key = format!("schedule:{}", schedule.id);
|
let key = format!("schedule:{}", schedule.id);
|
||||||
|
|
@ -364,11 +363,10 @@ mod tests {
|
||||||
assert_eq!(fired(&l, &slots, 1005), ["a body"]);
|
assert_eq!(fired(&l, &slots, 1005), ["a body"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same todo hive-c0re's worker pushes, so the wake path does not
|
/// A fire is subsystem `schedule`, key `schedule:<id>`, source
|
||||||
/// change: subsystem `schedule`, key `schedule:<id>`, source
|
/// `scheduled`, and reopens once the agent acked the previous fire.
|
||||||
/// `scheduled`, and a reopen once the agent acked the previous fire.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_fire_is_the_hive_c0re_schedule_todo() {
|
fn a_fire_is_the_schedule_todo() {
|
||||||
let todos = Todos::open(Path::new(":memory:")).unwrap();
|
let todos = Todos::open(Path::new(":memory:")).unwrap();
|
||||||
let wake = Notify::new();
|
let wake = Notify::new();
|
||||||
let s = schedule("abcdefgh", 1000, Some(100));
|
let s = schedule("abcdefgh", 1000, Some(100));
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,6 @@ hive-jobq.workspace = true
|
||||||
hive-jobq-wire.workspace = true
|
hive-jobq-wire.workspace = true
|
||||||
hive-log.workspace = true
|
hive-log.workspace = true
|
||||||
hive-priv-sock.workspace = true
|
hive-priv-sock.workspace = true
|
||||||
hive-agent-sock.workspace = true
|
|
||||||
hive-sock-client.workspace = true
|
|
||||||
hive-types.workspace = true
|
hive-types.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
listenfd = "1"
|
listenfd = "1"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ use crate::lifecycle;
|
||||||
///
|
///
|
||||||
/// Every queued kind resolves its approval row via
|
/// Every queued kind resolves its approval row via
|
||||||
/// [`resolve_approval_dag`] when the DAG settles terminal.
|
/// [`resolve_approval_dag`] when the DAG settles terminal.
|
||||||
pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
pub fn approve(coord: &Arc<Coordinator>, id: i64) -> Result<()> {
|
||||||
let approval = coord.approvals.mark_approved(id)?;
|
let approval = coord.approvals.mark_approved(id)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
%approval.id,
|
%approval.id,
|
||||||
|
|
@ -50,19 +50,6 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
||||||
coord.emit_rebuild_queue_snapshot();
|
coord.emit_rebuild_queue_snapshot();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
ApprovalKind::SchedulePrompt => {
|
|
||||||
// No queue card for SchedulePrompt — the work is a single
|
|
||||||
// sqlite insert, the actual "running" lifetime lives on
|
|
||||||
// the scheduled-prompts surface itself (worker fires it
|
|
||||||
// at the scheduled time). Run inline + fire
|
|
||||||
// `ApprovalResolved` so the approval row leaves Pending
|
|
||||||
// immediately.
|
|
||||||
let result = run_approval_schedule_prompt(&coord, approval).await;
|
|
||||||
if result.is_ok() {
|
|
||||||
coord.emit_schedules_snapshot();
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,37 +149,6 @@ pub(crate) async fn post_rev_deploy_failure_to_pr(agent: &str, rev: &str, err: &
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inline (non-queued) handler for `ApprovalKind::SchedulePrompt`.
|
|
||||||
/// On approve, decode the `SchedulePromptPayload` JSON from the
|
|
||||||
/// approval's `commit_ref`, insert a row into `scheduled_prompts`
|
|
||||||
/// (with `source = Approval { id }`), and fire `ApprovalResolved`.
|
|
||||||
/// The worker takes over from here — fan-out at fire time.
|
|
||||||
async fn run_approval_schedule_prompt(
|
|
||||||
coord: &Coordinator,
|
|
||||||
approval: hive_sh4re::approvals::Approval,
|
|
||||||
) -> Result<()> {
|
|
||||||
let result: Result<()> = async {
|
|
||||||
let payload: hive_sh4re::manager::SchedulePromptPayload =
|
|
||||||
serde_json::from_str(&approval.commit_ref)
|
|
||||||
.context("decode SchedulePromptPayload from approval.commit_ref")?;
|
|
||||||
coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.submit(&crate::scheduled_prompts::NewSchedule {
|
|
||||||
owner: approval.agent.to_string(),
|
|
||||||
targets: payload.targets,
|
|
||||||
body: payload.body,
|
|
||||||
first_fire_at_unix: payload.first_fire_at_unix,
|
|
||||||
interval_seconds: payload.interval_seconds,
|
|
||||||
description: payload.description,
|
|
||||||
source: crate::scheduled_prompts::ScheduleSource::Approval { id: approval.id },
|
|
||||||
})
|
|
||||||
.map(|_| ())
|
|
||||||
.context("insert scheduled prompt")
|
|
||||||
}
|
|
||||||
.await;
|
|
||||||
finish_approval(coord, &approval, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve an approval row from how its DAG's work ended — the body of the
|
/// Resolve an approval row from how its DAG's work ended — the body of the
|
||||||
/// [`NodeKind::ResolveApproval`] tail node. Every approval-carrying template
|
/// [`NodeKind::ResolveApproval`] tail node. Every approval-carrying template
|
||||||
/// resolves here: the pipeline is ordinary queue nodes,
|
/// resolves here: the pipeline is ordinary queue nodes,
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,9 @@ const LAST_STOPPED_RUNNING_KEY: &str = "last_stopped_running";
|
||||||
pub struct Coordinator {
|
pub struct Coordinator {
|
||||||
pub broker: Arc<Broker>,
|
pub broker: Arc<Broker>,
|
||||||
pub approvals: Arc<Approvals>,
|
pub approvals: Arc<Approvals>,
|
||||||
/// Scheduled-prompts queue. One sqlite connection,
|
/// Scheduled-prompt rows, read-only: the dashboard lists them so the
|
||||||
/// internal mutex; the worker drains due rows and the manager
|
/// operator can recreate them with `swarmctl schedule`. Nothing on
|
||||||
/// handlers insert / cancel through the same handle.
|
/// the hive writes or fires them.
|
||||||
pub scheduled_prompts: Arc<crate::scheduled_prompts::ScheduledPrompts>,
|
pub scheduled_prompts: Arc<crate::scheduled_prompts::ScheduledPrompts>,
|
||||||
/// Full build-log capture. `lifecycle::run` /
|
/// Full build-log capture. `lifecycle::run` /
|
||||||
/// `lifecycle::prebuild_toplevel` `start()` a row per attempt,
|
/// `lifecycle::prebuild_toplevel` `start()` a row per attempt,
|
||||||
|
|
@ -426,17 +426,6 @@ pub struct ApprovalResolved<'a> {
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Field-named payload for [`Coordinator::emit_approval_added`].
|
|
||||||
/// Mirrors the `ApprovalAdded` dashboard-event fields. `agent`
|
|
||||||
/// borrows from the caller; `approval_kind` is a compile-time
|
|
||||||
/// constant.
|
|
||||||
pub struct ApprovalAdded<'a> {
|
|
||||||
pub id: i64,
|
|
||||||
pub agent: &'a str,
|
|
||||||
pub approval_kind: &'static str,
|
|
||||||
pub description: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The two ways [`Coordinator::set_paused_by_name`] can fail. See its own
|
/// The two ways [`Coordinator::set_paused_by_name`] can fail. See its own
|
||||||
/// doc comment for why this is a distinct type rather than one
|
/// doc comment for why this is a distinct type rather than one
|
||||||
/// `anyhow::Error` (`Write` already wraps one, `BadName` never needs to).
|
/// `anyhow::Error` (`Write` already wraps one, `BadName` never needs to).
|
||||||
|
|
@ -587,42 +576,11 @@ impl Coordinator {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit a `SchedulesChanged` snapshot event. Called from every
|
|
||||||
/// schedule mutation site (operator API handlers + the worker
|
|
||||||
/// after each tick that fires or rearms a row) so the dashboard's
|
|
||||||
/// scheduled-prompts tab updates live without polling.
|
|
||||||
pub fn emit_schedules_snapshot(self: &Arc<Self>) {
|
|
||||||
let mut schedules: Vec<hive_sh4re::schedule::WireSchedule> =
|
|
||||||
match self.scheduled_prompts.list() {
|
|
||||||
Ok(rows) => rows
|
|
||||||
.into_iter()
|
|
||||||
.map(crate::socket_server::schedule_to_wire_public)
|
|
||||||
.collect(),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = ?e, "emit_schedules_snapshot: list failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Strip ghost targets (destroyed agents) so the dashboard doesn't
|
|
||||||
// render dead columns. Best-effort: if the roster cache is
|
|
||||||
// momentarily contended we emit unfiltered rather than block this
|
|
||||||
// sync path — the next snapshot / page reload corrects it.
|
|
||||||
if let Some(live) = self.live_container_names_blocking() {
|
|
||||||
crate::socket_server::filter_ghost_schedule_targets(&mut schedules, &live);
|
|
||||||
}
|
|
||||||
self.emit_dashboard_event(DashboardEvent::SchedulesChanged {
|
|
||||||
seq: self.next_seq(),
|
|
||||||
schedules,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort synchronous snapshot of live container names (the
|
/// Best-effort synchronous snapshot of live container names (the
|
||||||
/// logical agent names from the last `nixos-container list` scan).
|
/// logical agent names from the last `nixos-container list` scan).
|
||||||
/// Returns `None` when the roster cache lock is momentarily
|
/// Returns `None` when the roster cache lock is momentarily
|
||||||
/// contended, so a transient miss is treated as "roster unknown,
|
/// contended. The async `containers_snapshot` is the reliable path
|
||||||
/// don't filter" rather than hiding live schedule targets. The async
|
/// for request handlers; this exists for the sync SSE snapshot emits.
|
||||||
/// `containers_snapshot` is the reliable path for request handlers;
|
|
||||||
/// this exists for the sync `emit_schedules_snapshot` SSE emit.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn live_container_names_blocking(&self) -> Option<HashSet<String>> {
|
pub fn live_container_names_blocking(&self) -> Option<HashSet<String>> {
|
||||||
self.last_containers
|
self.last_containers
|
||||||
|
|
@ -772,24 +730,6 @@ impl Coordinator {
|
||||||
self.meta_updates_active.load(Ordering::SeqCst) > 0
|
self.meta_updates_active.load(Ordering::SeqCst) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit `ApprovalAdded` immediately after the row is inserted in
|
|
||||||
/// sqlite.
|
|
||||||
pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) {
|
|
||||||
let ApprovalAdded {
|
|
||||||
id,
|
|
||||||
agent,
|
|
||||||
approval_kind,
|
|
||||||
description,
|
|
||||||
} = ev;
|
|
||||||
self.emit_dashboard_event(DashboardEvent::ApprovalAdded {
|
|
||||||
seq: self.next_seq(),
|
|
||||||
id,
|
|
||||||
agent: agent.to_owned(),
|
|
||||||
approval_kind,
|
|
||||||
description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emit `ApprovalResolved` after `mark_approved` / `mark_denied` /
|
/// Emit `ApprovalResolved` after `mark_approved` / `mark_denied` /
|
||||||
/// `mark_failed` lands. `resolved_at` is stamped from the system
|
/// `mark_failed` lands. `resolved_at` is stamped from the system
|
||||||
/// clock here so call sites don't repeat the conversion; if you
|
/// clock here so call sites don't repeat the conversion; if you
|
||||||
|
|
@ -904,7 +844,7 @@ impl Coordinator {
|
||||||
.with_context(|| format!("create agent dir {}", agent_dir.display()))?;
|
.with_context(|| format!("create agent dir {}", agent_dir.display()))?;
|
||||||
let socket_path = Self::socket_path(name);
|
let socket_path = Self::socket_path(name);
|
||||||
// Hand the full Coordinator to the per-agent socket — it needs
|
// Hand the full Coordinator to the per-agent socket — it needs
|
||||||
// more than just the broker (approvals, scheduled_prompts, ...).
|
// more than just the broker (approvals, ...).
|
||||||
let socket = socket_server::start(name, &socket_path, self.clone())?;
|
let socket = socket_server::start(name, &socket_path, self.clone())?;
|
||||||
self.agents.lock().unwrap().insert(name.to_owned(), socket);
|
self.agents.lock().unwrap().insert(name.to_owned(), socket);
|
||||||
Ok(agent_dir)
|
Ok(agent_dir)
|
||||||
|
|
@ -1176,77 +1116,6 @@ impl Coordinator {
|
||||||
.unwrap_or_else(|| hive_sh4re::manager::MANAGER_AGENT.to_owned())
|
.unwrap_or_else(|| hive_sh4re::manager::MANAGER_AGENT.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push a todo directly into `agent`'s in-container todo store — a
|
|
||||||
/// best-effort *live* dial of its `hive-agent-sock` socket
|
|
||||||
/// (`hive_host_sock::agent_todo_socket`), same `UpsertTodo` request
|
|
||||||
/// shape the built-in in-container producers (matrix/bash/forge-notify)
|
|
||||||
/// already send — `subsystem` isn't restricted to that set, any
|
|
||||||
/// producer can use the same shape. This is the migration target for
|
|
||||||
/// `HelperEvent` variants that are pure "FYI, check when convenient" notices: the
|
|
||||||
/// event stops being a broker `Message` (which always drives an
|
|
||||||
/// immediate turn) and becomes a todo instead, with the same
|
|
||||||
/// dedup-by-key semantics as any other producer.
|
|
||||||
///
|
|
||||||
/// Deliberately push, not queue: if the agent's container is down
|
|
||||||
/// (socket file absent) or the dial otherwise fails, this is a
|
|
||||||
/// silent no-op (logged at `debug`/`warn`) — no retry, no fallback
|
|
||||||
/// delivery. An agent that's down doesn't need a todo about
|
|
||||||
/// something it'll never see appear this way; whatever mechanism
|
|
||||||
/// resurfaces its state on the next boot is unrelated to this path.
|
|
||||||
///
|
|
||||||
/// Returns `Ok(())` on a successful push and `Err(reason)` — a
|
|
||||||
/// short human-readable string, already logged at `debug`/`warn`
|
|
||||||
/// by this function — on any of the silent-no-op cases below.
|
|
||||||
/// Most callers are pure fire-and-forget notices and ignore it
|
|
||||||
/// (`let _ = coord.push_todo(...).await;`); callers that track a
|
|
||||||
/// per-target delivery outcome (e.g. the scheduled-prompts worker's
|
|
||||||
/// `last_result` column) use it instead of assuming success.
|
|
||||||
/// `reopen_if_acked` forwards to `UpsertTodo` (see its doc comment) —
|
|
||||||
/// `false` for every caller here except the scheduled-prompts worker.
|
|
||||||
pub async fn push_todo(
|
|
||||||
&self,
|
|
||||||
agent: &str,
|
|
||||||
subsystem: &str,
|
|
||||||
key: Option<String>,
|
|
||||||
summary: String,
|
|
||||||
source: Option<String>,
|
|
||||||
reopen_if_acked: bool,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let Ok(ident) = hive_types::Ident::parse(agent) else {
|
|
||||||
tracing::warn!(%agent, "push_todo: not a valid agent ident, skipping");
|
|
||||||
return Err(format!("'{agent}' is not a valid agent ident"));
|
|
||||||
};
|
|
||||||
let path = hive_host_sock::agent_todo_socket(&ident);
|
|
||||||
if !path.exists() {
|
|
||||||
tracing::debug!(%agent, path = %path.display(), "push_todo: agent socket not present (offline?), skipping");
|
|
||||||
return Err(format!("agent '{agent}' socket not present (offline?)"));
|
|
||||||
}
|
|
||||||
let req = hive_agent_sock::Request::UpsertTodo {
|
|
||||||
subsystem: subsystem.to_owned(),
|
|
||||||
key,
|
|
||||||
summary,
|
|
||||||
source,
|
|
||||||
reopen_if_acked,
|
|
||||||
};
|
|
||||||
match hive_sock_client::request::<_, hive_agent_sock::Response>(
|
|
||||||
&path,
|
|
||||||
&req,
|
|
||||||
hive_sock_client::Retry::None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(hive_agent_sock::Response::Err { message }) => {
|
|
||||||
tracing::warn!(%agent, %message, "push_todo: agent rejected the todo");
|
|
||||||
Err(format!("agent '{agent}' rejected the todo: {message}"))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(%agent, error = ?e, "push_todo: dial failed");
|
|
||||||
Err(format!("push_todo dial to '{agent}' failed: {e:#}"))
|
|
||||||
}
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
|
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
|
||||||
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
|
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
|
||||||
/// body = JSON-encoded event) — e.g. `ContainerCrash`, `NeedsUpdate`.
|
/// body = JSON-encoded event) — e.g. `ContainerCrash`, `NeedsUpdate`.
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ pub(super) async fn post_approve(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(id): AxumPath<i64>,
|
AxumPath(id): AxumPath<i64>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match actions::approve(state.coord.clone(), id).await {
|
match actions::approve(&state.coord, id) {
|
||||||
// 200 instead of 303 — `actions::approve` fires
|
// 200 instead of 303 — `actions::approve` fires
|
||||||
// `ApprovalResolved` (success path) or the eventual failure
|
// `ApprovalResolved` (success path) or the eventual failure
|
||||||
// event, both of which the dashboard's derived store applies
|
// event, both of which the dashboard's derived store applies
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ use crate::lifecycle;
|
||||||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||||
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
|
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
|
||||||
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
||||||
(name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"),
|
(name = "schedules", description = "read-only scheduled-prompt list + rebuild-queue cancel"),
|
||||||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||||
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
||||||
|
|
@ -112,8 +112,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
// GET-vs-GET or POST-vs-POST pairs across different paths do. Each
|
// GET-vs-GET or POST-vs-POST pairs across different paths do. Each
|
||||||
// call below is therefore scoped to exactly one path — two handlers
|
// call below is therefore scoped to exactly one path — two handlers
|
||||||
// in the same call only when they genuinely share a path with
|
// in the same call only when they genuinely share a path with
|
||||||
// different methods (`schedules::api_schedules`/`post_schedule_new`
|
// different methods — chained via
|
||||||
// on `/api/schedules`) — chained via
|
|
||||||
// repeated `.routes(...)` calls instead of one giant `routes!(...)`
|
// repeated `.routes(...)` calls instead of one giant `routes!(...)`
|
||||||
// with everything in it.
|
// with everything in it.
|
||||||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||||
|
|
@ -141,15 +140,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.routes(routes!(permissions::post_permissions))
|
.routes(routes!(permissions::post_permissions))
|
||||||
.routes(routes!(permissions::get_stale_permissions))
|
.routes(routes!(permissions::get_stale_permissions))
|
||||||
.routes(routes!(permissions::delete_agent_permissions))
|
.routes(routes!(permissions::delete_agent_permissions))
|
||||||
.routes(routes!(
|
.routes(routes!(schedules::api_schedules))
|
||||||
schedules::api_schedules,
|
|
||||||
schedules::post_schedule_new
|
|
||||||
))
|
|
||||||
.routes(routes!(schedules::patch_schedule))
|
|
||||||
.routes(routes!(schedules::post_schedule_cancel))
|
|
||||||
.routes(routes!(schedules::post_schedule_pause))
|
|
||||||
.routes(routes!(schedules::post_schedule_resume))
|
|
||||||
.routes(routes!(schedules::post_schedule_fire_now))
|
|
||||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
||||||
.routes(routes!(approvals::post_approve))
|
.routes(routes!(approvals::post_approve))
|
||||||
.routes(routes!(approvals::post_deny))
|
.routes(routes!(approvals::post_deny))
|
||||||
|
|
@ -367,15 +358,7 @@ mod router_build_probe {
|
||||||
.routes(routes!(permissions::post_permissions))
|
.routes(routes!(permissions::post_permissions))
|
||||||
.routes(routes!(permissions::get_stale_permissions))
|
.routes(routes!(permissions::get_stale_permissions))
|
||||||
.routes(routes!(permissions::delete_agent_permissions))
|
.routes(routes!(permissions::delete_agent_permissions))
|
||||||
.routes(routes!(
|
.routes(routes!(schedules::api_schedules))
|
||||||
schedules::api_schedules,
|
|
||||||
schedules::post_schedule_new
|
|
||||||
))
|
|
||||||
.routes(routes!(schedules::patch_schedule))
|
|
||||||
.routes(routes!(schedules::post_schedule_cancel))
|
|
||||||
.routes(routes!(schedules::post_schedule_pause))
|
|
||||||
.routes(routes!(schedules::post_schedule_resume))
|
|
||||||
.routes(routes!(schedules::post_schedule_fire_now))
|
|
||||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
||||||
.routes(routes!(approvals::post_approve))
|
.routes(routes!(approvals::post_approve))
|
||||||
.routes(routes!(approvals::post_deny))
|
.routes(routes!(approvals::post_deny))
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,31 @@
|
||||||
//! Scheduled-prompt + rebuild-queue endpoints for the dashboard.
|
//! Scheduled-prompt list + rebuild-queue cancel endpoints for the dashboard.
|
||||||
//!
|
//!
|
||||||
//! Operator-direct schedule CRUD (`/api/schedules` GET/POST, `{id}` PATCH,
|
//! `/api/schedules` is read-only: it lists the rows still in the hive's
|
||||||
//! `{id}/cancel` + `{id}/fire-now`) — the operator click *is* the approval,
|
//! `scheduled_prompts` table so the operator can recreate them with
|
||||||
//! so these skip the manager approval gate. Also the co-located
|
//! `swarmctl schedule` (`docs/swarm/schedules.md`). Nothing on the hive
|
||||||
|
//! writes or fires those rows. Also the co-located
|
||||||
//! `/api/rebuild-queue/{id}/cancel` endpoint.
|
//! `/api/rebuild-queue/{id}/cancel` endpoint.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path as AxumPath, State},
|
extract::{Path as AxumPath, State},
|
||||||
http::StatusCode,
|
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
use problem_details::ProblemDetails;
|
use super::{AppState, error_response};
|
||||||
|
|
||||||
use crate::scheduled_prompts::ScheduleNotFoundOrCancelled;
|
|
||||||
use crate::scheduled_prompts_worker::FireNowReport;
|
|
||||||
|
|
||||||
use super::{AppState, error_problem, error_response};
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
|
||||||
pub(super) struct NewScheduleBody {
|
|
||||||
id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||||
pub(super) struct CancelResultBody {
|
pub(super) struct CancelResultBody {
|
||||||
cancelled: bool,
|
cancelled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of every schedule for the
|
/// Snapshot of every schedule row for the
|
||||||
/// scheduled-prompts tab.
|
/// scheduled-prompts tab.
|
||||||
///
|
///
|
||||||
/// Returns the wire shape directly so the frontend can render
|
/// Returns the wire shape directly so the frontend can render
|
||||||
/// without an extra translation layer.
|
/// without an extra translation layer.
|
||||||
// `hive_sh4re::schedule::WireSchedule` (the actual body) has no `ToSchema` — adding
|
// `hive_sh4re::schedule::WireSchedule` (the actual body) has no `ToSchema` — adding
|
||||||
// one would pull `utoipa` into the wire-types crate for a single dashboard
|
// one would pull `utoipa` into the wire-types crate for a single dashboard
|
||||||
// endpoint. `serde_json::Value` placeholder; see the batch report.
|
// endpoint. `serde_json::Value` placeholder.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/schedules",
|
path = "/api/schedules",
|
||||||
|
|
@ -48,13 +38,8 @@ pub(super) struct CancelResultBody {
|
||||||
pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
|
pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
|
||||||
match state.coord.scheduled_prompts.list() {
|
match state.coord.scheduled_prompts.list() {
|
||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
let mut wire: Vec<hive_sh4re::schedule::WireSchedule> = rows
|
let mut wire: Vec<hive_sh4re::schedule::WireSchedule> =
|
||||||
.into_iter()
|
rows.into_iter().map(schedule_to_wire).collect();
|
||||||
.map(crate::socket_server::schedule_to_wire_public)
|
|
||||||
.collect();
|
|
||||||
// Drop ghost targets (agents that no longer exist) so the
|
|
||||||
// table never shows dead columns. Uses the reliable async
|
|
||||||
// roster snapshot here on the request path.
|
|
||||||
let live: std::collections::HashSet<String> = state
|
let live: std::collections::HashSet<String> = state
|
||||||
.coord
|
.coord
|
||||||
.containers_snapshot()
|
.containers_snapshot()
|
||||||
|
|
@ -62,117 +47,56 @@ pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| c.name)
|
.map(|c| c.name)
|
||||||
.collect();
|
.collect();
|
||||||
crate::socket_server::filter_ghost_schedule_targets(&mut wire, &live);
|
filter_ghost_schedule_targets(&mut wire, &live);
|
||||||
axum::Json(wire).into_response()
|
axum::Json(wire).into_response()
|
||||||
}
|
}
|
||||||
Err(e) => error_response(&format!("scheduled_prompts list: {e:#}")),
|
Err(e) => error_response(&format!("scheduled_prompts list: {e:#}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Operator-direct schedule creation
|
/// Drop schedule targets that point at agents which no longer exist, so
|
||||||
/// (mara: "user can add them manually").
|
/// the schedule table doesn't render ghost columns for destroyed agents.
|
||||||
///
|
/// `live` is the set of logical agent names from the last
|
||||||
/// Accepts the same `SchedulePromptPayload` shape as the manager
|
/// `nixos-container list` scan (stopped agents included, destroyed ones
|
||||||
/// request flow but skips the approval gate — the operator click
|
/// absent). A view filter: the rows keep every target.
|
||||||
/// *is* the approval. The schedule lands directly with
|
fn filter_ghost_schedule_targets(
|
||||||
/// `source = Operator` and the worker picks it up at fire time.
|
schedules: &mut [hive_sh4re::schedule::WireSchedule],
|
||||||
#[utoipa::path(
|
live: &std::collections::HashSet<String>,
|
||||||
post,
|
) {
|
||||||
path = "/api/schedules",
|
for s in schedules.iter_mut() {
|
||||||
// `hive_sh4re::manager::SchedulePromptPayload` (the actual body) has no `ToSchema` —
|
s.targets.retain(|t| live.contains(&t.target));
|
||||||
// same reasoning as the `Vec<serde_json::Value>` placeholder on
|
|
||||||
// `api_schedules` above.
|
|
||||||
request_body(content = serde_json::Value, description = "SchedulePromptPayload wire shape"),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "created; body carries the new row id", body = NewScheduleBody),
|
|
||||||
(status = 400, description = "no targets, empty body, or interval_seconds == 0"),
|
|
||||||
(status = 500, description = "submit failed"),
|
|
||||||
),
|
|
||||||
tag = "schedules"
|
|
||||||
)]
|
|
||||||
pub(super) async fn post_schedule_new(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
axum::Json(payload): axum::Json<hive_sh4re::manager::SchedulePromptPayload>,
|
|
||||||
) -> Result<Response, ProblemDetails> {
|
|
||||||
if payload.targets.is_empty() {
|
|
||||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
|
||||||
.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() {
|
|
||||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
|
||||||
.with_detail("schedule body must be non-empty"));
|
|
||||||
}
|
|
||||||
if let Some(0) = payload.interval_seconds {
|
|
||||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
|
||||||
.with_detail("interval_seconds must be > 0 (use None for one-shot)"));
|
|
||||||
}
|
|
||||||
let new = crate::scheduled_prompts::NewSchedule {
|
|
||||||
owner: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(),
|
|
||||||
targets: payload.targets,
|
|
||||||
body: payload.body,
|
|
||||||
first_fire_at_unix: payload.first_fire_at_unix,
|
|
||||||
interval_seconds: payload.interval_seconds,
|
|
||||||
description: payload.description,
|
|
||||||
source: crate::scheduled_prompts::ScheduleSource::Operator,
|
|
||||||
};
|
|
||||||
match state.coord.scheduled_prompts.submit(&new) {
|
|
||||||
Ok(id) => {
|
|
||||||
state.coord.emit_schedules_snapshot();
|
|
||||||
Ok(axum::Json(NewScheduleBody { id }).into_response())
|
|
||||||
}
|
|
||||||
Err(e) => Err(error_problem(&format!("schedule submit: {e:#}"))),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional JSON body for `fire-now`. Absent / empty body ⇒
|
fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::schedule::WireSchedule {
|
||||||
/// `reset_timer = false` (back-compat: cadence stays intact).
|
hive_sh4re::schedule::WireSchedule {
|
||||||
#[derive(serde::Deserialize, Default, utoipa::ToSchema)]
|
id: s.id,
|
||||||
pub(super) struct FireNowBody {
|
owner: s.owner,
|
||||||
#[serde(default)]
|
body: s.body,
|
||||||
reset_timer: bool,
|
interval_seconds: s.interval_seconds,
|
||||||
}
|
next_fire_at_unix: s.next_fire_at_unix,
|
||||||
|
created_at_unix: s.created_at_unix,
|
||||||
/// Operator-initiated
|
source: match s.source {
|
||||||
/// out-of-band fire of a scheduled prompt.
|
crate::scheduled_prompts::ScheduleSource::Operator => {
|
||||||
///
|
hive_sh4re::schedule::WireScheduleSource::Operator
|
||||||
/// Runs the per-target fan-out once immediately and reports
|
}
|
||||||
/// per-target outcome counts. One-shot schedules are consumed
|
crate::scheduled_prompts::ScheduleSource::Approval { id } => {
|
||||||
/// (cancelled) by a manual fire — the operator's intent is "send
|
hive_sh4re::schedule::WireScheduleSource::Approval { id }
|
||||||
/// this now, the scheduled time was wrong." For recurring schedules
|
}
|
||||||
/// the cadence stays intact unless the body carries
|
},
|
||||||
/// `{"reset_timer": true}`, in which case the countdown is re-armed
|
cancelled_at_unix: s.cancelled_at_unix,
|
||||||
/// from now (`next_fire_at = now + interval`).
|
paused_at_unix: s.paused_at_unix,
|
||||||
#[utoipa::path(
|
description: s.description,
|
||||||
post,
|
targets: s
|
||||||
path = "/api/schedules/{id}/fire-now",
|
.targets
|
||||||
params(("id" = i64, Path, description = "schedule row id")),
|
.into_iter()
|
||||||
request_body(content = FireNowBody, description = "optional; absent body means reset_timer = false"),
|
.map(|t| hive_sh4re::schedule::WireScheduleTarget {
|
||||||
responses(
|
target: t.target,
|
||||||
(status = 200, description = "fired; per-target outcome counts", body = FireNowReport),
|
cancelled_at_unix: t.cancelled_at_unix,
|
||||||
(status = 500, description = "schedule missing, cancelled, or fully drained"),
|
last_fired_at_unix: t.last_fired_at_unix,
|
||||||
),
|
last_result: t.last_result,
|
||||||
tag = "schedules"
|
})
|
||||||
)]
|
.collect(),
|
||||||
pub(super) async fn post_schedule_fire_now(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(id): AxumPath<i64>,
|
|
||||||
body: Option<axum::Json<FireNowBody>>,
|
|
||||||
) -> Response {
|
|
||||||
let reset_timer = body.is_some_and(|axum::Json(b)| b.reset_timer);
|
|
||||||
match crate::scheduled_prompts_worker::fire_now(&state.coord, id, reset_timer).await {
|
|
||||||
Ok(report) => {
|
|
||||||
state.coord.emit_schedules_snapshot();
|
|
||||||
axum::Json(report).into_response()
|
|
||||||
}
|
|
||||||
Err(e) => error_response(&format!("fire schedule {id} now: {e:#}")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,213 +136,55 @@ pub(super) async fn post_rebuild_queue_cancel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Default, utoipa::ToSchema)]
|
#[cfg(test)]
|
||||||
pub(super) struct CancelScheduleForm {
|
mod tests {
|
||||||
/// `None` / absent / empty array → cancel whole schedule.
|
use super::*;
|
||||||
#[serde(default)]
|
|
||||||
targets: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Default, utoipa::ToSchema)]
|
fn target(name: &str) -> hive_sh4re::schedule::WireScheduleTarget {
|
||||||
#[allow(
|
hive_sh4re::schedule::WireScheduleTarget {
|
||||||
clippy::option_option,
|
target: name.to_owned(),
|
||||||
reason = "double-Option carries three-state PATCH semantics on the wire \
|
cancelled_at_unix: None,
|
||||||
(missing key = leave alone, JSON null = clear, value = set); \
|
last_fired_at_unix: None,
|
||||||
collapsing to a single Option would lose the 'clear' state"
|
last_result: None,
|
||||||
)]
|
|
||||||
pub(super) struct EditScheduleForm {
|
|
||||||
#[serde(default)]
|
|
||||||
body: Option<String>,
|
|
||||||
/// Double-`Option` semantics on the wire: missing key = leave
|
|
||||||
/// alone, explicit `null` = clear, value = set. serde's
|
|
||||||
/// `deserialize_with` trick to distinguish missing from null:
|
|
||||||
/// we wrap each editable field in its own helper. Simpler
|
|
||||||
/// here — keep them plain `Option<Option<_>>` and document
|
|
||||||
/// that the dashboard caller passes JSON `null` to clear.
|
|
||||||
#[serde(default, deserialize_with = "deserialize_some")]
|
|
||||||
description: Option<Option<String>>,
|
|
||||||
#[serde(default, deserialize_with = "deserialize_some")]
|
|
||||||
interval_seconds: Option<Option<u64>>,
|
|
||||||
#[serde(default)]
|
|
||||||
next_fire_at_unix: Option<i64>,
|
|
||||||
/// New targets to add. Replace-on-conflict: re-adding a
|
|
||||||
/// previously-cancelled target drops the tombstone and the
|
|
||||||
/// target starts fresh (operator intent on re-add = "this
|
|
||||||
/// target is active again, fresh start").
|
|
||||||
#[serde(default)]
|
|
||||||
targets_add: Option<Vec<String>>,
|
|
||||||
/// Targets to cancel. Same path as `cancel_targets`:
|
|
||||||
/// tombstones preserve audit and the parent schedule
|
|
||||||
/// auto-cancels when no active targets remain.
|
|
||||||
#[serde(default)]
|
|
||||||
targets_remove: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// serde adaptor: turns missing-key into `None`, explicit-null
|
|
||||||
/// into `Some(None)`, value into `Some(Some(v))`. Standard trick
|
|
||||||
/// for distinguishing "field absent" from "field set to null" in
|
|
||||||
/// JSON PATCH bodies.
|
|
||||||
fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
|
|
||||||
where
|
|
||||||
T: serde::Deserialize<'de>,
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
T::deserialize(deserializer).map(Some)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Partial update of an existing
|
|
||||||
/// schedule.
|
|
||||||
///
|
|
||||||
/// Mutable fields: `body`, `description`, `interval_seconds`,
|
|
||||||
/// `next_fire_at_unix`, plus the target set via `targets_add` /
|
|
||||||
/// `targets_remove`. Both target lists are applied in the same
|
|
||||||
/// transaction as the scalar fields with removes-before-adds;
|
|
||||||
/// re-adding a previously-removed target resets per-target history
|
|
||||||
/// (fresh start); draining all targets auto-cancels the parent
|
|
||||||
/// schedule. JSON body uses missing-key = "leave alone", explicit
|
|
||||||
/// null = "clear" for `description` + `interval_seconds`. Cancelled
|
|
||||||
/// schedules are refused — submit a new one instead. Returns the
|
|
||||||
/// updated `WireSchedule` so the caller's post-edit refresh has the
|
|
||||||
/// new state inline.
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/api/schedules/{id}",
|
|
||||||
params(("id" = i64, Path, description = "schedule row id")),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "updated, wire shape", body = serde_json::Value),
|
|
||||||
(status = 500, description = "update failed (cancelled, not found, ...)"),
|
|
||||||
),
|
|
||||||
tag = "schedules"
|
|
||||||
)]
|
|
||||||
pub(super) async fn patch_schedule(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(id): AxumPath<i64>,
|
|
||||||
axum::Json(form): axum::Json<EditScheduleForm>,
|
|
||||||
) -> Response {
|
|
||||||
let patch = crate::scheduled_prompts::UpdateSchedule {
|
|
||||||
body: form.body,
|
|
||||||
description: form.description,
|
|
||||||
interval_seconds: form.interval_seconds,
|
|
||||||
next_fire_at_unix: form.next_fire_at_unix,
|
|
||||||
targets_add: form.targets_add,
|
|
||||||
targets_remove: form.targets_remove,
|
|
||||||
};
|
|
||||||
if let Err(e) = state.coord.scheduled_prompts.update(id, patch) {
|
|
||||||
return error_response(&format!("edit schedule {id}: {e:#}"));
|
|
||||||
}
|
|
||||||
match state.coord.scheduled_prompts.get(id) {
|
|
||||||
Ok(Some(s)) => {
|
|
||||||
let wire = crate::socket_server::schedule_to_wire_public(s);
|
|
||||||
state.coord.emit_schedules_snapshot();
|
|
||||||
axum::Json(wire).into_response()
|
|
||||||
}
|
|
||||||
Ok(None) => error_response(&format!("edit schedule {id}: row vanished post-update")),
|
|
||||||
Err(e) => error_response(&format!("re-read schedule {id}: {e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pause a schedule so the worker
|
|
||||||
/// skips it until explicitly resumed.
|
|
||||||
///
|
|
||||||
/// Idempotent; no-op on an already-paused row. Returns 404 when the
|
|
||||||
/// schedule is cancelled or not found.
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/schedules/{id}/pause",
|
|
||||||
params(("id" = i64, Path, description = "schedule row id")),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "paused", body = String),
|
|
||||||
(status = 404, description = "schedule cancelled or not found"),
|
|
||||||
(status = 500, description = "pause failed"),
|
|
||||||
),
|
|
||||||
tag = "schedules"
|
|
||||||
)]
|
|
||||||
pub(super) async fn post_schedule_pause(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(id): AxumPath<i64>,
|
|
||||||
) -> Response {
|
|
||||||
match state.coord.scheduled_prompts.pause(id) {
|
|
||||||
Ok(()) => {
|
|
||||||
state.coord.emit_schedules_snapshot();
|
|
||||||
(StatusCode::OK, "ok").into_response()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if e.downcast_ref::<ScheduleNotFoundOrCancelled>().is_some() {
|
|
||||||
(StatusCode::NOT_FOUND, format!("{e}")).into_response()
|
|
||||||
} else {
|
|
||||||
error_response(&format!("pause schedule {id}: {e:#}"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Resume a paused schedule.
|
fn schedule(targets: &[&str]) -> hive_sh4re::schedule::WireSchedule {
|
||||||
///
|
hive_sh4re::schedule::WireSchedule {
|
||||||
/// Idempotent; no-op on an already-active row. Returns 404 when the
|
id: 1,
|
||||||
/// schedule is cancelled or not found.
|
owner: "operator".to_owned(),
|
||||||
#[utoipa::path(
|
body: "ping".to_owned(),
|
||||||
post,
|
interval_seconds: None,
|
||||||
path = "/api/schedules/{id}/resume",
|
next_fire_at_unix: hive_sh4re::wire_time::from_secs(0),
|
||||||
params(("id" = i64, Path, description = "schedule row id")),
|
created_at_unix: hive_sh4re::wire_time::from_secs(0),
|
||||||
responses(
|
source: hive_sh4re::schedule::WireScheduleSource::Operator,
|
||||||
(status = 200, description = "resumed", body = String),
|
cancelled_at_unix: None,
|
||||||
(status = 404, description = "schedule cancelled or not found"),
|
paused_at_unix: None,
|
||||||
(status = 500, description = "resume failed"),
|
description: None,
|
||||||
),
|
targets: targets.iter().map(|t| target(t)).collect(),
|
||||||
tag = "schedules"
|
|
||||||
)]
|
|
||||||
pub(super) async fn post_schedule_resume(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(id): AxumPath<i64>,
|
|
||||||
) -> Response {
|
|
||||||
match state.coord.scheduled_prompts.resume(id) {
|
|
||||||
Ok(()) => {
|
|
||||||
state.coord.emit_schedules_snapshot();
|
|
||||||
(StatusCode::OK, "ok").into_response()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if e.downcast_ref::<ScheduleNotFoundOrCancelled>().is_some() {
|
|
||||||
(StatusCode::NOT_FOUND, format!("{e}")).into_response()
|
|
||||||
} else {
|
|
||||||
error_response(&format!("resume schedule {id}: {e:#}"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Operator-side cancel
|
#[test]
|
||||||
/// (whole schedule when no `targets` field, partial when one is
|
fn ghost_filter_drops_dead_agents_keeps_live() {
|
||||||
/// provided).
|
let live: std::collections::HashSet<String> = ["iris".to_owned(), "damocles".to_owned()]
|
||||||
///
|
.into_iter()
|
||||||
/// Operator bypasses the topology check; the manager surface
|
.collect();
|
||||||
/// enforces it for agent callers.
|
let mut schedules = vec![schedule(&["iris", "ghost", "damocles"])];
|
||||||
#[utoipa::path(
|
filter_ghost_schedule_targets(&mut schedules, &live);
|
||||||
post,
|
let kept: Vec<&str> = schedules[0]
|
||||||
path = "/api/schedules/{id}/cancel",
|
.targets
|
||||||
params(("id" = i64, Path, description = "schedule row id")),
|
.iter()
|
||||||
responses(
|
.map(|t| t.target.as_str())
|
||||||
(status = 200, description = "cancelled (whole or partial)", body = String),
|
.collect();
|
||||||
(status = 500, description = "cancel failed"),
|
assert_eq!(kept, vec!["iris", "damocles"]);
|
||||||
),
|
}
|
||||||
tag = "schedules"
|
|
||||||
)]
|
#[test]
|
||||||
pub(super) async fn post_schedule_cancel(
|
fn ghost_filter_can_empty_targets_when_all_dead() {
|
||||||
State(state): State<AppState>,
|
let live: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
AxumPath(id): AxumPath<i64>,
|
let mut schedules = vec![schedule(&["gone1", "gone2"])];
|
||||||
body: Option<axum::Json<CancelScheduleForm>>,
|
filter_ghost_schedule_targets(&mut schedules, &live);
|
||||||
) -> Response {
|
assert!(schedules[0].targets.is_empty());
|
||||||
let targets = body
|
|
||||||
.and_then(|axum::Json(b)| b.targets)
|
|
||||||
.filter(|t| !t.is_empty());
|
|
||||||
let result = match targets.as_deref() {
|
|
||||||
Some(list) => state.coord.scheduled_prompts.cancel_targets(id, list),
|
|
||||||
None => state.coord.scheduled_prompts.cancel_all(id),
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
state.coord.emit_schedules_snapshot();
|
|
||||||
(StatusCode::OK, "ok").into_response()
|
|
||||||
}
|
|
||||||
Err(e) => error_response(&format!("cancel schedule {id}: {e:#}")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,7 @@ struct ApprovalView {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
|
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
|
||||||
/// `Vec<String>` of input names; `"[]"` = all inputs) and
|
/// `Vec<String>` of input names; `"[]"` = all inputs). The
|
||||||
/// `SchedulePrompt` (JSON-encoded `SchedulePromptPayload`). The
|
|
||||||
/// frontend parses this to render a human-readable card body.
|
/// frontend parses this to render a human-readable card body.
|
||||||
commit_ref: String,
|
commit_ref: String,
|
||||||
/// RFC 3339 UTC time the approval was queued. Rendered as a
|
/// RFC 3339 UTC time the approval was queued. Rendered as a
|
||||||
|
|
|
||||||
|
|
@ -48,24 +48,14 @@ pub enum DashboardEvent {
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
file_refs: Vec<String>,
|
file_refs: Vec<String>,
|
||||||
},
|
},
|
||||||
/// A new approval landed in the pending queue. Payload carries
|
|
||||||
/// enough to render the dashboard row without a `/api/state`
|
|
||||||
/// refetch.
|
|
||||||
///
|
|
||||||
/// The approval's own kind (`"schedule_prompt"` / `"update_meta_inputs"`) lives
|
|
||||||
/// on `approval_kind` rather than `kind` because the latter is taken
|
|
||||||
/// by the serde tag identifying which `DashboardEvent` variant
|
|
||||||
/// this is.
|
|
||||||
ApprovalAdded {
|
|
||||||
seq: u64,
|
|
||||||
id: i64,
|
|
||||||
agent: String,
|
|
||||||
approval_kind: &'static str,
|
|
||||||
description: Option<String>,
|
|
||||||
},
|
|
||||||
/// A pending approval transitioned to a terminal state
|
/// A pending approval transitioned to a terminal state
|
||||||
/// (approved / denied / failed). Clients move the row out of the
|
/// (approved / denied / failed). Clients move the row out of the
|
||||||
/// pending list and into history.
|
/// pending list and into history.
|
||||||
|
///
|
||||||
|
/// The approval's own kind (`"update_meta_inputs"`) lives
|
||||||
|
/// on `approval_kind` rather than `kind` because the latter is taken
|
||||||
|
/// by the serde tag identifying which `DashboardEvent` variant
|
||||||
|
/// this is.
|
||||||
ApprovalResolved {
|
ApprovalResolved {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
id: i64,
|
id: i64,
|
||||||
|
|
@ -170,17 +160,6 @@ pub enum DashboardEvent {
|
||||||
/// (transients, container state, approvals), and a poll loop would be
|
/// (transients, container state, approvals), and a poll loop would be
|
||||||
/// slower for the same information.
|
/// slower for the same information.
|
||||||
RebuildQueueChanged { seq: u64 },
|
RebuildQueueChanged { seq: u64 },
|
||||||
/// Full snapshot of all scheduled prompts. Emitted after every
|
|
||||||
/// operator mutation (new / edit / cancel / fire-now) and after the
|
|
||||||
/// worker fires or rearms a row. Same snapshot-shape rationale as
|
|
||||||
/// `TombstonesChanged` / `MetaInputsChanged` — the list is small and the
|
|
||||||
/// client's
|
|
||||||
/// per-target `last_result` / `last_fired_at_unix` fields are most
|
|
||||||
/// naturally re-derived from the full list.
|
|
||||||
SchedulesChanged {
|
|
||||||
seq: u64,
|
|
||||||
schedules: Vec<hive_sh4re::schedule::WireSchedule>,
|
|
||||||
},
|
|
||||||
/// Full snapshot of capability grants (per-agent `Vec<cap_name>`).
|
/// Full snapshot of capability grants (per-agent `Vec<cap_name>`).
|
||||||
/// Emitted from the rebuild-queue worker after a `PermChange`
|
/// Emitted from the rebuild-queue worker after a `PermChange`
|
||||||
/// `Capabilities` entry commits the JSON file. Lets the P3RM1SS10NS
|
/// `Capabilities` entry commits the JSON file. Lets the P3RM1SS10NS
|
||||||
|
|
@ -235,7 +214,6 @@ impl DashboardEvent {
|
||||||
match self {
|
match self {
|
||||||
DashboardEvent::Sent { .. } => "sent",
|
DashboardEvent::Sent { .. } => "sent",
|
||||||
DashboardEvent::Delivered { .. } => "delivered",
|
DashboardEvent::Delivered { .. } => "delivered",
|
||||||
DashboardEvent::ApprovalAdded { .. } => "approval_added",
|
|
||||||
DashboardEvent::ApprovalResolved { .. } => "approval_resolved",
|
DashboardEvent::ApprovalResolved { .. } => "approval_resolved",
|
||||||
DashboardEvent::TransientSet { .. } => "transient_set",
|
DashboardEvent::TransientSet { .. } => "transient_set",
|
||||||
DashboardEvent::TransientCleared { .. } => "transient_cleared",
|
DashboardEvent::TransientCleared { .. } => "transient_cleared",
|
||||||
|
|
@ -245,7 +223,6 @@ impl DashboardEvent {
|
||||||
DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed",
|
DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed",
|
||||||
DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running",
|
DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running",
|
||||||
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
|
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
|
||||||
DashboardEvent::SchedulesChanged { .. } => "schedules_changed",
|
|
||||||
DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed",
|
DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed",
|
||||||
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
|
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
|
||||||
}
|
}
|
||||||
|
|
@ -294,18 +271,11 @@ mod tests {
|
||||||
in_reply_to: None,
|
in_reply_to: None,
|
||||||
file_refs: Vec::new(),
|
file_refs: Vec::new(),
|
||||||
},
|
},
|
||||||
DashboardEvent::ApprovalAdded {
|
|
||||||
seq: 1,
|
|
||||||
id: 1,
|
|
||||||
agent: "x".into(),
|
|
||||||
approval_kind: "schedule_prompt",
|
|
||||||
description: None,
|
|
||||||
},
|
|
||||||
DashboardEvent::ApprovalResolved {
|
DashboardEvent::ApprovalResolved {
|
||||||
seq: 1,
|
seq: 1,
|
||||||
id: 1,
|
id: 1,
|
||||||
agent: "x".into(),
|
agent: "x".into(),
|
||||||
approval_kind: "schedule_prompt",
|
approval_kind: "update_meta_inputs",
|
||||||
status: "approved",
|
status: "approved",
|
||||||
resolved_at: hive_sh4re::wire_time::from_secs(0),
|
resolved_at: hive_sh4re::wire_time::from_secs(0),
|
||||||
note: None,
|
note: None,
|
||||||
|
|
@ -339,10 +309,6 @@ mod tests {
|
||||||
running: false,
|
running: false,
|
||||||
},
|
},
|
||||||
DashboardEvent::RebuildQueueChanged { seq: 1 },
|
DashboardEvent::RebuildQueueChanged { seq: 1 },
|
||||||
DashboardEvent::SchedulesChanged {
|
|
||||||
seq: 1,
|
|
||||||
schedules: Vec::new(),
|
|
||||||
},
|
|
||||||
DashboardEvent::CapabilitiesChanged {
|
DashboardEvent::CapabilitiesChanged {
|
||||||
seq: 1,
|
seq: 1,
|
||||||
caps: Vec::new(),
|
caps: Vec::new(),
|
||||||
|
|
|
||||||
|
|
@ -383,10 +383,6 @@ async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str, purge: b
|
||||||
// (gained one on destroy, lost one on purge — recompute either way).
|
// (gained one on destroy, lost one on purge — recompute either way).
|
||||||
coord.rescan_containers_and_emit().await;
|
coord.rescan_containers_and_emit().await;
|
||||||
crate::dashboard::emit_tombstones_snapshot(coord).await;
|
crate::dashboard::emit_tombstones_snapshot(coord).await;
|
||||||
// Re-emit the schedules snapshot: the rescan above refreshed the live
|
|
||||||
// roster, so any schedule that still targets the just-destroyed agent now
|
|
||||||
// drops that ghost column live (no page reload needed).
|
|
||||||
coord.emit_schedules_snapshot();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit this agent's rebuild-complete todo. `ok` is not computed — it is which
|
/// Emit this agent's rebuild-complete todo. `ok` is not computed — it is which
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,7 @@ mod workers;
|
||||||
pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups, topology};
|
pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups, topology};
|
||||||
pub(crate) use stats::{container_stats, hive_stats, host_stats, otel_metrics, warnings};
|
pub(crate) use stats::{container_stats, hive_stats, host_stats, otel_metrics, warnings};
|
||||||
pub(crate) use stores::{approvals, broker, build_logs, db, power, scheduled_prompts};
|
pub(crate) use stores::{approvals, broker, build_logs, db, power, scheduled_prompts};
|
||||||
pub(crate) use workers::{
|
pub(crate) use workers::{agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets};
|
||||||
agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets, scheduled_prompts_worker,
|
|
||||||
};
|
|
||||||
|
|
||||||
use coordinator::{Coordinator, HiveEnv, ServeConfig};
|
use coordinator::{Coordinator, HiveEnv, ServeConfig};
|
||||||
|
|
||||||
|
|
@ -240,7 +238,7 @@ fn submit_periodic_sweep(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the coordinator daemon: open the broker, run migrations, spawn
|
/// Start the coordinator daemon: open the broker, run migrations, spawn
|
||||||
/// background tasks (auto-update, vacuums, crash-watcher, scheduled-prompts,
|
/// background tasks (auto-update, vacuums, crash-watcher,
|
||||||
/// dashboard), then serve the admin socket until a signal arrives.
|
/// dashboard), then serve the admin socket until a signal arrives.
|
||||||
#[allow(
|
#[allow(
|
||||||
clippy::too_many_lines,
|
clippy::too_many_lines,
|
||||||
|
|
@ -433,10 +431,6 @@ async fn cmd_serve(
|
||||||
// run_reconcile call register_agent on start; kill/destroy call
|
// run_reconcile call register_agent on start; kill/destroy call
|
||||||
// unregister_agent. No recurring poll needed — c0re owns the listeners.
|
// unregister_agent. No recurring poll needed — c0re owns the listeners.
|
||||||
mcp_sockets::sync_on_start(coord.clone()).await;
|
mcp_sockets::sync_on_start(coord.clone()).await;
|
||||||
// Scheduled-prompts worker: drains due scheduled_prompts rows
|
|
||||||
// and fans the body out to each active target's inbox. See
|
|
||||||
// scheduled_prompts_worker.rs.
|
|
||||||
scheduled_prompts_worker::spawn(coord.clone());
|
|
||||||
// Job-queue scheduler: drives the global DAG queue (rebuild /
|
// Job-queue scheduler: drives the global DAG queue (rebuild /
|
||||||
// meta-update / spawn / power ops). Concurrency comes from the
|
// meta-update / spawn / power ops). Concurrency comes from the
|
||||||
// build-slot count + per-agent leases inside the queue, not from
|
// build-slot count + per-agent leases inside the queue, not from
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
HostRequest::Urls => HostResponse::urls(hive_urls()),
|
HostRequest::Urls => HostResponse::urls(hive_urls()),
|
||||||
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
|
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
|
||||||
HostRequest::Approve { id } => {
|
HostRequest::Approve { id } => {
|
||||||
actions::approve(coord.clone(), *id).await?;
|
actions::approve(&coord, *id)?;
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
HostRequest::Deny { id } => {
|
HostRequest::Deny { id } => {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
//! authenticates the caller: connecting to `<.../agents/foo/mcp.sock>` means
|
//! authenticates the caller: connecting to `<.../agents/foo/mcp.sock>` means
|
||||||
//! you are `foo`; the manager socket simply serves as `ruth`. There is no
|
//! you are `foo`; the manager socket simply serves as `ruth`. There is no
|
||||||
//! privilege flag — both transports run the same [`serve`] / [`dispatch`]
|
//! privilege flag — both transports run the same [`serve`] / [`dispatch`]
|
||||||
//! code, and authority derives uniformly from the caller's identity:
|
//! code, and authority derives uniformly from the caller's identity
|
||||||
//! tool-group membership for the orchestration verbs. An agent-targeting
|
//! (tool-group membership for cancelling an approval). An agent-targeting
|
||||||
//! verb may name any agent, so `ruth` reaches every agent exactly the way
|
//! verb may name any agent, so `ruth` reaches every agent exactly the way
|
||||||
//! every other agent does, not via any hardcoded name match.
|
//! every other agent does, not via any hardcoded name match.
|
||||||
|
|
||||||
|
|
@ -21,16 +21,6 @@ use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
use crate::coordinator::Coordinator;
|
||||||
|
|
||||||
mod schedules;
|
|
||||||
|
|
||||||
pub(crate) use schedules::filter_ghost_schedule_targets;
|
|
||||||
pub use schedules::schedule_to_wire_public;
|
|
||||||
|
|
||||||
use schedules::{
|
|
||||||
EditSchedulePatch, handle_cancel_schedule, handle_edit_schedule, handle_fire_schedule_now,
|
|
||||||
handle_list_schedules, handle_request_schedule_prompt,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct AgentSocket {
|
pub struct AgentSocket {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub handle: JoinHandle<()>,
|
pub handle: JoinHandle<()>,
|
||||||
|
|
@ -275,21 +265,15 @@ pub(crate) fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle the subset of `Request` variants that are identical on both
|
/// Unified dispatch for every socket connection — per-agent sockets and the
|
||||||
/// the agent socket and the manager socket. Returns `Some(response)` for
|
/// (now pure-transport) manager socket alike. There is no privilege bit;
|
||||||
/// every variant it handles; returns `None` for the remaining variants —
|
/// authority derives uniformly from the caller's identity.
|
||||||
/// `GetLooseEnds` and the orchestration verbs (schedules), which need
|
async fn dispatch(
|
||||||
/// per-verb tool-group gating — or for host-admin / unknown requests
|
|
||||||
/// invalid on either socket.
|
|
||||||
///
|
|
||||||
/// The unified `dispatch` calls this first; the remaining arms (which gate
|
|
||||||
/// on topology / capabilities / tool-groups) are handled there.
|
|
||||||
pub(crate) async fn dispatch_shared(
|
|
||||||
req: &hive_core_agent_sock::Request,
|
req: &hive_core_agent_sock::Request,
|
||||||
agent: &str,
|
agent: &str,
|
||||||
coord: &Arc<Coordinator>,
|
coord: &Arc<Coordinator>,
|
||||||
) -> Option<hive_core_agent_sock::Response> {
|
) -> hive_core_agent_sock::Response {
|
||||||
Some(match req {
|
match req {
|
||||||
hive_core_agent_sock::Request::Send {
|
hive_core_agent_sock::Request::Send {
|
||||||
to,
|
to,
|
||||||
body,
|
body,
|
||||||
|
|
@ -331,9 +315,8 @@ pub(crate) async fn dispatch_shared(
|
||||||
coord.clear_pause_pending(agent);
|
coord.clear_pause_pending(agent);
|
||||||
hive_core_agent_sock::Response::Ok
|
hive_core_agent_sock::Response::Ok
|
||||||
}
|
}
|
||||||
// Not a shared variant.
|
hive_core_agent_sock::Request::GetLooseEnds => handle_get_loose_ends(coord, agent),
|
||||||
_ => return None,
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Recv` — long-poll the broker for up to `max` messages (capped at
|
/// `Recv` — long-poll the broker for up to `max` messages (capped at
|
||||||
|
|
@ -572,104 +555,6 @@ fn handle_requeue_inflight(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unified dispatch for every socket connection — per-agent sockets and the
|
|
||||||
/// (now pure-transport) manager socket alike. There is no privilege bit;
|
|
||||||
/// authority derives uniformly from the caller's identity: hive-wide
|
|
||||||
/// orchestration verbs (schedules) require the matching tool-group (the
|
|
||||||
/// grantable capability).
|
|
||||||
async fn dispatch(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Response {
|
|
||||||
if let Some(resp) = dispatch_shared(req, agent, coord).await {
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
match req {
|
|
||||||
Request::GetLooseEnds => handle_get_loose_ends(coord, agent),
|
|
||||||
// Orchestration verbs — gated per-verb on tool-group membership
|
|
||||||
// (see `dispatch_orchestration`).
|
|
||||||
_ => dispatch_orchestration(req, agent, coord).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle the hive-wide orchestration verbs (scheduling). No blanket socket
|
|
||||||
/// gate: each verb gates on the grantable capability that authorises it —
|
|
||||||
/// the matching tool-group (`scheduling`). Any other variant is a
|
|
||||||
/// host-admin / unknown request invalid on either socket.
|
|
||||||
async fn dispatch_orchestration(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Response {
|
|
||||||
match req {
|
|
||||||
Request::RequestSchedulePrompt(payload) => {
|
|
||||||
if let Some(err) = require_group(agent, "scheduling", "schedule a prompt") {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
handle_request_schedule_prompt(coord, agent, payload)
|
|
||||||
}
|
|
||||||
Request::CancelSchedule { id, targets } => {
|
|
||||||
if let Some(err) = require_group(agent, "scheduling", "cancel a schedule") {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
handle_cancel_schedule(coord, agent, *id, targets.as_deref())
|
|
||||||
}
|
|
||||||
Request::EditSchedule {
|
|
||||||
id,
|
|
||||||
body,
|
|
||||||
description,
|
|
||||||
interval_seconds,
|
|
||||||
next_fire_at_unix,
|
|
||||||
targets_add,
|
|
||||||
targets_remove,
|
|
||||||
} => {
|
|
||||||
if let Some(err) = require_group(agent, "scheduling", "edit a schedule") {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
handle_edit_schedule(
|
|
||||||
coord,
|
|
||||||
agent,
|
|
||||||
*id,
|
|
||||||
EditSchedulePatch {
|
|
||||||
body: body.clone(),
|
|
||||||
description: description.clone(),
|
|
||||||
interval_seconds: *interval_seconds,
|
|
||||||
next_fire_at_unix: *next_fire_at_unix,
|
|
||||||
targets_add: targets_add.clone(),
|
|
||||||
targets_remove: targets_remove.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Request::ListSchedules => {
|
|
||||||
if let Some(err) = require_group(agent, "scheduling", "list schedules") {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
handle_list_schedules(coord, agent)
|
|
||||||
}
|
|
||||||
Request::FireScheduleNow { id } => {
|
|
||||||
if let Some(err) = require_group(agent, "scheduling", "fire a schedule") {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
handle_fire_schedule_now(coord, agent, *id).await
|
|
||||||
}
|
|
||||||
// Host-admin-only / unknown variants: never valid on either socket.
|
|
||||||
_ => Response::Err {
|
|
||||||
message: "request not handled on this socket".to_owned(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Capability guard for the hive-wide orchestration verbs: the caller must
|
|
||||||
/// hold the given tool-group. The tool-group (c0re-owned `tool_groups.json`,
|
|
||||||
/// read server-side via [`crate::tool_groups::groups_for`]) is the grantable
|
|
||||||
/// capability — granting it to an orchestrator (e.g. the root) authorises
|
|
||||||
/// these verbs without any positional/hardcoded privilege. `action` is the
|
|
||||||
/// verb phrase for the message.
|
|
||||||
fn require_group(agent: &str, group: &str, action: &str) -> Option<Response> {
|
|
||||||
match crate::tool_groups::groups_for(agent) {
|
|
||||||
Ok(groups) if groups.iter().any(|g| g == group) => None,
|
|
||||||
Ok(_) => Some(Response::Err {
|
|
||||||
message: format!("agent `{agent}` cannot {action}: requires the `{group}` tool group"),
|
|
||||||
}),
|
|
||||||
Err(e) => Some(Response::Err {
|
|
||||||
message: format!("agent `{agent}` cannot {action}: {e}"),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GetLooseEnds` — always the caller's own loose ends.
|
/// `GetLooseEnds` — always the caller's own loose ends.
|
||||||
fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str) -> Response {
|
fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str) -> Response {
|
||||||
match crate::loose_ends::for_agent(coord, agent) {
|
match crate::loose_ends::for_agent(coord, agent) {
|
||||||
|
|
@ -767,10 +652,9 @@ fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Common Send handler shared between dispatch arms. Applies the
|
/// `Send` handler. Applies the
|
||||||
/// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through
|
/// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through
|
||||||
/// their respective broker calls.
|
/// their respective broker calls.
|
||||||
/// `pub(crate)` so `dispatch_shared` can use it across both socket paths.
|
|
||||||
pub(crate) fn handle_send(
|
pub(crate) fn handle_send(
|
||||||
coord: &Arc<Coordinator>,
|
coord: &Arc<Coordinator>,
|
||||||
agent: &str,
|
agent: &str,
|
||||||
|
|
@ -837,6 +721,21 @@ pub(crate) fn handle_send(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A real `Coordinator` over a throwaway sqlite dir. `serve` takes an
|
||||||
|
/// `Arc<Coordinator>`, so there is no lighter way in; `open` touches
|
||||||
|
/// nothing outside the db path it is handed.
|
||||||
|
fn coordinator() -> (tempfile::TempDir, Arc<Coordinator>) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let coord = Coordinator::open(
|
||||||
|
&dir.path().join("broker.sqlite"),
|
||||||
|
crate::coordinator::HiveEnv::default(),
|
||||||
|
crate::hive_stats::PriceTable::new(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.expect("open coordinator");
|
||||||
|
(dir, Arc::new(coord))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn approval_cancel_rejects_callers_without_the_approvals_group() {
|
fn approval_cancel_rejects_callers_without_the_approvals_group() {
|
||||||
// A caller that doesn't hold the `approvals` tool-group must not be
|
// A caller that doesn't hold the `approvals` tool-group must not be
|
||||||
|
|
@ -869,7 +768,7 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn serve_answers_an_over_long_line_with_an_error_and_closes() {
|
async fn serve_answers_an_over_long_line_with_an_error_and_closes() {
|
||||||
let (_dir, coord) = schedules::tests::coordinator();
|
let (_dir, coord) = coordinator();
|
||||||
let (client, server) = UnixStream::pair().expect("socketpair");
|
let (client, server) = UnixStream::pair().expect("socketpair");
|
||||||
let handler = tokio::spawn(serve(server, "iris".to_owned(), coord));
|
let handler = tokio::spawn(serve(server, "iris".to_owned(), coord));
|
||||||
let (read, mut write) = client.into_split();
|
let (read, mut write) = client.into_split();
|
||||||
|
|
|
||||||
|
|
@ -1,485 +0,0 @@
|
||||||
//! Scheduled-prompt request handlers (`ListSchedules` /
|
|
||||||
//! `RequestSchedulePrompt` / `CancelSchedule` / `EditSchedule` /
|
|
||||||
//! `FireScheduleNow`) and the
|
|
||||||
//! schedule-to-wire mapping reused by the dashboard
|
|
||||||
//! (`schedule_to_wire_public` / `filter_ghost_schedule_targets`).
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use hive_core_agent_sock::Response;
|
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
|
||||||
|
|
||||||
/// `ListSchedules` — snapshot every scheduled prompt. Ownership no longer
|
|
||||||
/// narrows the view: `CancelSchedule`/`EditSchedule`/`FireScheduleNow` accept
|
|
||||||
/// any requester, so filtering the listing would only hide rows the requester
|
|
||||||
/// can act on anyway.
|
|
||||||
pub(super) fn handle_list_schedules(coord: &Arc<Coordinator>, requester: &str) -> Response {
|
|
||||||
tracing::debug!(%requester, "list schedules");
|
|
||||||
match coord.scheduled_prompts.list() {
|
|
||||||
Ok(schedules) => Response::Schedules {
|
|
||||||
schedules: schedules.into_iter().map(schedule_to_wire).collect(),
|
|
||||||
},
|
|
||||||
Err(e) => Response::Err {
|
|
||||||
message: format!("list scheduled prompts: {e:#}"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Submit a `RequestSchedulePrompt` payload as an `ApprovalKind::SchedulePrompt`
|
|
||||||
/// row. Encodes the payload into the approval's `commit_ref` so the
|
|
||||||
/// approve handler can re-parse it without a side table. Validates
|
|
||||||
/// inputs (non-empty targets, non-empty body, sane interval) at
|
|
||||||
/// submit time — the operator should never see a malformed schedule
|
|
||||||
/// pending approval.
|
|
||||||
pub(super) fn handle_request_schedule_prompt(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
requester: &str,
|
|
||||||
payload: &hive_sh4re::manager::SchedulePromptPayload,
|
|
||||||
) -> Response {
|
|
||||||
if payload.targets.is_empty() {
|
|
||||||
return Response::Err {
|
|
||||||
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() {
|
|
||||||
return Response::Err {
|
|
||||||
message: "schedule body must be non-empty".into(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if let Some(0) = payload.interval_seconds {
|
|
||||||
return Response::Err {
|
|
||||||
message: "interval_seconds must be > 0 (use None for one-shot)".into(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let commit_ref = match serde_json::to_string(payload) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
return Response::Err {
|
|
||||||
message: format!("encode SchedulePromptPayload: {e:#}"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let id = match coord.approvals.submit_kind(
|
|
||||||
requester,
|
|
||||||
hive_sh4re::approvals::ApprovalKind::SchedulePrompt,
|
|
||||||
&commit_ref,
|
|
||||||
payload.description.as_deref(),
|
|
||||||
requester,
|
|
||||||
) {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(e) => {
|
|
||||||
return Response::Err {
|
|
||||||
message: format!("queue schedule_prompt approval: {e:#}"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tracing::info!(
|
|
||||||
%id,
|
|
||||||
requester,
|
|
||||||
targets = ?payload.targets,
|
|
||||||
first_fire_at = payload.first_fire_at_unix,
|
|
||||||
interval = ?payload.interval_seconds,
|
|
||||||
"schedule_prompt approval queued"
|
|
||||||
);
|
|
||||||
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
|
||||||
id,
|
|
||||||
agent: requester,
|
|
||||||
approval_kind: "schedule_prompt",
|
|
||||||
description: payload.description.clone(),
|
|
||||||
});
|
|
||||||
Response::Ok
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel a schedule (whole or per-target). Any requester may cancel any
|
|
||||||
/// schedule, whoever owns it.
|
|
||||||
pub(super) fn handle_cancel_schedule(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
requester: &str,
|
|
||||||
schedule_id: i64,
|
|
||||||
targets: Option<&[String]>,
|
|
||||||
) -> Response {
|
|
||||||
// Existence check only — it buys the caller a clean "not found" instead
|
|
||||||
// of whatever the cancel itself would say about an absent row.
|
|
||||||
if let Some(err) = require_schedule(coord, schedule_id) {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
tracing::info!(%requester, %schedule_id, "cancel schedule");
|
|
||||||
let result = match targets {
|
|
||||||
Some(list) if !list.is_empty() => coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.cancel_targets(schedule_id, list)
|
|
||||||
.map_err(|e| format!("cancel targets: {e:#}")),
|
|
||||||
_ => coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.cancel_all(schedule_id)
|
|
||||||
.map_err(|e| format!("cancel all: {e:#}")),
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
coord.emit_schedules_snapshot();
|
|
||||||
Response::Ok
|
|
||||||
}
|
|
||||||
Err(message) => Response::Err { message },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch a `FireScheduleNow` request. Same rule as `CancelSchedule`: any
|
|
||||||
/// requester, any schedule. The actual fan-out lives in
|
|
||||||
/// `scheduled_prompts_worker::fire_now`.
|
|
||||||
pub(super) async fn handle_fire_schedule_now(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
requester: &str,
|
|
||||||
schedule_id: i64,
|
|
||||||
) -> Response {
|
|
||||||
if let Some(err) = require_schedule(coord, schedule_id) {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
tracing::info!(%requester, %schedule_id, "fire schedule now");
|
|
||||||
// MCP fire_schedule_now stays no-reset (cadence intact); the
|
|
||||||
// reset-timer option is a dashboard-dialog affordance.
|
|
||||||
match crate::scheduled_prompts_worker::fire_now(coord, schedule_id, false).await {
|
|
||||||
Ok(_report) => {
|
|
||||||
coord.emit_schedules_snapshot();
|
|
||||||
Response::Ok
|
|
||||||
}
|
|
||||||
Err(e) => Response::Err {
|
|
||||||
message: format!("fire schedule {schedule_id} now: {e:#}"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Field-named PATCH payload for [`handle_edit_schedule`]. Every
|
|
||||||
/// field is "leave alone" when `None`; the double-`Option` fields
|
|
||||||
/// additionally distinguish clear (`Some(None)`) from set
|
|
||||||
/// (`Some(Some(v))`).
|
|
||||||
#[allow(
|
|
||||||
clippy::option_option,
|
|
||||||
reason = "double-Option carries three-state PATCH semantics: outer None = \
|
|
||||||
leave alone, Some(None) = clear, Some(Some(v)) = set"
|
|
||||||
)]
|
|
||||||
pub(super) struct EditSchedulePatch {
|
|
||||||
pub(super) body: Option<String>,
|
|
||||||
pub(super) description: Option<Option<String>>,
|
|
||||||
pub(super) interval_seconds: Option<Option<u64>>,
|
|
||||||
pub(super) next_fire_at_unix: Option<i64>,
|
|
||||||
pub(super) targets_add: Option<Vec<String>>,
|
|
||||||
pub(super) targets_remove: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch an `EditSchedule` patch. Same rule as `CancelSchedule`: any
|
|
||||||
/// requester, any schedule. Forwards the partial payload to
|
|
||||||
/// `ScheduledPrompts::update` which enforces the cancelled-row /
|
|
||||||
/// zero-interval validation. Returns `Ok` on a clean update;
|
|
||||||
/// `Err` with the underlying message on any validation
|
|
||||||
/// failure so the dashboard can surface it verbatim.
|
|
||||||
pub(super) fn handle_edit_schedule(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
requester: &str,
|
|
||||||
schedule_id: i64,
|
|
||||||
patch: EditSchedulePatch,
|
|
||||||
) -> Response {
|
|
||||||
let EditSchedulePatch {
|
|
||||||
body,
|
|
||||||
description,
|
|
||||||
interval_seconds,
|
|
||||||
next_fire_at_unix,
|
|
||||||
targets_add,
|
|
||||||
targets_remove,
|
|
||||||
} = patch;
|
|
||||||
if let Some(err) = require_schedule(coord, schedule_id) {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
tracing::info!(%requester, %schedule_id, "edit schedule");
|
|
||||||
let patch = crate::scheduled_prompts::UpdateSchedule {
|
|
||||||
body,
|
|
||||||
description,
|
|
||||||
interval_seconds,
|
|
||||||
next_fire_at_unix,
|
|
||||||
targets_add,
|
|
||||||
targets_remove,
|
|
||||||
};
|
|
||||||
match coord.scheduled_prompts.update(schedule_id, patch) {
|
|
||||||
Ok(()) => {
|
|
||||||
coord.emit_schedules_snapshot();
|
|
||||||
Response::Ok
|
|
||||||
}
|
|
||||||
Err(e) => Response::Err {
|
|
||||||
message: format!("edit schedule {schedule_id}: {e:#}"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared existence guard for the three schedule-mutating verbs: `None` when
|
|
||||||
/// the row is there, `Some(Err)` to short-circuit when it isn't or the read
|
|
||||||
/// failed. Not a permission check — any requester may act on any schedule.
|
|
||||||
fn require_schedule(coord: &Arc<Coordinator>, schedule_id: i64) -> Option<Response> {
|
|
||||||
match coord.scheduled_prompts.get(schedule_id) {
|
|
||||||
Ok(Some(_)) => None,
|
|
||||||
Ok(None) => Some(Response::Err {
|
|
||||||
message: format!("schedule {schedule_id} not found"),
|
|
||||||
}),
|
|
||||||
Err(e) => Some(Response::Err {
|
|
||||||
message: format!("read schedule {schedule_id}: {e:#}"),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a `scheduled_prompts::Schedule` to its public wire shape.
|
|
||||||
/// Field-by-field copy — the two types are intentionally identical;
|
|
||||||
/// the separation keeps hive-sh4re free of hive-c0re-internal types.
|
|
||||||
/// Public alias `schedule_to_wire_public` re-exports for
|
|
||||||
/// `dashboard.rs::api_schedules` without crossing the module
|
|
||||||
/// boundary into the socket-server file.
|
|
||||||
pub fn schedule_to_wire_public(
|
|
||||||
s: crate::scheduled_prompts::Schedule,
|
|
||||||
) -> hive_sh4re::schedule::WireSchedule {
|
|
||||||
schedule_to_wire(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop schedule targets that point at agents which no longer exist, so
|
|
||||||
/// the dashboard's schedule table doesn't render ghost columns for
|
|
||||||
/// destroyed agents. `live` is the set of logical agent names from the
|
|
||||||
/// last `nixos-container list` scan (stopped agents included, destroyed
|
|
||||||
/// ones absent). Applied only to the dashboard wire paths
|
|
||||||
/// (`api_schedules` + the `SchedulesChanged` SSE emit) — `list_schedules`
|
|
||||||
/// 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
|
|
||||||
/// agent can still see and cancel its own stale targets. This is a view
|
|
||||||
/// filter: the underlying schedule rows keep every target, so a
|
|
||||||
/// re-spawned agent's targets reappear on their own.
|
|
||||||
pub(crate) fn filter_ghost_schedule_targets(
|
|
||||||
schedules: &mut [hive_sh4re::schedule::WireSchedule],
|
|
||||||
live: &std::collections::HashSet<String>,
|
|
||||||
) {
|
|
||||||
for s in schedules.iter_mut() {
|
|
||||||
s.targets.retain(|t| live.contains(&t.target));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::schedule::WireSchedule {
|
|
||||||
hive_sh4re::schedule::WireSchedule {
|
|
||||||
id: s.id,
|
|
||||||
owner: s.owner,
|
|
||||||
body: s.body,
|
|
||||||
interval_seconds: s.interval_seconds,
|
|
||||||
next_fire_at_unix: s.next_fire_at_unix,
|
|
||||||
created_at_unix: s.created_at_unix,
|
|
||||||
source: match s.source {
|
|
||||||
crate::scheduled_prompts::ScheduleSource::Operator => {
|
|
||||||
hive_sh4re::schedule::WireScheduleSource::Operator
|
|
||||||
}
|
|
||||||
crate::scheduled_prompts::ScheduleSource::Approval { id } => {
|
|
||||||
hive_sh4re::schedule::WireScheduleSource::Approval { id }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cancelled_at_unix: s.cancelled_at_unix,
|
|
||||||
paused_at_unix: s.paused_at_unix,
|
|
||||||
description: s.description,
|
|
||||||
targets: s
|
|
||||||
.targets
|
|
||||||
.into_iter()
|
|
||||||
.map(|t| hive_sh4re::schedule::WireScheduleTarget {
|
|
||||||
target: t.target,
|
|
||||||
cancelled_at_unix: t.cancelled_at_unix,
|
|
||||||
last_fired_at_unix: t.last_fired_at_unix,
|
|
||||||
last_result: t.last_result,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(super) mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn target(name: &str) -> hive_sh4re::schedule::WireScheduleTarget {
|
|
||||||
hive_sh4re::schedule::WireScheduleTarget {
|
|
||||||
target: name.to_owned(),
|
|
||||||
cancelled_at_unix: None,
|
|
||||||
last_fired_at_unix: None,
|
|
||||||
last_result: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schedule(targets: &[&str]) -> hive_sh4re::schedule::WireSchedule {
|
|
||||||
hive_sh4re::schedule::WireSchedule {
|
|
||||||
id: 1,
|
|
||||||
owner: "operator".to_owned(),
|
|
||||||
body: "ping".to_owned(),
|
|
||||||
interval_seconds: None,
|
|
||||||
next_fire_at_unix: hive_sh4re::wire_time::from_secs(0),
|
|
||||||
created_at_unix: hive_sh4re::wire_time::from_secs(0),
|
|
||||||
source: hive_sh4re::schedule::WireScheduleSource::Operator,
|
|
||||||
cancelled_at_unix: None,
|
|
||||||
paused_at_unix: None,
|
|
||||||
description: None,
|
|
||||||
targets: targets.iter().map(|t| target(t)).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ghost_filter_drops_dead_agents_keeps_live() {
|
|
||||||
let live: std::collections::HashSet<String> = ["iris".to_owned(), "damocles".to_owned()]
|
|
||||||
.into_iter()
|
|
||||||
.collect();
|
|
||||||
let mut schedules = vec![schedule(&["iris", "ghost", "damocles"])];
|
|
||||||
filter_ghost_schedule_targets(&mut schedules, &live);
|
|
||||||
let kept: Vec<&str> = schedules[0]
|
|
||||||
.targets
|
|
||||||
.iter()
|
|
||||||
.map(|t| t.target.as_str())
|
|
||||||
.collect();
|
|
||||||
// `ghost` (destroyed) dropped; live agents kept.
|
|
||||||
assert_eq!(kept, vec!["iris", "damocles"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ghost_filter_can_empty_targets_when_all_dead() {
|
|
||||||
let live: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
let mut schedules = vec![schedule(&["gone1", "gone2"])];
|
|
||||||
filter_ghost_schedule_targets(&mut schedules, &live);
|
|
||||||
assert!(schedules[0].targets.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// The scheduling surface permits an unrelated requester
|
|
||||||
//
|
|
||||||
// These pin the *decision* the deleted `schedule_authorized` used to make,
|
|
||||||
// inverted: a requester with no relation to a schedule's owner reaches the
|
|
||||||
// handler's effect instead of a refusal. They live at the handler level
|
|
||||||
// because the predicate, its pure `_in` form and all three wrappers are
|
|
||||||
// gone — the handler is the lowest layer where the decision still exists
|
|
||||||
// to be observed.
|
|
||||||
//
|
|
||||||
// `OWNER` and `STRANGER` are names no hive has, so no `topology.json` on
|
|
||||||
// any machine running these tests can relate them. Any ancestry check
|
|
||||||
// reintroduced between the two — disk-reading or pure — therefore answers
|
|
||||||
// "unrelated", and every assertion below flips from the handler's effect
|
|
||||||
// to a refusal.
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Owner of the schedule under test.
|
|
||||||
const OWNER: &str = "schedule-owner-with-no-relations";
|
|
||||||
/// Requester acting on it: not the owner, not the operator, and not
|
|
||||||
/// anybody's ancestor or descendant.
|
|
||||||
const STRANGER: &str = "requester-with-no-relations";
|
|
||||||
|
|
||||||
/// A real `Coordinator` over a throwaway sqlite dir. The socket-server
|
|
||||||
/// handlers take `&Arc<Coordinator>`, so there is no lighter way in;
|
|
||||||
/// `open` touches nothing outside the db path it is handed.
|
|
||||||
pub(crate) fn coordinator() -> (tempfile::TempDir, Arc<Coordinator>) {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let coord = Coordinator::open(
|
|
||||||
&dir.path().join("broker.sqlite"),
|
|
||||||
crate::coordinator::HiveEnv::default(),
|
|
||||||
crate::hive_stats::PriceTable::new(),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
.expect("open coordinator");
|
|
||||||
(dir, Arc::new(coord))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a schedule owned by [`OWNER`], returning its id.
|
|
||||||
fn submit_owned_by_other(coord: &Arc<Coordinator>) -> i64 {
|
|
||||||
coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.submit(&crate::scheduled_prompts::NewSchedule {
|
|
||||||
owner: OWNER.to_owned(),
|
|
||||||
targets: vec!["iris".to_owned()],
|
|
||||||
body: "ping".to_owned(),
|
|
||||||
first_fire_at_unix: 100,
|
|
||||||
interval_seconds: None,
|
|
||||||
description: None,
|
|
||||||
source: crate::scheduled_prompts::ScheduleSource::Operator,
|
|
||||||
})
|
|
||||||
.expect("submit")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cancel_schedule_permits_unrelated_requester() {
|
|
||||||
let (_dir, coord) = coordinator();
|
|
||||||
let id = submit_owned_by_other(&coord);
|
|
||||||
let resp = handle_cancel_schedule(&coord, STRANGER, id, None);
|
|
||||||
assert!(
|
|
||||||
matches!(resp, Response::Ok),
|
|
||||||
"unrelated requester must be allowed to cancel; got {resp:?}"
|
|
||||||
);
|
|
||||||
// Ok alone could be a refusal that forgot to say so — check the row.
|
|
||||||
let row = coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.get(id)
|
|
||||||
.expect("read back")
|
|
||||||
.expect("row present");
|
|
||||||
assert!(
|
|
||||||
row.cancelled_at_unix.is_some(),
|
|
||||||
"cancel must have taken effect, not just answered Ok"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn edit_schedule_permits_unrelated_requester() {
|
|
||||||
let (_dir, coord) = coordinator();
|
|
||||||
let id = submit_owned_by_other(&coord);
|
|
||||||
let resp = handle_edit_schedule(
|
|
||||||
&coord,
|
|
||||||
STRANGER,
|
|
||||||
id,
|
|
||||||
EditSchedulePatch {
|
|
||||||
body: Some("edited by a stranger".to_owned()),
|
|
||||||
description: None,
|
|
||||||
interval_seconds: None,
|
|
||||||
next_fire_at_unix: None,
|
|
||||||
targets_add: None,
|
|
||||||
targets_remove: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
matches!(resp, Response::Ok),
|
|
||||||
"unrelated requester must be allowed to edit; got {resp:?}"
|
|
||||||
);
|
|
||||||
let row = coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.get(id)
|
|
||||||
.expect("read back")
|
|
||||||
.expect("row present");
|
|
||||||
assert_eq!(row.body, "edited by a stranger");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn fire_schedule_now_permits_unrelated_requester() {
|
|
||||||
let (_dir, coord) = coordinator();
|
|
||||||
let id = submit_owned_by_other(&coord);
|
|
||||||
let resp = handle_fire_schedule_now(&coord, STRANGER, id).await;
|
|
||||||
// The fan-out itself is allowed to find no live target (this test has
|
|
||||||
// no containers) — that is reported inside the report, not as an Err.
|
|
||||||
assert!(
|
|
||||||
matches!(resp, Response::Ok),
|
|
||||||
"unrelated requester must be allowed to fire; got {resp:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_schedules_shows_a_schedule_owned_by_someone_else() {
|
|
||||||
let (_dir, coord) = coordinator();
|
|
||||||
let id = submit_owned_by_other(&coord);
|
|
||||||
let Response::Schedules { schedules } = handle_list_schedules(&coord, STRANGER) else {
|
|
||||||
panic!("expected a Schedules response");
|
|
||||||
};
|
|
||||||
// The deleted filter dropped every row the requester was unrelated to,
|
|
||||||
// which for this requester is all of them.
|
|
||||||
assert!(
|
|
||||||
schedules.iter().any(|s| s.id == id && s.owner == OWNER),
|
|
||||||
"unrelated requester must see another agent's schedule; got {schedules:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
//! Approval queue. Requests are submitted by an agent
|
//! Approval queue. The user approves/denies via the host admin CLI; on
|
||||||
//! (`RequestSchedulePrompt`); the user approves/denies via the host admin
|
//! approval the host runs the corresponding action.
|
||||||
//! CLI; on approval the host runs the corresponding action.
|
|
||||||
//!
|
//!
|
||||||
//! `UpdateMetaInputs` rows are legacy: the MCP tool that queued them was
|
//! Nothing submits approvals any more. `UpdateMetaInputs`, the only kind
|
||||||
//! removed and nothing produces the kind any more. The variant and
|
//! left, is legacy: the MCP tool that queued it was removed. The variant
|
||||||
//! its (de)serialization stay so pre-existing rows still read back.
|
//! and its (de)serialization stay so pre-existing rows still read back.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
@ -64,6 +63,13 @@ const MIGRATIONS: &[Migration] = &[
|
||||||
sql: "DELETE FROM approvals WHERE kind = 'merge_config_pr'",
|
sql: "DELETE FROM approvals WHERE kind = 'merge_config_pr'",
|
||||||
adds_column: None,
|
adds_column: None,
|
||||||
},
|
},
|
||||||
|
// v6: drop `schedule_prompt` rows. Scheduled prompts are swarm-level, so
|
||||||
|
// no approval of that kind can be acted on, and `row_to_approval` rejects
|
||||||
|
// the kind.
|
||||||
|
Migration {
|
||||||
|
sql: "DELETE FROM approvals WHERE kind = 'schedule_prompt'",
|
||||||
|
adds_column: None,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub struct Approvals {
|
pub struct Approvals {
|
||||||
|
|
@ -81,7 +87,8 @@ impl Approvals {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a new pending approval row.
|
/// Test fixture: insert a new pending approval row.
|
||||||
|
#[cfg(test)]
|
||||||
pub fn submit_kind(
|
pub fn submit_kind(
|
||||||
&self,
|
&self,
|
||||||
agent: &str,
|
agent: &str,
|
||||||
|
|
@ -354,7 +361,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
|
||||||
let kind: String = row.get(2)?;
|
let kind: String = row.get(2)?;
|
||||||
let kind = match kind.as_str() {
|
let kind = match kind.as_str() {
|
||||||
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
||||||
"schedule_prompt" => ApprovalKind::SchedulePrompt,
|
|
||||||
other => {
|
other => {
|
||||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
2,
|
2,
|
||||||
|
|
@ -404,7 +410,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
|
||||||
fn kind_from_str(s: &str) -> Result<ApprovalKind> {
|
fn kind_from_str(s: &str) -> Result<ApprovalKind> {
|
||||||
Ok(match s {
|
Ok(match s {
|
||||||
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
||||||
"schedule_prompt" => ApprovalKind::SchedulePrompt,
|
|
||||||
other => bail!("unknown approval kind '{other}'"),
|
other => bail!("unknown approval kind '{other}'"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -421,17 +426,6 @@ mod tests {
|
||||||
(dir, path, db)
|
(dir, path, db)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mixed_kinds_all_listed() {
|
|
||||||
let (_dir, _path, db) = open_temp();
|
|
||||||
db.submit_kind("b", ApprovalKind::SchedulePrompt, "", None, "b")
|
|
||||||
.unwrap();
|
|
||||||
db.submit_kind("c", ApprovalKind::UpdateMetaInputs, "[]", None, "c")
|
|
||||||
.unwrap();
|
|
||||||
let pending = db.pending().expect("pending");
|
|
||||||
assert_eq!(pending.len(), 2, "both kinds must be visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mark_cancelled_transitions_pending_row() {
|
fn mark_cancelled_transitions_pending_row() {
|
||||||
// Manager withdraws a pending approval. Row leaves pending(),
|
// Manager withdraws a pending approval. Row leaves pending(),
|
||||||
|
|
@ -441,8 +435,8 @@ mod tests {
|
||||||
let id = db
|
let id = db
|
||||||
.submit_kind(
|
.submit_kind(
|
||||||
"bitburner",
|
"bitburner",
|
||||||
ApprovalKind::SchedulePrompt,
|
ApprovalKind::UpdateMetaInputs,
|
||||||
"{}",
|
"[]",
|
||||||
Some("test"),
|
Some("test"),
|
||||||
"bitburner",
|
"bitburner",
|
||||||
)
|
)
|
||||||
|
|
@ -464,7 +458,7 @@ mod tests {
|
||||||
// final — re-cancelling errors instead of silently overwriting.
|
// final — re-cancelling errors instead of silently overwriting.
|
||||||
let (_dir, _path, db) = open_temp();
|
let (_dir, _path, db) = open_temp();
|
||||||
let id = db
|
let id = db
|
||||||
.submit_kind("a", ApprovalKind::SchedulePrompt, "{}", None, "a")
|
.submit_kind("a", ApprovalKind::UpdateMetaInputs, "[]", None, "a")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
db.mark_cancelled(id, "manager").expect("first cancel");
|
db.mark_cancelled(id, "manager").expect("first cancel");
|
||||||
let err = db
|
let err = db
|
||||||
|
|
@ -479,7 +473,7 @@ mod tests {
|
||||||
// whole list — collect_lenient skips it instead of failing.
|
// whole list — collect_lenient skips it instead of failing.
|
||||||
let (_dir, path, db) = open_temp();
|
let (_dir, path, db) = open_temp();
|
||||||
let good = db
|
let good = db
|
||||||
.submit_kind("good", ApprovalKind::SchedulePrompt, "{}", None, "good")
|
.submit_kind("good", ApprovalKind::UpdateMetaInputs, "[]", None, "good")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let raw = Connection::open(&path).unwrap();
|
let raw = Connection::open(&path).unwrap();
|
||||||
raw.execute(
|
raw.execute(
|
||||||
|
|
@ -502,14 +496,20 @@ mod tests {
|
||||||
// fall back to the root agent.
|
// fall back to the root agent.
|
||||||
let (_dir, path, db) = open_temp();
|
let (_dir, path, db) = open_temp();
|
||||||
let id = db
|
let id = db
|
||||||
.submit_kind("child", ApprovalKind::SchedulePrompt, "{}", None, "parent")
|
.submit_kind(
|
||||||
|
"child",
|
||||||
|
ApprovalKind::UpdateMetaInputs,
|
||||||
|
"[]",
|
||||||
|
None,
|
||||||
|
"parent",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(db.submitter_of(id).unwrap().as_deref(), Some("parent"));
|
assert_eq!(db.submitter_of(id).unwrap().as_deref(), Some("parent"));
|
||||||
|
|
||||||
let raw = Connection::open(&path).unwrap();
|
let raw = Connection::open(&path).unwrap();
|
||||||
raw.execute(
|
raw.execute(
|
||||||
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
|
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
|
||||||
VALUES ('old', 'schedule_prompt', '', 0, 'pending')",
|
VALUES ('old', 'update_meta_inputs', '', 0, 'pending')",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -524,7 +524,7 @@ mod tests {
|
||||||
fn a_pre_v5_merge_config_pr_row_is_dropped_on_open() {
|
fn a_pre_v5_merge_config_pr_row_is_dropped_on_open() {
|
||||||
let (_dir, path, db) = open_temp();
|
let (_dir, path, db) = open_temp();
|
||||||
let kept = db
|
let kept = db
|
||||||
.submit_kind("iris", ApprovalKind::SchedulePrompt, "{}", None, "iris")
|
.submit_kind("iris", ApprovalKind::UpdateMetaInputs, "[]", None, "iris")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(db);
|
drop(db);
|
||||||
let raw = Connection::open(&path).unwrap();
|
let raw = Connection::open(&path).unwrap();
|
||||||
|
|
@ -546,4 +546,30 @@ mod tests {
|
||||||
assert!(db.recent_resolved(10).unwrap().is_empty());
|
assert!(db.recent_resolved(10).unwrap().is_empty());
|
||||||
assert!(db.get(old).unwrap().is_none());
|
assert!(db.get(old).unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A database written before v6 still holds `schedule_prompt` rows.
|
||||||
|
/// Opening it drops them and keeps every other row.
|
||||||
|
#[test]
|
||||||
|
fn a_pre_v6_schedule_prompt_row_is_dropped_on_open() {
|
||||||
|
let (_dir, path, db) = open_temp();
|
||||||
|
let kept = db
|
||||||
|
.submit_kind("iris", ApprovalKind::UpdateMetaInputs, "[]", None, "iris")
|
||||||
|
.unwrap();
|
||||||
|
drop(db);
|
||||||
|
let raw = Connection::open(&path).unwrap();
|
||||||
|
raw.execute_batch(
|
||||||
|
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
|
||||||
|
VALUES ('iris', 'schedule_prompt', '{}', 0, 'pending');
|
||||||
|
UPDATE schema_versions SET version = 5 WHERE store = 'approvals';",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let old: i64 = raw.last_insert_rowid();
|
||||||
|
drop(raw);
|
||||||
|
|
||||||
|
let db = Approvals::open(&path).expect("a pre-v6 db opens");
|
||||||
|
let pending = db.pending().expect("pending");
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(pending[0].id, kept);
|
||||||
|
assert!(db.get(old).unwrap().is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//! Sqlite-backed host-side stores (broker, approval / schedule queues,
|
//! Sqlite-backed host-side stores (broker, approval queue, read-only
|
||||||
//! build logs, power intent) plus the shared connection open/migration
|
//! scheduled prompts, build logs, power intent) plus the shared connection open/migration
|
||||||
//! helper (`db`). Each submodule is re-exported at the crate root, so
|
//! helper (`db`). Each submodule is re-exported at the crate root, so
|
||||||
//! `crate::broker::…` etc. keep working unchanged.
|
//! `crate::broker::…` etc. keep working unchanged.
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,5 @@
|
||||||
//! Background tasks and periodic sweeps: crash/login watcher, the
|
//! Background tasks and periodic sweeps: crash/login watcher, boot-time
|
||||||
//! scheduled-prompt delivery loop, boot-time auto-update
|
//! auto-update reconcile, the agent-sockets.json writer loop, the MCP socket listener
|
||||||
//! reconcile, the agent-sockets.json writer loop, the MCP socket listener
|
|
||||||
//! reconcile loop, knowledge-repo sync, and the swarm wanted-state pull.
|
//! reconcile loop, knowledge-repo sync, and the swarm wanted-state pull.
|
||||||
//! Each submodule is re-exported
|
//! Each submodule is re-exported
|
||||||
//! at the crate root, so `crate::crash_watch::…` etc. keep working unchanged.
|
//! at the crate root, so `crate::crash_watch::…` etc. keep working unchanged.
|
||||||
|
|
@ -10,5 +9,4 @@ pub mod auto_update;
|
||||||
pub mod crash_watch;
|
pub mod crash_watch;
|
||||||
pub mod knowledge;
|
pub mod knowledge;
|
||||||
pub mod mcp_sockets;
|
pub mod mcp_sockets;
|
||||||
pub mod scheduled_prompts_worker;
|
|
||||||
pub mod wanted;
|
pub mod wanted;
|
||||||
|
|
|
||||||
|
|
@ -1,386 +0,0 @@
|
||||||
//! Background loop that drains due `scheduled_prompts` rows and fans
|
|
||||||
//! the body to each active target. 5s poll cadence, shutdown-aware.
|
|
||||||
//! Catch-up clamp, missing-target handling, and broker-error retry
|
|
||||||
//! semantics: `docs/agent-lifecycle/approvals.md::Scheduled prompt worker`.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
use hive_sh4re::inbox::Message;
|
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
|
||||||
use crate::scheduled_prompts::Schedule;
|
|
||||||
|
|
||||||
/// Per-tick cap. Each schedule fires once per tick at most;
|
|
||||||
/// 100/tick × 5s tick = sustained throughput cap of ~20/sec,
|
|
||||||
/// matching `reminder_scheduler::REMINDER_BATCH_LIMIT`. Bump
|
|
||||||
/// together if real-world rates push past this.
|
|
||||||
const SCHEDULE_BATCH_LIMIT: u64 = 100;
|
|
||||||
|
|
||||||
/// Poll interval. Same 5s as the reminder scheduler — picking
|
|
||||||
/// up freshly-due rows within at most one tick keeps the
|
|
||||||
/// dashboard's "next fire in ..." countdown honest without
|
|
||||||
/// burning CPU on empty sweeps.
|
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
|
||||||
|
|
||||||
/// Reap cancelled schedules older than this from the table so
|
|
||||||
/// the dashboard list view doesn't accrue tombstones forever.
|
|
||||||
/// Cancelled rows live long enough that the operator can still
|
|
||||||
/// see what they cancelled in the recent past.
|
|
||||||
const CANCELLED_REAP_AGE: Duration = Duration::from_hours(1);
|
|
||||||
|
|
||||||
pub fn spawn(coord: Arc<Coordinator>) {
|
|
||||||
let mut shutdown = coord.shutdown_rx();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
tick(&coord).await;
|
|
||||||
tokio::select! {
|
|
||||||
() = tokio::time::sleep(POLL_INTERVAL) => {}
|
|
||||||
_ = shutdown.changed() => {
|
|
||||||
tracing::info!("scheduled_prompts worker: shutdown signal received");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn tick(coord: &Arc<Coordinator>) {
|
|
||||||
let now = Utc::now().timestamp();
|
|
||||||
let due = match coord.scheduled_prompts.due(now, SCHEDULE_BATCH_LIMIT) {
|
|
||||||
Ok(rows) => rows,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = ?e, "scheduled_prompts: query due rows failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if due.is_empty() {
|
|
||||||
// Periodic reaper still gets a chance even on empty ticks.
|
|
||||||
let cutoff = now - i64::try_from(CANCELLED_REAP_AGE.as_secs()).unwrap_or(0);
|
|
||||||
if let Err(e) = coord.scheduled_prompts.reap_cancelled(cutoff) {
|
|
||||||
tracing::warn!(error = ?e, "scheduled_prompts: reap cancelled failed");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for schedule in due {
|
|
||||||
fire_schedule(coord, &schedule, now).await;
|
|
||||||
}
|
|
||||||
let cutoff = now - i64::try_from(CANCELLED_REAP_AGE.as_secs()).unwrap_or(0);
|
|
||||||
if let Err(e) = coord.scheduled_prompts.reap_cancelled(cutoff) {
|
|
||||||
tracing::warn!(error = ?e, "scheduled_prompts: reap cancelled failed");
|
|
||||||
}
|
|
||||||
// Emit after all fires + reaps so the dashboard reflects updated
|
|
||||||
// last_fired_at_unix, next_fire_at_unix, and any reaped one-shots.
|
|
||||||
coord.emit_schedules_snapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fan out one schedule's body to every active target. Records
|
|
||||||
/// per-target `last_result`; advances or reaps the parent row at
|
|
||||||
/// the end depending on whether `interval_seconds` is set.
|
|
||||||
///
|
|
||||||
/// Delivery is `push_todo`, not a broker `Message` — a scheduled
|
|
||||||
/// prompt now wakes its target with a todo instead of driving an
|
|
||||||
/// immediate turn, by design: mara confirmed on the tracking issue
|
|
||||||
/// that this is the intended behavior, not an incidental side effect.
|
|
||||||
/// `key = "schedule:{id}"` per target gives `push_todo`'s own
|
|
||||||
/// upsert-by-key dedup the same job a now-removed
|
|
||||||
/// `has_pending_with_body` broker check used to do — collapsing a
|
|
||||||
/// re-fire of the *same schedule* against a target that hasn't
|
|
||||||
/// reviewed the last one yet, keyed on schedule identity rather than on
|
|
||||||
/// the body happening to be byte-identical. `deliver_to_target` also
|
|
||||||
/// passes `reopen_if_acked = true`, so this collapse only applies while
|
|
||||||
/// the previous fire is genuinely still sitting there un-reviewed — once
|
|
||||||
/// the target acks it, the *next* fire reopens the row and wakes again
|
|
||||||
/// even with an identical body, instead of silently staying quiet
|
|
||||||
/// forever — a recurring schedule with a static body used to wake its
|
|
||||||
/// target once, ever, and then look dead.
|
|
||||||
async fn fire_schedule(coord: &Arc<Coordinator>, schedule: &Schedule, now: i64) {
|
|
||||||
let known: std::collections::HashSet<String> = known_agents().await;
|
|
||||||
for target_row in &schedule.targets {
|
|
||||||
if target_row.cancelled_at_unix.is_some() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let target = &target_row.target;
|
|
||||||
if !known.contains(target) {
|
|
||||||
let reason = format!("no such agent: {target}");
|
|
||||||
if let Err(e) =
|
|
||||||
coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.record_target_result(schedule.id, target, now, &reason)
|
|
||||||
{
|
|
||||||
tracing::warn!(error = ?e, schedule = schedule.id, %target, "record_target_result failed");
|
|
||||||
}
|
|
||||||
notify_operator_missing_target(coord, schedule, target);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let result_str = match deliver_to_target(coord, schedule.id, target, &schedule.body).await {
|
|
||||||
Ok(()) => "ok".to_owned(),
|
|
||||||
Err(reason) => {
|
|
||||||
tracing::warn!(
|
|
||||||
schedule = schedule.id,
|
|
||||||
%target,
|
|
||||||
%reason,
|
|
||||||
"scheduled_prompts: delivery failed (will retry on next interval)"
|
|
||||||
);
|
|
||||||
reason
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) =
|
|
||||||
coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.record_target_result(schedule.id, target, now, &result_str)
|
|
||||||
{
|
|
||||||
tracing::warn!(error = ?e, schedule = schedule.id, %target, "record_target_result failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Advance or reap. One-shots delete; recurring re-arm with
|
|
||||||
// catch-up clamp.
|
|
||||||
if schedule.interval_seconds.is_some() {
|
|
||||||
match coord.scheduled_prompts.rearm(schedule.id, now) {
|
|
||||||
Ok(0) => {}
|
|
||||||
Ok(skipped) => {
|
|
||||||
tracing::info!(
|
|
||||||
schedule = schedule.id,
|
|
||||||
skipped,
|
|
||||||
"scheduled_prompts: caught up missed cycles"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = ?e, schedule = schedule.id, "rearm failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Err(e) = coord.scheduled_prompts.delete(schedule.id) {
|
|
||||||
tracing::warn!(error = ?e, schedule = schedule.id, "delete one-shot failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deliver `body` to a single already-known-live `target` via
|
|
||||||
/// `push_todo`, keyed on the schedule's own identity
|
|
||||||
/// (`schedule:{schedule_id}`) so a re-fire against a target that hasn't
|
|
||||||
/// reviewed the last one collapses via `push_todo`'s own upsert-by-key
|
|
||||||
/// dedup — but `reopen_if_acked = true` means that collapse only holds
|
|
||||||
/// while unreviewed; once acked, the next fire reopens regardless of
|
|
||||||
/// whether the body changed. Shared by the periodic `fire_schedule`
|
|
||||||
/// tick and the manual `fire_now` dashboard action — the only
|
|
||||||
/// difference between them is what each caller does with the `Result`
|
|
||||||
/// (log/prefix and per-target `last_result`/`FireNowReport`
|
|
||||||
/// bookkeeping), not the delivery choice itself.
|
|
||||||
async fn deliver_to_target(
|
|
||||||
coord: &Arc<Coordinator>,
|
|
||||||
schedule_id: i64,
|
|
||||||
target: &str,
|
|
||||||
body: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
coord
|
|
||||||
.push_todo(
|
|
||||||
target,
|
|
||||||
"schedule",
|
|
||||||
Some(format!("schedule:{schedule_id}")),
|
|
||||||
body.to_owned(),
|
|
||||||
Some("scheduled".to_owned()),
|
|
||||||
// Each fire is a distinct occurrence, not a restatement of
|
|
||||||
// a persisting condition — an agent that already acked the
|
|
||||||
// *previous* firing hasn't acked *this* one, so an acked
|
|
||||||
// row must reopen even when the body is byte-identical
|
|
||||||
// (the common case: most schedules don't vary their text
|
|
||||||
// per fire). See `Todos::upsert`'s doc comment for the
|
|
||||||
// full reconciler-vs-event rationale.
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send the operator a one-line advisory when a schedule fires
|
|
||||||
/// against an agent that no longer exists. Best-effort — failure
|
|
||||||
/// to send just gets logged; the schedule continues firing.
|
|
||||||
fn notify_operator_missing_target(coord: &Coordinator, schedule: &Schedule, target: &str) {
|
|
||||||
let body = format!(
|
|
||||||
"scheduled prompt #{id} fired but target `{target}` is not a live agent. \
|
|
||||||
body was:\n\n{body}",
|
|
||||||
id = schedule.id,
|
|
||||||
target = target,
|
|
||||||
body = schedule.body
|
|
||||||
);
|
|
||||||
let msg = Message {
|
|
||||||
from: hive_sh4re::manager::trusted_sender("scheduled"),
|
|
||||||
to: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(),
|
|
||||||
body,
|
|
||||||
in_reply_to: None,
|
|
||||||
};
|
|
||||||
if let Err(e) = coord.broker.send(&msg) {
|
|
||||||
tracing::warn!(error = ?e, schedule = schedule.id, %target, "operator advisory send failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-target outcome counts for one `fire_now` invocation.
|
|
||||||
/// Returned to the operator so the dashboard can render
|
|
||||||
/// "fired to N (M failed, K missing)" without a follow-up GET.
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
|
|
||||||
pub struct FireNowReport {
|
|
||||||
/// Targets the broker accepted the message for.
|
|
||||||
pub ok: u32,
|
|
||||||
/// Targets where broker.send returned an error.
|
|
||||||
pub failed: u32,
|
|
||||||
/// Targets that didn't resolve to a known agent (and got the
|
|
||||||
/// operator-advisory treatment).
|
|
||||||
pub missing: u32,
|
|
||||||
/// Whether the one-shot was consumed by this manual fire.
|
|
||||||
/// `true` only when the schedule was a one-shot (recurring
|
|
||||||
/// schedules never auto-cancel on manual fire — they keep
|
|
||||||
/// their cadence).
|
|
||||||
pub one_shot_consumed: bool,
|
|
||||||
/// Whether this manual fire re-armed a recurring schedule's timer
|
|
||||||
/// (`next_fire_at = now + interval`). `true` only when the caller
|
|
||||||
/// passed `reset_timer` AND the schedule is recurring.
|
|
||||||
pub timer_reset: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manual / out-of-band fire of a scheduled prompt ("fire now"
|
|
||||||
/// dashboard button). Mirrors the per-target fan-out of `fire_schedule`
|
|
||||||
/// but skips the rearm step entirely — manual fires don't disturb
|
|
||||||
/// a recurring schedule's rhythm. For one-shots, a manual fire
|
|
||||||
/// **consumes** the schedule (operator intent: "send this now,
|
|
||||||
/// the scheduled time was wrong"); recurring schedules keep their
|
|
||||||
/// `next_fire_at_unix` unchanged.
|
|
||||||
///
|
|
||||||
/// `last_result` is annotated with the `manual fire:` prefix so
|
|
||||||
/// the dashboard's per-target last-result column can distinguish
|
|
||||||
/// scheduled fires from operator-initiated ones at a glance.
|
|
||||||
///
|
|
||||||
/// `reset_timer` re-arms a *recurring* schedule's countdown from now
|
|
||||||
/// (`next_fire_at = now + interval`) after the fan-out — the dashboard
|
|
||||||
/// fire-now dialog's "reset timer" checkbox. It's a no-op for one-shots
|
|
||||||
/// (still consumed) and when `false` (today's default: cadence intact).
|
|
||||||
///
|
|
||||||
/// Returns Err if the schedule is missing, cancelled, or fully
|
|
||||||
/// drained of active targets — the dashboard can surface those
|
|
||||||
/// as plain 4xxs instead of pretending to fire a phantom row.
|
|
||||||
pub async fn fire_now(
|
|
||||||
coord: &std::sync::Arc<Coordinator>,
|
|
||||||
schedule_id: i64,
|
|
||||||
reset_timer: bool,
|
|
||||||
) -> anyhow::Result<FireNowReport> {
|
|
||||||
let now = Utc::now().timestamp();
|
|
||||||
let schedule = coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.get(schedule_id)?
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("schedule {schedule_id} not found"))?;
|
|
||||||
if schedule.cancelled_at_unix.is_some() {
|
|
||||||
anyhow::bail!("schedule {schedule_id} is already cancelled");
|
|
||||||
}
|
|
||||||
if !schedule
|
|
||||||
.targets
|
|
||||||
.iter()
|
|
||||||
.any(|t| t.cancelled_at_unix.is_none())
|
|
||||||
{
|
|
||||||
anyhow::bail!("schedule {schedule_id} has no active targets");
|
|
||||||
}
|
|
||||||
let known = known_agents().await;
|
|
||||||
let mut report = FireNowReport {
|
|
||||||
ok: 0,
|
|
||||||
failed: 0,
|
|
||||||
missing: 0,
|
|
||||||
one_shot_consumed: false,
|
|
||||||
timer_reset: false,
|
|
||||||
};
|
|
||||||
for target_row in &schedule.targets {
|
|
||||||
if target_row.cancelled_at_unix.is_some() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let target = &target_row.target;
|
|
||||||
if !known.contains(target) {
|
|
||||||
let reason = format!("manual fire: no such agent: {target}");
|
|
||||||
if let Err(e) =
|
|
||||||
coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.record_target_result(schedule_id, target, now, &reason)
|
|
||||||
{
|
|
||||||
tracing::warn!(error = ?e, schedule = schedule_id, %target, "record_target_result failed");
|
|
||||||
}
|
|
||||||
notify_operator_missing_target(coord, &schedule, target);
|
|
||||||
report.missing += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let result_str = match deliver_to_target(coord, schedule_id, target, &schedule.body).await {
|
|
||||||
Ok(()) => {
|
|
||||||
report.ok += 1;
|
|
||||||
"manual fire: ok".to_owned()
|
|
||||||
}
|
|
||||||
Err(reason) => {
|
|
||||||
report.failed += 1;
|
|
||||||
tracing::warn!(
|
|
||||||
schedule = schedule_id,
|
|
||||||
%target,
|
|
||||||
%reason,
|
|
||||||
"fire_now: delivery failed (no retry — manual fires don't loop)"
|
|
||||||
);
|
|
||||||
format!("manual fire: {reason}")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) =
|
|
||||||
coord
|
|
||||||
.scheduled_prompts
|
|
||||||
.record_target_result(schedule_id, target, now, &result_str)
|
|
||||||
{
|
|
||||||
tracing::warn!(error = ?e, schedule = schedule_id, %target, "record_target_result failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match schedule.interval_seconds {
|
|
||||||
None => {
|
|
||||||
// One-shot is consumed by the manual fire. (reset_timer is
|
|
||||||
// moot here — there's no recurring cadence to re-arm.)
|
|
||||||
if let Err(e) = coord.scheduled_prompts.cancel_all(schedule_id) {
|
|
||||||
tracing::warn!(error = ?e, schedule = schedule_id, "cancel_all after one-shot manual fire failed");
|
|
||||||
} else {
|
|
||||||
report.one_shot_consumed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(interval) if reset_timer => {
|
|
||||||
// Recurring + operator asked to reset: re-arm the countdown
|
|
||||||
// from now (now + interval), not along the existing cadence.
|
|
||||||
let next = now.saturating_add(i64::try_from(interval).unwrap_or(i64::MAX));
|
|
||||||
if let Err(e) = coord.scheduled_prompts.set_next_fire(schedule_id, next) {
|
|
||||||
tracing::warn!(error = ?e, schedule = schedule_id, "set_next_fire after manual fire reset failed");
|
|
||||||
} else {
|
|
||||||
report.timer_reset = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Recurring without reset: cadence stays intact (additive fire).
|
|
||||||
Some(_) => {}
|
|
||||||
}
|
|
||||||
Ok(report)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot of live container names for the missing-target check.
|
|
||||||
/// Always seeds the manager name (which is always reachable);
|
|
||||||
/// adds every live nspawn container that matches the `h-` prefix.
|
|
||||||
/// On `lifecycle::list` failure the set stays at just the manager
|
|
||||||
/// — fail-CLOSED, meaning every non-manager target looks missing
|
|
||||||
/// this tick and gets the same treatment as a genuinely-destroyed
|
|
||||||
/// agent: operator advisory + per-target `last_result` annotation +
|
|
||||||
/// skipped delivery. Recurring
|
|
||||||
/// schedules recover automatically on the next tick (the lifecycle
|
|
||||||
/// listing usually works); one-shots that land on this window
|
|
||||||
/// lose their single delivery. Logged at `warn`, not propagated.
|
|
||||||
/// Shared by `fire_schedule` and `fire_now` — both are async now
|
|
||||||
/// (the sync `fire_schedule` used to need a `block_in_place` variant
|
|
||||||
/// of this before it started `push_todo`ing, which is itself async).
|
|
||||||
async fn known_agents() -> std::collections::HashSet<String> {
|
|
||||||
use std::collections::HashSet;
|
|
||||||
let mut out: HashSet<String> = HashSet::new();
|
|
||||||
out.insert(hive_sh4re::manager::MANAGER_AGENT.to_owned());
|
|
||||||
match crate::lifecycle::list().await {
|
|
||||||
Ok(list) => {
|
|
||||||
for raw in list {
|
|
||||||
if let Some(name) = raw.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
|
|
||||||
out.insert(name.to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = ?e, "fire_now: container listing failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
@ -9,8 +9,6 @@
|
||||||
|
|
||||||
use hive_sh4re::container::MatrixIdentity;
|
use hive_sh4re::container::MatrixIdentity;
|
||||||
use hive_sh4re::inbox::{CancelLooseEndKind, DeliveredMessage, InboxRow, LooseEnd};
|
use hive_sh4re::inbox::{CancelLooseEndKind, DeliveredMessage, InboxRow, LooseEnd};
|
||||||
use hive_sh4re::manager::SchedulePromptPayload;
|
|
||||||
use hive_sh4re::schedule::WireSchedule;
|
|
||||||
use hive_types::Ident;
|
use hive_types::Ident;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -20,8 +18,7 @@ fn default_true() -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unified request enum for both agent and manager sockets. The agent's
|
/// Unified request enum for both agent and manager sockets. The agent's
|
||||||
/// identity is the socket it arrived on. Privileged variants are marked
|
/// identity is the socket it arrived on.
|
||||||
/// `*(privileged)*` — an agent socket returns `Err` for them server-side.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "cmd", rename_all = "snake_case")]
|
#[serde(tag = "cmd", rename_all = "snake_case")]
|
||||||
pub enum Request {
|
pub enum Request {
|
||||||
|
|
@ -111,40 +108,9 @@ pub enum Request {
|
||||||
/// DAG's drain node resolve immediately instead of waiting out its
|
/// DAG's drain node resolve immediately instead of waiting out its
|
||||||
/// timeout fallback — same shape as `GracefulStopComplete`.
|
/// timeout fallback — same shape as `GracefulStopComplete`.
|
||||||
PauseAcknowledged,
|
PauseAcknowledged,
|
||||||
|
|
||||||
// ---- privileged (manager socket only for now) ---------------------------
|
|
||||||
/// *(privileged)* Queue an approval to add a scheduled prompt.
|
|
||||||
RequestSchedulePrompt(SchedulePromptPayload),
|
|
||||||
/// *(privileged)* Cancel a scheduled prompt.
|
|
||||||
CancelSchedule {
|
|
||||||
id: i64,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
targets: Option<Vec<String>>,
|
|
||||||
},
|
|
||||||
/// List every schedule in the hive, whoever owns it.
|
|
||||||
ListSchedules,
|
|
||||||
/// *(privileged)* Fire a scheduled prompt out of band immediately.
|
|
||||||
FireScheduleNow { id: i64 },
|
|
||||||
/// *(privileged)* Edit an existing schedule's mutable fields.
|
|
||||||
EditSchedule {
|
|
||||||
id: i64,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
body: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
description: Option<Option<String>>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
interval_seconds: Option<Option<u64>>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
next_fire_at_unix: Option<i64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
targets_add: Option<Vec<String>>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
targets_remove: Option<Vec<String>>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unified response enum for both agent and manager sockets. Privileged
|
/// Unified response enum for both agent and manager sockets.
|
||||||
/// variants (`Schedules`) are never returned on agent sockets.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum Response {
|
pub enum Response {
|
||||||
|
|
@ -211,10 +177,6 @@ pub enum Response {
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
matrix_accounts: Vec<MatrixIdentity>,
|
matrix_accounts: Vec<MatrixIdentity>,
|
||||||
},
|
},
|
||||||
/// `ListSchedules` result. Snapshot of the schedules the requester is
|
|
||||||
/// authorized to see — see `ListSchedules`'s own doc comment.
|
|
||||||
/// Returned on the manager socket only.
|
|
||||||
Schedules { schedules: Vec<WireSchedule> },
|
|
||||||
/// `Recv` result when a graceful stop is pending for this agent
|
/// `Recv` result when a graceful stop is pending for this agent
|
||||||
/// (set by hive-c0re's `GracefulStop` orchestration). Returned in
|
/// (set by hive-c0re's `GracefulStop` orchestration). Returned in
|
||||||
/// place of `Messages` — it doubles as the inbound fence: the harness
|
/// place of `Messages` — it doubles as the inbound fence: the harness
|
||||||
|
|
|
||||||
|
|
@ -76,20 +76,6 @@ pub fn agent_web_socket(name: &Ident) -> PathBuf {
|
||||||
.join("web.sock")
|
.join("web.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-agent in-container todo socket path —
|
|
||||||
/// `AGENT_SOCKET_DIR/<name>/agent.sock`. Same socket the harness's
|
|
||||||
/// `HIVE_AGENT_SOCKET` binds and in-container producers (matrix/bash/
|
|
||||||
/// forge-notify) already dial — `hive-c0re` uses this to push a todo
|
|
||||||
/// directly into a *running* agent's local store (see
|
|
||||||
/// `Coordinator::push_todo`); the container being down (socket absent)
|
|
||||||
/// means the push is simply skipped, not queued.
|
|
||||||
#[must_use]
|
|
||||||
pub fn agent_todo_socket(name: &Ident) -> PathBuf {
|
|
||||||
PathBuf::from(AGENT_SOCKET_DIR)
|
|
||||||
.join(name.as_str())
|
|
||||||
.join("agent.sock")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// nspawn machine-name prefix for agent containers (`h-<name>`). A single
|
/// nspawn machine-name prefix for agent containers (`h-<name>`). A single
|
||||||
/// `starts_with(AGENT_PREFIX)` filter enumerates managed containers.
|
/// `starts_with(AGENT_PREFIX)` filter enumerates managed containers.
|
||||||
pub const AGENT_PREFIX: &str = "h-";
|
pub const AGENT_PREFIX: &str = "h-";
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ pub struct Approval {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub agent: Ident,
|
pub agent: Ident,
|
||||||
pub kind: ApprovalKind,
|
pub kind: ApprovalKind,
|
||||||
/// Kind-specific payload (git sha / inputs array / schedule
|
/// Kind-specific payload (git sha / inputs array / empty). See the
|
||||||
/// payload / empty). See the Approval struct doc.
|
/// Approval struct doc.
|
||||||
pub commit_ref: String,
|
pub commit_ref: String,
|
||||||
pub requested_at: DateTime<Utc>,
|
pub requested_at: DateTime<Utc>,
|
||||||
pub status: ApprovalStatus,
|
pub status: ApprovalStatus,
|
||||||
|
|
@ -40,8 +40,6 @@ pub enum ApprovalKind {
|
||||||
/// Run `nix flake update [inputs...]` on the meta flake and commit
|
/// Run `nix flake update [inputs...]` on the meta flake and commit
|
||||||
/// the resulting lock changes.
|
/// the resulting lock changes.
|
||||||
UpdateMetaInputs,
|
UpdateMetaInputs,
|
||||||
/// Add a scheduled prompt to the broker queue.
|
|
||||||
SchedulePrompt,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
//! Manager socket — `/run/hyperhive/manager/mcp.sock` on the host,
|
//! Manager socket — `/run/hyperhive/manager/mcp.sock` on the host,
|
||||||
//! bind-mounted into the manager container at `/run/hive/mcp.sock`.
|
//! bind-mounted into the manager container at `/run/hive/mcp.sock`.
|
||||||
//! Reserved recipient names/senders, the out-of-band `HelperEvent`
|
//! Reserved recipient names/senders and the out-of-band `HelperEvent`
|
||||||
//! payload hive-c0re pushes into the manager's inbox, and the
|
//! payload hive-c0re pushes into the manager's inbox.
|
||||||
//! schedule-prompt submission payload.
|
|
||||||
|
|
||||||
use hive_types::Ident;
|
use hive_types::Ident;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -66,30 +65,6 @@ pub enum HelperEvent {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submission payload for `RequestSchedulePrompt`. Lives outside the
|
|
||||||
/// enum so it can also serialize into the approval row's `commit_ref`
|
|
||||||
/// (the dispatcher re-parses it on approve and inserts the schedule).
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct SchedulePromptPayload {
|
|
||||||
/// Names of recipient agents. Operator + `root` allowed.
|
|
||||||
pub targets: Vec<String>,
|
|
||||||
/// Message body delivered to each target's inbox at fire time.
|
|
||||||
/// Same size budget as `Send.body` — soft cap at the broker level.
|
|
||||||
pub body: String,
|
|
||||||
/// Absolute unix timestamp (seconds) for the FIRST fire. For
|
|
||||||
/// recurring schedules the worker then re-arms in
|
|
||||||
/// `interval_seconds` steps.
|
|
||||||
pub first_fire_at_unix: i64,
|
|
||||||
/// `None` = one-shot. `Some(n > 0)` = recurring every `n` seconds.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub interval_seconds: Option<u64>,
|
|
||||||
/// Optional description shown on the dashboard approval card AND
|
|
||||||
/// stored on the resulting schedule row for the operator's
|
|
||||||
/// "what is this?" reference later.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub description: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod reserved_name_tests {
|
mod reserved_name_tests {
|
||||||
use super::{MANAGER_AGENT, OPERATOR_RECIPIENT, SYSTEM_SENDER};
|
use super::{MANAGER_AGENT, OPERATOR_RECIPIENT, SYSTEM_SENDER};
|
||||||
|
|
|
||||||
|
|
@ -162,9 +162,6 @@ pub enum ToolGroup {
|
||||||
/// server-side gate: `cancel_loose_end`'s approval-cancel arm
|
/// server-side gate: `cancel_loose_end`'s approval-cancel arm
|
||||||
/// requires it (`socket_server::check_can_cancel_approval`).
|
/// requires it (`socket_server::check_can_cancel_approval`).
|
||||||
Approvals,
|
Approvals,
|
||||||
/// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`,
|
|
||||||
/// `edit_schedule`, `list_schedules` - *(privileged)*
|
|
||||||
Scheduling,
|
|
||||||
/// Gates no tool today — `create_repo`, its only member, was removed
|
/// Gates no tool today — `create_repo`, its only member, was removed
|
||||||
/// with no replacement. Kept so existing `meta/capabilities.json`
|
/// with no replacement. Kept so existing `meta/capabilities.json`
|
||||||
/// grants still parse; `tools()` returns `&[]`.
|
/// grants still parse; `tools()` returns `&[]`.
|
||||||
|
|
@ -230,13 +227,6 @@ impl ToolGroup {
|
||||||
Self::Messaging => &["send", "recv", "ack_until"],
|
Self::Messaging => &["send", "recv", "ack_until"],
|
||||||
Self::Meta => &["get_agent_meta"],
|
Self::Meta => &["get_agent_meta"],
|
||||||
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
|
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
|
||||||
Self::Scheduling => &[
|
|
||||||
"request_schedule_prompt",
|
|
||||||
"fire_schedule_now",
|
|
||||||
"cancel_schedule",
|
|
||||||
"edit_schedule",
|
|
||||||
"list_schedules",
|
|
||||||
],
|
|
||||||
// All five empty, for different reasons — see each
|
// All five empty, for different reasons — see each
|
||||||
// variant's own doc comment above. `Execution` grants the
|
// variant's own doc comment above. `Execution` grants the
|
||||||
// out-of-process `bash` MCP server
|
// out-of-process `bash` MCP server
|
||||||
|
|
@ -308,7 +298,6 @@ impl ToolGroup {
|
||||||
Self::Inbox,
|
Self::Inbox,
|
||||||
Self::Lifecycle,
|
Self::Lifecycle,
|
||||||
Self::Approvals,
|
Self::Approvals,
|
||||||
Self::Scheduling,
|
|
||||||
Self::Execution,
|
Self::Execution,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -321,7 +310,6 @@ impl ToolGroup {
|
||||||
Self::Inbox,
|
Self::Inbox,
|
||||||
Self::Lifecycle,
|
Self::Lifecycle,
|
||||||
Self::Approvals,
|
Self::Approvals,
|
||||||
Self::Scheduling,
|
|
||||||
Self::Forge,
|
Self::Forge,
|
||||||
Self::Execution,
|
Self::Execution,
|
||||||
Self::WebTools,
|
Self::WebTools,
|
||||||
|
|
@ -340,9 +328,6 @@ impl ToolGroup {
|
||||||
Self::Approvals => {
|
Self::Approvals => {
|
||||||
"no tools — grants cancel_loose_end's approval-cancel arm (privileged)"
|
"no tools — grants cancel_loose_end's approval-cancel arm (privileged)"
|
||||||
}
|
}
|
||||||
Self::Scheduling => {
|
|
||||||
"request_schedule_prompt and related — operator-visible scheduled prompts (privileged)"
|
|
||||||
}
|
|
||||||
Self::Forge => "no tools — vestigial since create_repo was removed",
|
Self::Forge => "no tools — vestigial since create_repo was removed",
|
||||||
Self::Execution => {
|
Self::Execution => {
|
||||||
"run, status — run shell commands via mcp__bash__run / mcp__bash__status"
|
"run, status — run shell commands via mcp__bash__run / mcp__bash__status"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
//! Scheduled-prompt row shape on the wire, shared by the dashboard and
|
//! Scheduled-prompt row shape on the wire, served by the dashboard's
|
||||||
//! agent surfaces (mirrors `hive-c0re`'s internal `scheduled_prompts::Schedule`).
|
//! read-only schedule list (mirrors `hive-c0re`'s internal `scheduled_prompts::Schedule`).
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Schedule row shape on the wire — mirror of
|
/// Schedule row shape on the wire — mirror of
|
||||||
/// `scheduled_prompts::Schedule` but in the public crate so the
|
/// `scheduled_prompts::Schedule` but in the public crate so a client
|
||||||
/// dashboard and agent surfaces can deserialize without depending
|
/// can deserialize it without depending on hive-c0re-internal types.
|
||||||
/// on hive-c0re-internal types. Kept structurally identical to the
|
/// Kept structurally identical to the in-process type; the conversion
|
||||||
/// in-process type; the conversion is field-by-field in
|
/// is field-by-field in the dashboard.
|
||||||
/// `manager_server` / `dashboard`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WireSchedule {
|
pub struct WireSchedule {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
|
@ -22,10 +21,7 @@ pub struct WireSchedule {
|
||||||
pub source: WireScheduleSource,
|
pub source: WireScheduleSource,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cancelled_at_unix: Option<DateTime<Utc>>,
|
pub cancelled_at_unix: Option<DateTime<Utc>>,
|
||||||
/// Set while the schedule is paused. Worker skips paused rows;
|
/// Set if the row is paused.
|
||||||
/// they keep their `next_fire_at_unix` so resuming at any time
|
|
||||||
/// fires at the next intended instant (no catch-up clamp needed
|
|
||||||
/// — a paused schedule simply slips its next fire).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub paused_at_unix: Option<DateTime<Utc>>,
|
pub paused_at_unix: Option<DateTime<Utc>>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
|
||||||
|
|
@ -645,13 +645,6 @@ fn fmt_hyperhive_tool(name: &str, short: &str, input: &Value) -> String {
|
||||||
format!("{short} ≤{up_to}")
|
format!("{short} ≤{up_to}")
|
||||||
}
|
}
|
||||||
"mcp__hyperhive__remind" => fmt_hyperhive_remind(short, input),
|
"mcp__hyperhive__remind" => fmt_hyperhive_remind(short, input),
|
||||||
"mcp__hyperhive__list_schedules"
|
|
||||||
| "mcp__hyperhive__cancel_schedule"
|
|
||||||
| "mcp__hyperhive__fire_schedule_now"
|
|
||||||
| "mcp__hyperhive__edit_schedule"
|
|
||||||
| "mcp__hyperhive__request_schedule_prompt" => {
|
|
||||||
fmt_hyperhive_schedule_tool(name, short, input)
|
|
||||||
}
|
|
||||||
"mcp__hyperhive__set_status" => {
|
"mcp__hyperhive__set_status" => {
|
||||||
format!("{short} \"{}\"", trim_str(&sv(input, "text"), 60))
|
format!("{short} \"{}\"", trim_str(&sv(input, "text"), 60))
|
||||||
}
|
}
|
||||||
|
|
@ -739,91 +732,6 @@ fn fmt_hyperhive_remind(short: &str, input: &Value) -> String {
|
||||||
format!("{short}{when_part}{msg_part}")
|
format!("{short}{when_part}{msg_part}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fmt_hyperhive_edit_schedule(short: &str, input: &Value) -> String {
|
|
||||||
let id = input
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map_or_else(|| "?".to_owned(), |n| n.to_string());
|
|
||||||
let mut parts = vec![format!("#{id}")];
|
|
||||||
if input.get("body").is_some() {
|
|
||||||
parts.push("body".to_owned());
|
|
||||||
}
|
|
||||||
if input.get("interval_seconds").is_some() {
|
|
||||||
parts.push("interval".to_owned());
|
|
||||||
}
|
|
||||||
if input.get("next_fire_at_unix").is_some() {
|
|
||||||
parts.push("next".to_owned());
|
|
||||||
}
|
|
||||||
if let Some(a) = input.get("targets_add").and_then(Value::as_array)
|
|
||||||
&& !a.is_empty()
|
|
||||||
{
|
|
||||||
parts.push(format!("+{} tgt", a.len()));
|
|
||||||
}
|
|
||||||
if let Some(r) = input.get("targets_remove").and_then(Value::as_array)
|
|
||||||
&& !r.is_empty()
|
|
||||||
{
|
|
||||||
parts.push(format!("-{} tgt", r.len()));
|
|
||||||
}
|
|
||||||
format!("{short} {}", parts.join(" · "))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schedule-management hyperhive tools (list/cancel/fire/edit/request).
|
|
||||||
fn fmt_hyperhive_schedule_tool(name: &str, short: &str, input: &Value) -> String {
|
|
||||||
match name {
|
|
||||||
"mcp__hyperhive__list_schedules" => format!("{short}()"),
|
|
||||||
"mcp__hyperhive__cancel_schedule" => {
|
|
||||||
let id = input
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map_or_else(|| "?".to_owned(), |n| n.to_string());
|
|
||||||
let tgts = match input.get("targets").and_then(Value::as_array) {
|
|
||||||
Some(arr) if !arr.is_empty() => {
|
|
||||||
let names: Vec<&str> = arr.iter().filter_map(Value::as_str).collect();
|
|
||||||
format!(" [{}]", names.join(", "))
|
|
||||||
}
|
|
||||||
_ => " all".to_owned(),
|
|
||||||
};
|
|
||||||
format!("{short} #{id}{tgts}")
|
|
||||||
}
|
|
||||||
"mcp__hyperhive__fire_schedule_now" => {
|
|
||||||
let id = input
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map_or_else(|| "?".to_owned(), |n| n.to_string());
|
|
||||||
format!("{short} #{id}")
|
|
||||||
}
|
|
||||||
"mcp__hyperhive__edit_schedule" => fmt_hyperhive_edit_schedule(short, input),
|
|
||||||
"mcp__hyperhive__request_schedule_prompt" => {
|
|
||||||
let tgts = match input.get("targets").and_then(Value::as_array) {
|
|
||||||
Some(arr) => arr
|
|
||||||
.iter()
|
|
||||||
.filter_map(Value::as_str)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", "),
|
|
||||||
None => "?".to_owned(),
|
|
||||||
};
|
|
||||||
let when = input
|
|
||||||
.get("first_fire_at_unix")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map_or_else(
|
|
||||||
|| "?".to_owned(),
|
|
||||||
|ts| {
|
|
||||||
let h = (ts % 86_400) / 3_600;
|
|
||||||
let m = (ts % 3_600) / 60;
|
|
||||||
format!("{h:02}:{m:02}Z")
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let recur = input
|
|
||||||
.get("interval_seconds")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|s| format!(" +{s}s"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
format!("{short} → {tgts} at {when}{recur}")
|
|
||||||
}
|
|
||||||
_ => fmt_args_generic(short, input),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `mcp__bash__*` tools.
|
/// `mcp__bash__*` tools.
|
||||||
fn fmt_bash_tool(name: &str, short: &str, input: &Value) -> String {
|
fn fmt_bash_tool(name: &str, short: &str, input: &Value) -> String {
|
||||||
match name {
|
match name {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue