c0re: schedule_prompt approval kind + worker + manager surface (#444 step 2)
This commit is contained in:
parent
c803bb714e
commit
aa7d8d9c9a
9 changed files with 612 additions and 4 deletions
|
|
@ -344,6 +344,20 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
|
|||
);
|
||||
ManagerResponse::Ok
|
||||
}
|
||||
ManagerRequest::RequestSchedulePrompt(payload) => {
|
||||
handle_request_schedule_prompt(coord, hive_sh4re::MANAGER_AGENT, payload).await
|
||||
}
|
||||
ManagerRequest::CancelSchedule { id, targets } => {
|
||||
handle_cancel_schedule(coord, hive_sh4re::MANAGER_AGENT, *id, targets.as_deref())
|
||||
}
|
||||
ManagerRequest::ListSchedules => match coord.scheduled_prompts.list() {
|
||||
Ok(schedules) => ManagerResponse::Schedules {
|
||||
schedules: schedules.into_iter().map(schedule_to_wire).collect(),
|
||||
},
|
||||
Err(e) => ManagerResponse::Err {
|
||||
message: format!("list scheduled prompts: {e:#}"),
|
||||
},
|
||||
},
|
||||
ManagerRequest::Ask {
|
||||
question,
|
||||
options,
|
||||
|
|
@ -692,6 +706,172 @@ async fn submit_apply_commit(
|
|||
Ok((id, sha))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
async fn handle_request_schedule_prompt(
|
||||
coord: &Arc<Coordinator>,
|
||||
requester: &str,
|
||||
payload: &hive_sh4re::SchedulePromptPayload,
|
||||
) -> ManagerResponse {
|
||||
if payload.targets.is_empty() {
|
||||
return ManagerResponse::Err {
|
||||
message: "schedule must have at least one target".into(),
|
||||
};
|
||||
}
|
||||
if payload.body.trim().is_empty() {
|
||||
return ManagerResponse::Err {
|
||||
message: "schedule body must be non-empty".into(),
|
||||
};
|
||||
}
|
||||
if let Some(0) = payload.interval_seconds {
|
||||
return ManagerResponse::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 ManagerResponse::Err {
|
||||
message: format!("encode SchedulePromptPayload: {e:#}"),
|
||||
}
|
||||
}
|
||||
};
|
||||
let id = match coord.approvals.submit_kind(
|
||||
requester,
|
||||
hive_sh4re::ApprovalKind::SchedulePrompt,
|
||||
&commit_ref,
|
||||
payload.description.as_deref(),
|
||||
) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
return ManagerResponse::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(
|
||||
id,
|
||||
requester,
|
||||
"schedule_prompt",
|
||||
None,
|
||||
None,
|
||||
payload.description.clone(),
|
||||
);
|
||||
ManagerResponse::Ok
|
||||
}
|
||||
|
||||
/// Cancel a schedule (whole or per-target). Manager-surface
|
||||
/// authorization: a manager can cancel its own schedules + any
|
||||
/// schedule whose owner is one of its sub-agents (topology-walked).
|
||||
/// The operator surface bypasses this and can cancel anything;
|
||||
/// agents reaching this path through the manager get the
|
||||
/// topology-scoped check.
|
||||
fn handle_cancel_schedule(
|
||||
coord: &Arc<Coordinator>,
|
||||
requester: &str,
|
||||
schedule_id: i64,
|
||||
targets: Option<&[String]>,
|
||||
) -> 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 cancel schedule owned by {owner}",
|
||||
owner = schedule.owner
|
||||
),
|
||||
};
|
||||
}
|
||||
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(()) => ManagerResponse::Ok,
|
||||
Err(message) => ManagerResponse::Err { message },
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `crate::topology::is_descendant_of`.
|
||||
fn cancel_authorized(requester: &str, owner: &str) -> bool {
|
||||
if requester == owner {
|
||||
return true;
|
||||
}
|
||||
if requester == hive_sh4re::OPERATOR_RECIPIENT {
|
||||
return true;
|
||||
}
|
||||
// Manager can cancel anything owned by an agent in its subtree.
|
||||
// For the current single-manager topology that covers everything,
|
||||
// but the check stays correct as the tree grows.
|
||||
crate::topology::is_descendant_of(owner, requester)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::WireSchedule {
|
||||
hive_sh4re::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::WireScheduleSource::Operator
|
||||
}
|
||||
crate::scheduled_prompts::ScheduleSource::Approval { id } => {
|
||||
hive_sh4re::WireScheduleSource::Approval { id }
|
||||
}
|
||||
},
|
||||
cancelled_at_unix: s.cancelled_at_unix,
|
||||
description: s.description,
|
||||
targets: s
|
||||
.targets
|
||||
.into_iter()
|
||||
.map(|t| hive_sh4re::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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// On `Ask { ttl_seconds: Some(n) }`, sleep n seconds and then try to
|
||||
/// resolve the question with `[expired]`. If the operator (or any
|
||||
/// other path) already answered it, `answer()` returns Err and we
|
||||
|
|
|
|||
Loading…
Reference in a new issue