dashboard: operator-direct schedule submit + cancel endpoints (#444 step 3)

This commit is contained in:
damocles 2026-05-26 01:15:45 +02:00 committed by Mara
commit a31cd8bb15
2 changed files with 87 additions and 0 deletions

View file

@ -74,6 +74,8 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/request-spawn", post(post_request_spawn))
.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}/cancel", post(post_schedule_cancel))
.route("/dashboard/stream", get(dashboard_stream))
.route("/dashboard/history", get(dashboard_history))
// Anything not matched by the dynamic routes above falls
@ -1389,6 +1391,84 @@ async fn api_reminders(State(state): State<AppState>) -> Response {
}
}
/// `GET /api/schedules` — snapshot of every schedule for the
/// scheduled-prompts tab (#444). Returns the wire shape directly
/// so the frontend can render without an extra translation layer.
async fn api_schedules(State(state): State<AppState>) -> Response {
match state.coord.scheduled_prompts.list() {
Ok(rows) => axum::Json(
rows.into_iter()
.map(crate::manager_server::schedule_to_wire_public)
.collect::<Vec<_>>(),
)
.into_response(),
Err(e) => error_response(&format!("scheduled_prompts list: {e:#}")),
}
}
/// `POST /api/schedules` — operator-direct schedule creation
/// (mara: "user can add them manually"). Accepts the same
/// `SchedulePromptPayload` shape as the manager request flow but
/// skips the approval gate — the operator click *is* the
/// approval. The schedule lands directly with
/// `source = Operator` and the worker picks it up at fire time.
async fn post_schedule_new(
State(state): State<AppState>,
axum::Json(payload): axum::Json<hive_sh4re::SchedulePromptPayload>,
) -> Response {
if payload.targets.is_empty() {
return error_response("schedule must have at least one target");
}
if payload.body.trim().is_empty() {
return error_response("schedule body must be non-empty");
}
if let Some(0) = payload.interval_seconds {
return error_response("interval_seconds must be > 0 (use None for one-shot)");
}
let new = crate::scheduled_prompts::NewSchedule {
owner: hive_sh4re::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) => axum::Json(serde_json::json!({"id": id})).into_response(),
Err(e) => error_response(&format!("schedule submit: {e:#}")),
}
}
#[derive(serde::Deserialize, Default)]
struct CancelScheduleForm {
/// `None` / absent / empty array → cancel whole schedule.
#[serde(default)]
targets: Option<Vec<String>>,
}
/// `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
/// surface enforces it for agent callers.
async fn post_schedule_cancel(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
body: Option<axum::Json<CancelScheduleForm>>,
) -> Response {
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(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("cancel schedule {id}: {e:#}")),
}
}
/// Same-origin proxy that fetches the named agent's
/// `GET /api/state` and forwards only the `links` field to the
/// dashboard JS (issue #262). Lets the agent backend stay the

View file

@ -841,6 +841,13 @@ fn cancel_authorized(requester: &str, owner: &str) -> bool {
/// 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 manager-server file.
pub fn schedule_to_wire_public(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::WireSchedule {
schedule_to_wire(s)
}
fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::WireSchedule {
hive_sh4re::WireSchedule {
id: s.id,