From 9fee1a1e56591044e4d4539fcc60f2a435218d8d Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 26 May 2026 15:04:05 +0200 Subject: [PATCH] scheduled prompts: edit existing schedule (closes #474) --- hive-ag3nt/prompts/manager.md | 1 + hive-ag3nt/src/mcp.rs | 65 +++++++++ hive-c0re/src/dashboard.rs | 67 ++++++++++ hive-c0re/src/manager_server.rs | 68 ++++++++++ hive-c0re/src/scheduled_prompts.rs | 208 +++++++++++++++++++++++++++++ hive-sh4re/src/lib.rs | 19 +++ 6 files changed, 428 insertions(+) diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index f84490ca..a089b7b9 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -14,6 +14,7 @@ Tools (hyperhive surface): - `mcp__hyperhive__request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)` — queue an approval for the operator to add a scheduled prompt. On approve hive-c0re inserts a schedule row and the worker fans `body` out to each agent in `targets` at `first_fire_at_unix` (recurring every `interval_seconds` if set, one-shot when absent). Even self-targeted schedules go through approval — the existing `remind` tool stays the quick no-approval self-wake path. Catch-up clamp: long downtime fires ONCE per recurring row on resume (skipped count surfaces in per-target `last_result`), not N stacked pulses. - `mcp__hyperhive__cancel_schedule(id, targets?)` — cancel a schedule. Omit `targets` / pass empty to cancel the whole schedule; pass a list to cancel just those recipients (the schedule keeps firing for any remaining active targets, auto-cancels when every target is gone). Authorization: you can cancel schedules you own OR any owned by a sub-agent in your subtree per topology.json. - `mcp__hyperhive__fire_schedule_now(id)` — fire a scheduled prompt out of band. Runs the per-target fan-out once immediately. Recurring schedules keep their cadence intact (the manual fire is additive); one-shot schedules are CONSUMED by the manual fire (cancelled afterwards). Same authorization as `cancel_schedule`. +- `mcp__hyperhive__edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?)` — partial-update a schedule's mutable fields (#474). Pass only the fields you want to change. Targets stay immutable (cancel + new schedule is the workaround). Refuses cancelled rows. Same authorization as `cancel_schedule`. Note: clearing fields (e.g. flipping recurring→one-shot) is operator-only via the dashboard PATCH — the agent surface only supports positive sets. - `mcp__hyperhive__list_schedules()` — snapshot every schedule in the queue (active + cancelled-but-not-reaped). Returns id, owner, body, target set with per-target `last_fired_at` + `last_result`, `next_fire_at_unix`, recurring `interval_seconds`. Use to look up an id before cancelling, or to audit upcoming wake-ups across the swarm. - `mcp__hyperhive__get_logs(agent, lines?)` — fetch recent journal lines for a sub-agent container. Use to diagnose MCP-server registration failures, startup crashes, or harness issues you can't see from inside. Pass the plain logical agent name; `lines` defaults to 50 (capped at 500). - `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the operator (default, or `to: "operator"`) OR a sub-agent (`to: ""`). Returns immediately with a question id; the answer arrives later as a system `question_answered { id, question, answer, answerer }` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline (capped at 6h server-side) — on expiry the answer is `[expired]` and `answerer` is `"ttl-watchdog"`. Do not poll inside the same turn — finish the current work and react when the event lands. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 51e5b4b6..227c7a66 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -981,6 +981,33 @@ pub struct CancelScheduleArgs { pub targets: Option>, } +#[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, + /// 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, + /// 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, + /// 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, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetLogsArgs { /// Logical agent name to fetch logs for (e.g. `gui`, `hm1nd`). @@ -1327,6 +1354,44 @@ impl ManagerServer { .await } + #[tool( + description = "Edit an existing scheduled prompt's mutable fields (#474). 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`. \ + Targets are immutable: per-target last-result history is keyed on them. To change \ + the recipient list, cancel and submit a new schedule. \n\n\ + Authorization mirrors `cancel_schedule` / `fire_schedule_now`: you can edit your \ + own schedules + any owned by a sub-agent in your subtree per topology.json. \ + Refuses cancelled schedules (the row's terminal — submit a fresh one)." + )] + async fn edit_schedule(&self, Parameters(args): Parameters) -> String { + let log = format!("{args:?}"); + run_tool_envelope("edit_schedule", log, async move { + let id = args.id; + let (resp, retries) = self + .dispatch(hive_sh4re::ManagerRequest::EditSchedule { + id: args.id, + body: args.body, + // The agent-side args use plain Option; the + // manager wire type's `Some(None)` ("set to + // null") cases stay operator-exclusive, so we + // promote agent-supplied values into + // `Some(Some(v))` and omit when the agent + // didn't pass a value. + description: args.description.map(Some), + interval_seconds: args.interval_seconds.map(Some), + next_fire_at_unix: args.next_fire_at_unix, + }) + .await; + annotate_retries( + format_ack(resp, "edit_schedule", format!("edited #{id}")), + retries, + ) + }) + .await + } + #[tool( description = "List every scheduled prompt in the queue (active + cancelled but \ not yet reaped). Returns the full snapshot — schedule id, owner, body, target set \ diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 0236bb57..a3ba2b2b 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -75,6 +75,10 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/op-send", post(post_op_send)) .route("/meta-update", post(post_meta_update)) .route("/api/schedules", get(api_schedules).post(post_schedule_new)) + .route( + "/api/schedules/{id}", + axum::routing::patch(patch_schedule), + ) .route("/api/schedules/{id}/cancel", post(post_schedule_cancel)) .route("/api/schedules/{id}/fire-now", post(post_schedule_fire_now)) .route("/api/rebuild-queue/{id}/cancel", post(post_rebuild_queue_cancel)) @@ -1489,6 +1493,69 @@ struct CancelScheduleForm { targets: Option>, } +#[derive(serde::Deserialize, Default)] +struct EditScheduleForm { + #[serde(default)] + body: Option, + /// 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>` and document + /// that the dashboard caller passes JSON `null` to clear. + #[serde(default, deserialize_with = "deserialize_some")] + description: Option>, + #[serde(default, deserialize_with = "deserialize_some")] + interval_seconds: Option>, + #[serde(default)] + next_fire_at_unix: Option, +} + +/// 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, D::Error> +where + T: serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + T::deserialize(deserializer).map(Some) +} + +/// `PATCH /api/schedules/{id}` — partial update of an existing +/// schedule (#474). Mutable fields: `body`, `description`, +/// `interval_seconds`, `next_fire_at_unix`. Targets stay +/// immutable (per-target last-result history is keyed on them; +/// the "I want different targets" workaround is cancel + submit +/// a new 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. +async fn patch_schedule( + State(state): State, + AxumPath(id): AxumPath, + axum::Json(form): axum::Json, +) -> 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, + }; + 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)) => { + axum::Json(crate::manager_server::schedule_to_wire_public(s)).into_response() + } + Ok(None) => error_response(&format!("edit schedule {id}: row vanished post-update")), + Err(e) => error_response(&format!("re-read schedule {id}: {e:#}")), + } +} + /// `POST /api/schedules/{id}/cancel` — operator-side cancel /// (whole schedule when no `targets` field, partial when one is /// provided). Operator bypasses the topology check; the manager diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 9c4a5a52..19d19e50 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -350,6 +350,21 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp ManagerRequest::CancelSchedule { id, targets } => { handle_cancel_schedule(coord, hive_sh4re::MANAGER_AGENT, *id, targets.as_deref()) } + ManagerRequest::EditSchedule { + id, + body, + description, + interval_seconds, + next_fire_at_unix, + } => handle_edit_schedule( + coord, + hive_sh4re::MANAGER_AGENT, + *id, + body.clone(), + description.clone(), + *interval_seconds, + *next_fire_at_unix, + ), ManagerRequest::ListSchedules => match coord.scheduled_prompts.list() { Ok(schedules) => ManagerResponse::Schedules { schedules: schedules.into_iter().map(schedule_to_wire).collect(), @@ -863,6 +878,59 @@ async fn handle_fire_schedule_now( } } +/// Authorize + dispatch a `EditSchedule` patch (#474). Same +/// ownership rules as `CancelSchedule` — the manager can edit +/// schedules it owns + any owned by an agent in its subtree. +/// 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 auth / validation +/// failure so the dashboard can surface it verbatim. +#[allow(clippy::too_many_arguments)] +fn handle_edit_schedule( + coord: &Arc, + requester: &str, + schedule_id: i64, + body: Option, + description: Option>, + interval_seconds: Option>, + next_fire_at_unix: Option, +) -> ManagerResponse { + let schedule = match coord.scheduled_prompts.get(schedule_id) { + Ok(Some(s)) => s, + Ok(None) => { + return ManagerResponse::Err { + message: format!("schedule {schedule_id} not found"), + } + } + Err(e) => { + return ManagerResponse::Err { + message: format!("read schedule {schedule_id}: {e:#}"), + } + } + }; + if !cancel_authorized(requester, &schedule.owner) { + return ManagerResponse::Err { + message: format!( + "not authorized: {requester} cannot edit schedule owned by {owner}", + owner = schedule.owner + ), + }; + } + let patch = crate::scheduled_prompts::UpdateSchedule { + body, + description, + interval_seconds, + next_fire_at_unix, + }; + match coord.scheduled_prompts.update(schedule_id, patch) { + Ok(()) => ManagerResponse::Ok, + Err(e) => ManagerResponse::Err { + message: format!("edit schedule {schedule_id}: {e:#}"), + }, + } +} + /// Permission check for `CancelSchedule` on the manager surface. /// `requester` (always `hm1nd` here) can cancel its own schedules. /// Sub-agent ownership is delegated to topology — see diff --git a/hive-c0re/src/scheduled_prompts.rs b/hive-c0re/src/scheduled_prompts.rs index 9ed90fc6..9de7f799 100644 --- a/hive-c0re/src/scheduled_prompts.rs +++ b/hive-c0re/src/scheduled_prompts.rs @@ -145,6 +145,22 @@ pub struct NewSchedule { pub source: ScheduleSource, } +/// Partial-update payload for `ScheduledPrompts::update` (#474). +/// Every field is `Option<_>`; `None` keeps the existing value. +/// The doubly-wrapped `Option>` on `interval_seconds` +/// is intentional: outer `None` = "don't touch", outer +/// `Some(None)` = "set to NULL (toggle to one-shot)", outer +/// `Some(Some(n))` = "set to n seconds." Same encoding as JSON +/// "missing key" vs "explicit null" — the dashboard surface +/// preserves the distinction. +#[derive(Debug, Clone, Default)] +pub struct UpdateSchedule { + pub body: Option, + pub description: Option>, + pub interval_seconds: Option>, + pub next_fire_at_unix: Option, +} + pub struct ScheduledPrompts { conn: Mutex, } @@ -331,6 +347,67 @@ impl ScheduledPrompts { Ok(skipped) } + /// Partial-update an existing schedule's mutable fields (#474). + /// Every field is `Option<_>`; `None` means "leave the existing + /// value alone", `Some(_)` means "set it to this." Refuses + /// cancelled rows (no point editing a tombstone — operator can + /// just submit a new schedule). Refuses `interval_seconds = Some(0)` + /// — same rule as submit-time validation. Targets stay + /// immutable: per-target last-result history is keyed on + /// (schedule_id, target); changing the target set would orphan + /// or duplicate history rows. Operator workaround for "I want + /// different targets" is cancel-target + submit a new schedule. + pub fn update(&self, id: i64, patch: UpdateSchedule) -> Result<()> { + // Pre-flight: row must exist and not be cancelled. + let mut conn = self.conn.lock().unwrap(); + let cancelled: Option = conn + .query_row( + "SELECT cancelled_at_unix FROM scheduled_prompts WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| anyhow::anyhow!("schedule {id} not found"))?; + if cancelled.is_some() { + bail!("schedule {id} is cancelled — submit a new one"); + } + if let Some(Some(0)) = patch.interval_seconds { + bail!("interval_seconds must be > 0 (use None for one-shot)"); + } + // Single transaction so a partial failure can't leave a + // half-updated row. + let tx = conn.transaction()?; + if let Some(body) = patch.body { + tx.execute( + "UPDATE scheduled_prompts SET body = ?1 WHERE id = ?2", + params![body, id], + )?; + } + if let Some(description) = patch.description { + tx.execute( + "UPDATE scheduled_prompts SET description = ?1 WHERE id = ?2", + params![description, id], + )?; + } + if let Some(interval) = patch.interval_seconds { + // `Some(Some(n))` = set to n. `Some(None)` = clear (one-shot). + // `None` is filtered out by the outer `if let`. + let value: Option = interval.map(i64::try_from).and_then(Result::ok); + tx.execute( + "UPDATE scheduled_prompts SET interval_seconds = ?1 WHERE id = ?2", + params![value, id], + )?; + } + if let Some(next) = patch.next_fire_at_unix { + tx.execute( + "UPDATE scheduled_prompts SET next_fire_at_unix = ?1 WHERE id = ?2", + params![next, id], + )?; + } + tx.commit()?; + Ok(()) + } + /// Delete a one-shot row after its single fire. Cascades the /// target rows via the FK constraint. Idempotent on a missing /// id. @@ -638,6 +715,137 @@ mod tests { assert_eq!(bob.last_result.as_deref(), Some("ok")); } + #[test] + fn update_partial_only_touches_set_fields() { + let (_dir, db) = open(); + let id = db + .submit(NewSchedule { + owner: "operator".into(), + targets: vec!["alice".into()], + body: "old body".into(), + first_fire_at_unix: 100, + interval_seconds: Some(60), + description: Some("old desc".into()), + source: ScheduleSource::Operator, + }) + .expect("submit"); + // Only change body — everything else must stay. + db.update( + id, + UpdateSchedule { + body: Some("new body".into()), + ..Default::default() + }, + ) + .expect("update body"); + let s = db.get(id).expect("get").expect("present"); + assert_eq!(s.body, "new body"); + assert_eq!(s.description.as_deref(), Some("old desc")); + assert_eq!(s.interval_seconds, Some(60)); + assert_eq!(s.next_fire_at_unix, 100); + } + + #[test] + fn update_interval_toggle_recurring_to_one_shot() { + let (_dir, db) = open(); + let id = db + .submit(NewSchedule { + owner: "operator".into(), + targets: vec!["alice".into()], + body: "x".into(), + first_fire_at_unix: 100, + interval_seconds: Some(60), + description: None, + source: ScheduleSource::Operator, + }) + .expect("submit"); + db.update( + id, + UpdateSchedule { + interval_seconds: Some(None), + ..Default::default() + }, + ) + .expect("update"); + let s = db.get(id).expect("get").expect("present"); + assert_eq!(s.interval_seconds, None); + } + + #[test] + fn update_interval_toggle_one_shot_to_recurring() { + let (_dir, db) = open(); + let id = submit_one_shot(&db, 100, &["alice"]); + db.update( + id, + UpdateSchedule { + interval_seconds: Some(Some(3600)), + ..Default::default() + }, + ) + .expect("update"); + let s = db.get(id).expect("get").expect("present"); + assert_eq!(s.interval_seconds, Some(3600)); + } + + #[test] + fn update_rejects_cancelled_row() { + let (_dir, db) = open(); + let id = submit_one_shot(&db, 100, &["alice"]); + db.cancel_all(id).expect("cancel"); + let err = db + .update( + id, + UpdateSchedule { + body: Some("nope".into()), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("cancelled")); + } + + #[test] + fn update_rejects_zero_interval() { + let (_dir, db) = open(); + let id = submit_one_shot(&db, 100, &["alice"]); + let err = db + .update( + id, + UpdateSchedule { + interval_seconds: Some(Some(0)), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("interval_seconds")); + } + + #[test] + fn update_clears_description() { + let (_dir, db) = open(); + let id = db + .submit(NewSchedule { + owner: "operator".into(), + targets: vec!["alice".into()], + body: "x".into(), + first_fire_at_unix: 100, + interval_seconds: None, + description: Some("first".into()), + source: ScheduleSource::Operator, + }) + .expect("submit"); + db.update( + id, + UpdateSchedule { + description: Some(None), + ..Default::default() + }, + ) + .expect("update"); + let s = db.get(id).expect("get").expect("present"); + assert!(s.description.is_none()); + } + #[test] fn approval_source_round_trips() { let (_dir, db) = open(); diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index e3dd9a40..8d53ddac 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -948,6 +948,25 @@ pub enum ManagerRequest { /// + any owned by a sub-agent in its subtree per topology.json; /// the operator surface bypasses the check. FireScheduleNow { id: i64 }, + /// Edit an existing schedule's mutable fields (#474). Partial + /// PATCH semantics: `None` / missing JSON key = leave alone, + /// `Some(_)` = set. `interval_seconds` and `description` are + /// doubly-wrapped so `Some(None)` (set explicit null) can flip + /// a recurring schedule back to one-shot / clear the + /// description, while plain `None` keeps the current value. + /// Targets stay immutable. Refuses cancelled rows. + /// Authorization mirrors `CancelSchedule`. + EditSchedule { + id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + interval_seconds: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + next_fire_at_unix: Option, + }, } /// Submission payload for `RequestSchedulePrompt`. Lives outside the