blob: 5c667f6120c7ce9bd0b7e65017bbe69ac91f582e [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Task timing owns durable plans; Agent remains the only execution owner.
mod dispatch;
mod repository;
mod tools;
use super::*;
use maka_capabilities::storage::Data;
use maka_capabilities::storage::Mutation;
use maka_capabilities::storage::Namespace;
use maka_capabilities::storage::Store;
use maka_scheduler::{
plan::TaskPlan,
task::{Creator, Outcome, Run, Status},
};
use repository::Saved;
use std::sync::{OnceLock, Weak};
use tokio::sync::Notify;
type Plan = TaskPlan<ScheduledEffect, Approval>;
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum Approval {
Reminder,
Execution { basis: Box<MessageBasis> },
}
pub(crate) struct Scheduler {
storage: Arc<maka_host_runtime::storage::Storage>,
gate: Mutex<()>,
wake: Notify,
changed: tokio::sync::watch::Sender<u64>,
link: OnceLock<Weak<Owner>>,
}
impl Scheduler {
pub fn new(store: Arc<EventLog>, prefix: String) -> Self {
Self {
storage: Arc::new(maka_host_runtime::storage::Storage::new(
store,
prefix,
Namespace::new(
"maka.agent.scheduler",
maka_plugin_core::scope::Scope::Profile,
)
.expect("scheduler namespace"),
)),
gate: Mutex::new(()),
wake: Notify::new(),
changed: tokio::sync::watch::channel(0).0,
link: OnceLock::new(),
}
}
pub fn bind(&self, owner: &Arc<Owner>) {
self.link
.set(Arc::downgrade(owner))
.expect("scheduler bound once");
}
pub async fn finish(&self) -> Result<(), String> {
self.storage.finish().await
}
}
impl Owner {
pub fn schedule_changes(&self) -> tokio::sync::watch::Receiver<u64> {
self.scheduler.changed.subscribe()
}
pub async fn schedule_alerts(&self) -> Result<ScheduleAlerts, String> {
let _gate = self.scheduler.gate.lock().await;
let catalog = self.scheduler.load().await?;
let mut reminders = Vec::new();
let mut pending = 0;
for saved in catalog.plans.values() {
if let Some(fire) = &saved.plan.pending
&& matches!(fire.effect, ScheduledEffect::Reminder)
{
pending += 1;
if reminders.len() < 8 {
reminders.push(ScheduledReminder {
task_id: fire.task_id.clone(),
fire_id: fire.id.clone(),
title: fire.title.clone(),
body: fire.intent.body().chars().take(512).collect(),
});
}
}
}
Ok(ScheduleAlerts { pending, reminders })
}
pub async fn schedules(&self, input: ScheduleRead) -> Result<Schedules, String> {
let scheduler = &self.scheduler;
let _gate = scheduler.gate.lock().await;
if let ScheduleRead::Receipt { reference } = input {
if reference.source.state_domain != self.data_source.state_domain
|| reference.source.deployment != self.data_source.deployment
|| reference.namespace != self.data_source.namespace
|| reference.export != crate::EXPORT
|| reference.method != "schedule"
{
return Err("scheduler_receipt_owner_invalid".into());
}
return Ok(Schedules::Receipt {
result: scheduler.receipt(&reference).await?,
});
}
let catalog = scheduler.load().await?;
match input {
ScheduleRead::List {
cursor,
expected_revision,
} => {
let revision = catalog.revision.unwrap_or(0);
if expected_revision.is_some_and(|expected| expected != revision) {
return Ok(Schedules::Changed { revision });
}
if cursor.is_some() && expected_revision.is_none() {
return Err("scheduler_cursor_invalid".into());
}
let mut tasks = Vec::new();
let mut next_cursor = None;
for (id, saved) in &catalog.plans {
if cursor.as_ref().is_some_and(|after| id <= after) {
continue;
}
if tasks.len() == 20 {
next_cursor = tasks.last().map(|row: &ScheduledSummary| row.id.clone());
break;
}
tasks.push(saved.summary());
}
Ok(Schedules::Page {
revision,
tasks,
next_cursor,
})
}
ScheduleRead::Get { task_id } => Ok(Schedules::Task {
detail: catalog
.plans
.get(&task_id)
.map(|saved| Box::new(saved.detail())),
}),
ScheduleRead::Receipt { .. } => unreachable!(),
}
}
pub async fn schedule_change(
self: &Arc<Self>,
input: ScheduleChange,
operation: maka_plugin_core::operation::RecoveryRef,
scope: &maka_capabilities::call::Scope,
) -> Result<ScheduleResult, String> {
// Management grants are explicit. Model tools have a separate proposal
// path and cannot turn their own arguments into background approval.
if scope.identity.agent().is_some() {
return Err("scheduler_user_approval_required".into());
}
if !self.settings.scheduled_tasks {
return Err("scheduler_disabled".into());
}
let runs = self.runs.lock().await;
if runs.closing || self.stop.is_cancelled() {
return Err("agent_closed".into());
}
let owner = self.clone();
let job = self.workers.spawn(async move {
let _gate = owner.scheduler.gate.lock().await;
if let Some(result) = owner.scheduler.receipt(&operation).await? {
return Ok(result);
}
let mut catalog = owner.scheduler.load().await?;
let id = match &input {
ScheduleChange::Create { .. } => format!("task-{}", owner.operation_id(&operation)),
ScheduleChange::Update { task_id, .. }
| ScheduleChange::Approve { task_id, .. }
| ScheduleChange::Pause { task_id, .. }
| ScheduleChange::Resume { task_id, .. }
| ScheduleChange::TriggerNow { task_id, .. }
| ScheduleChange::Snooze { task_id, .. }
| ScheduleChange::Acknowledge { task_id, .. }
| ScheduleChange::ClearHistory { task_id, .. }
| ScheduleChange::Delete { task_id, .. } => task_id.clone(),
};
let expected = match &input {
ScheduleChange::Create { .. } => None,
ScheduleChange::Update { revision, .. }
| ScheduleChange::Approve { revision, .. }
| ScheduleChange::Pause { revision, .. }
| ScheduleChange::Resume { revision, .. }
| ScheduleChange::TriggerNow { revision, .. }
| ScheduleChange::Snooze { revision, .. }
| ScheduleChange::Acknowledge { revision, .. }
| ScheduleChange::ClearHistory { revision, .. }
| ScheduleChange::Delete { revision, .. } => Some(*revision),
};
let actual = catalog.plans.get(&id).map(|saved| saved.revision);
let candidate = if expected != actual {
Err(ScheduleResult::Conflict { actual })
} else {
owner
.schedule_candidate(&id, input, catalog.plans.get(&id))
.await
.map_err(|reason| ScheduleResult::Rejected { reason })
};
let result = match candidate {
Ok(plan) => {
let revision = actual.unwrap_or(0) + 1;
let result = ScheduleResult::Committed {
task_id: id.clone(),
revision,
};
owner
.scheduler
.commit(
&mut catalog,
&id,
plan,
Some(repository::receipt_mutation(&operation, &result)?),
)
.await?;
result
}
Err(result) => {
owner.scheduler.remember(&operation, &result).await?;
result
}
};
owner.scheduler.wake.notify_one();
Ok(result)
});
drop(runs);
job.await
.map_err(|_| "scheduler_operation_unknown".to_string())?
}
async fn schedule_candidate(
&self,
id: &str,
input: ScheduleChange,
saved: Option<&Saved>,
) -> Result<Option<Plan>, String> {
let at = now() as i64;
let plan = || {
saved
.map(|saved| &saved.plan)
.ok_or_else(|| "scheduler_task_missing".to_string())
};
let candidate = match input {
ScheduleChange::Create { input } => {
let mut plan =
Plan::create(id.into(), input.task, Creator::User, input.timezone, at)
.map_err(reason)?;
plan.misfire = input.misfire;
plan.authorization = Some(self.approve_schedule(&plan).await?);
plan
}
ScheduleChange::Update { input, .. } => {
let current = plan()?;
if current.pending.is_some() {
return Err("scheduler_trigger_pending".into());
}
let mut base = current.clone();
base.timezone = input.timezone;
base.misfire = input.misfire;
let mut next = base
.update(
maka_scheduler::command::Update {
title: Some(input.task.title),
intent_body: Some(input.task.intent_body),
schedule: Some(input.task.schedule),
effect: Some(input.task.effect),
max_fires: input.task.max_fires.map_or(
maka_agent_facts::configuration::Patch::Clear,
maka_agent_facts::configuration::Patch::Set,
),
expires_at: input.task.expires_at.map_or(
maka_agent_facts::configuration::Patch::Clear,
maka_agent_facts::configuration::Patch::Set,
),
},
at,
)
.map_err(reason)?;
next.authorization = Some(self.approve_schedule(&next).await?);
next.validate().map_err(reason)?;
next
}
ScheduleChange::Approve { .. } => {
let mut next = plan()?.clone();
if next.pending.is_some() {
return Err("scheduler_trigger_pending".into());
}
if !matches!(next.task.status, Status::Active | Status::Paused) {
return Err("scheduler_task_terminal".into());
}
next.authorization = Some(self.approve_schedule(&next).await?);
if next.task.status == Status::Paused {
next = next.resume(at).map_err(reason)?;
}
next
}
ScheduleChange::Pause { .. } => plan()?.pause(at),
ScheduleChange::Resume { .. } => {
let mut next = plan()?.resume(at).map_err(reason)?;
next.authorization = Some(self.approve_schedule(&next).await?);
next
}
ScheduleChange::TriggerNow { .. } => {
let mut next = plan()?.clone();
if next.task.status != Status::Active || next.pending.is_some() {
return Err("scheduler_task_not_ready".into());
}
if next.authorization.is_none() {
return Err("scheduler_user_approval_required".into());
}
next.task.next_fire_at = Some(at);
next.claim(at).map_err(reason)?;
next
}
ScheduleChange::Snooze { delay_ms, .. } => {
plan()?.snooze(delay_ms, at).map_err(reason)?
}
ScheduleChange::Acknowledge { fire_id, .. } => {
let mut next = plan()?.clone();
let pending = next
.pending
.as_ref()
.filter(|fire| {
fire.id == fire_id && matches!(fire.effect, ScheduledEffect::Reminder)
})
.ok_or("scheduler_reminder_changed")?;
next.settle(Run {
id: fire_id,
at: at.max(pending.scheduled_at),
outcome: Outcome::Ok,
message: "Reminder acknowledged".into(),
session_id: None,
run_id: None,
})
.map_err(reason)?;
next
}
ScheduleChange::ClearHistory { .. } => plan()?.clear_history(at),
ScheduleChange::Delete { .. } => {
if plan()?
.pending
.as_ref()
.is_some_and(|fire| !matches!(fire.effect, ScheduledEffect::Reminder))
{
return Err("scheduler_trigger_pending".into());
}
return Ok(None);
}
};
candidate.validate().map_err(reason)?;
Ok(Some(candidate))
}
}
fn reason(error: impl std::fmt::Display) -> String {
error.to_string()
}