blob: 8fce8a221d8ea337cd75b7345941cb1bef269780 [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.
*/
#[cfg(test)]
use super::Plan;
use super::TaskPlan;
use crate::{
Error,
command::Update,
invalid,
schedule::Schedule,
task::{Create, Intent, Status, TaskEffect},
};
use jiff::tz::TimeZone;
use maka_agent_facts::configuration::Patch;
impl<E: TaskEffect, A: Clone> TaskPlan<E, A> {
/// Work on a candidate: a rejected edit never mutates the loaded authority.
pub fn update(&self, patch: Update<E>, now: i64) -> Result<Self, Error> {
if !matches!(self.task.status, Status::Active | Status::Paused) {
return Err(invalid("terminal task cannot be edited"));
}
let mut next = self.clone();
let reschedule = patch.schedule.is_some();
if let Some(title) = patch.title {
next.task.title = title.trim().into();
}
if let Some(body) = patch.intent_body {
next.task.intent = Intent::Text {
body: body.trim().into(),
};
}
if let Some(schedule) = patch.schedule {
next.task.schedule = schedule;
}
if let Some(effect) = patch.effect {
next.task.effect = effect;
next.authorization = None;
}
apply(patch.max_fires, &mut next.task.max_fires);
apply(patch.expires_at, &mut next.task.expires_at);
// An unchanged one-shot schedule can be pending; only new schedules
// require a future runAt. Frozen pending Fire bytes remain unchanged.
let input = Create {
title: next.task.title.clone(),
intent_body: next.task.intent.body().into(),
schedule: next.task.schedule.clone(),
effect: next.task.effect.clone(),
max_fires: next.task.max_fires,
expires_at: next.task.expires_at,
};
if reschedule {
input.validate(now)?;
} else {
input.validate_fields()?;
}
if next.task.expires_at.is_some_and(|expires| expires <= now) {
return Err(invalid("expiry must be a future timestamp"));
}
if next
.task
.max_fires
.is_some_and(|max| max <= next.task.fire_count)
{
return Err(invalid("maxFires does not exceed the current fire count"));
}
if reschedule
|| (next.task.next_fire_at.is_none_or(|at| at <= now) && next.pending.is_none())
{
next.task.next_fire_at = if next.task.status == Status::Active {
Some(
next.task
.schedule
.next_after(now, &TimeZone::get(&next.timezone)?)?
.ok_or_else(|| invalid("schedule has no remaining trigger"))?,
)
} else {
None
};
}
next.check_expiry()?;
next.task.updated_at = next.task.updated_at.max(now);
next.validate()?;
Ok(next)
}
pub fn pause(&self, now: i64) -> Self {
let mut next = self.clone();
next.cancel_waiting_notification();
if next.task.status == Status::Active {
next.task.status = Status::Paused;
next.task.updated_at = next.task.updated_at.max(now);
}
next
}
pub fn resume(&self, now: i64) -> Result<Self, Error> {
if self.task.status != Status::Paused {
return Err(invalid("only paused tasks can resume"));
}
if self
.task
.max_fires
.is_some_and(|max| self.task.fire_count >= max)
|| (matches!(self.task.schedule, Schedule::Once { .. }) && self.task.fire_count > 0)
{
return Err(invalid("task fire budget exhausted"));
}
let mut next = self.clone();
if next.task.expires_at.is_some_and(|expires| now >= expires) {
next.task.status = Status::Expired;
next.task.next_fire_at = None;
} else {
next.task.status = Status::Active;
if next.task.next_fire_at.is_none_or(|at| at <= now) && next.pending.is_none() {
next.task.next_fire_at = Some(
next.task
.schedule
.next_after(now, &TimeZone::get(&next.timezone)?)?
.ok_or_else(|| invalid("schedule has no remaining trigger"))?,
);
}
next.check_expiry()?;
}
next.task.updated_at = next.task.updated_at.max(now);
Ok(next)
}
pub fn snooze(&self, delay_ms: i64, now: i64) -> Result<Self, Error> {
if delay_ms <= 0 {
return Err(invalid("snooze must be positive"));
}
if self.task.status != Status::Active
|| (self.pending.is_some() && !self.waiting_notification())
{
return Err(invalid("only unclaimed active tasks can be snoozed"));
}
let at = self
.task
.next_fire_at
.ok_or_else(|| invalid("task has no trigger"))?
.max(now);
let mut next = self.clone();
next.cancel_waiting_notification();
next.task.next_fire_at = Some(
at.checked_add(delay_ms)
.ok_or_else(|| invalid("time overflow"))?,
);
if let Some(at) = next.task.next_fire_at {
crate::schedule::timestamp(at)?;
}
next.task.updated_at = next.task.updated_at.max(now);
next.check_expiry()?;
Ok(next)
}
pub fn clear_history(&self, now: i64) -> Self {
let mut next = self.clone();
next.task.runs.clear();
next.task.last_error = None;
next.task.updated_at = next.task.updated_at.max(now);
next
}
fn check_expiry(&self) -> Result<(), Error> {
if let (Some(next), Some(expires)) = (self.task.next_fire_at, self.task.expires_at)
&& next >= expires
{
return Err(invalid("next trigger is beyond task expiry"));
}
Ok(())
}
}
fn apply<T>(patch: Patch<T>, field: &mut Option<T>) {
match patch {
Patch::Keep => {}
Patch::Clear => *field = None,
Patch::Set(value) => *field = Some(value),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::task::{Creator, Effect, Outcome, Run};
#[test]
fn old_tasks_can_reschedule_and_pending_calls_keep_their_intent_across_edits() {
let plan = Plan::create(
"task".into(),
Create {
title: "Original".into(),
intent_body: "Work".into(),
schedule: Schedule::Interval {
every_seconds: 10,
start_at: 1000,
},
effect: Effect::SessionResume {
session_id: "session".into(),
},
max_fires: None,
expires_at: None,
},
Creator::User,
"UTC".into(),
1000,
)
.unwrap();
// A reschedule uses real wall time even before recorded creation time.
let mut rewound = plan
.update(
Update {
schedule: Some(Schedule::Once { run_at: 750 }),
..Default::default()
},
500,
)
.unwrap();
assert_eq!(rewound.task.updated_at, 1000);
let mut recovered = rewound.clone();
recovered.recover(800).unwrap();
recovered.validate().unwrap();
assert_eq!(recovered.task.updated_at, 1000);
let fire = rewound.claim(750).unwrap().unwrap().clone();
assert_eq!(fire.scheduled_at, 750);
assert_eq!(rewound.task.updated_at, 1000);
let mut restored: Plan =
serde_json::from_value(serde_json::to_value(&rewound).unwrap()).unwrap();
restored.validate().unwrap();
assert_eq!(restored.pending.as_ref(), Some(&fire));
restored.pending.as_mut().unwrap().scheduled_at = -1;
assert!(restored.validate().is_err());
let now = 2 * 366 * 24 * 60 * 60 * 1000;
let mut next = plan
.update(
Update {
schedule: Some(Schedule::Once { run_at: now + 1000 }),
..Update::default()
},
now,
)
.unwrap();
next.validate().unwrap();
// Unrelated later edits must not revalidate against the original creation time.
next = next
.update(
Update {
title: Some("Rescheduled".into()),
..Update::default()
},
now,
)
.unwrap();
let fire = next.claim(now + 1000).unwrap().unwrap().clone();
next = next
.update(
Update {
title: Some("New title".into()),
schedule: Some(Schedule::Interval {
every_seconds: 10,
start_at: now,
}),
..Update::default()
},
now + 1000,
)
.unwrap()
.pause(now + 1000);
assert_eq!(next.pending.as_ref(), Some(&fire));
next.settle(Run {
id: fire.id,
at: now + 2000,
outcome: Outcome::Ok,
message: "admitted".into(),
session_id: Some("session".into()),
run_id: Some("run".into()),
})
.unwrap();
assert_eq!(next.task.status, Status::Paused);
assert_eq!(next.task.fire_count, 1);
next = next
.resume(now + 3000)
.unwrap()
.snooze(1000, now + 3000)
.unwrap();
assert_eq!(next.task.next_fire_at, Some(now + 11_000));
assert!(next.snooze(0, now + 3000).is_err());
let cleared = next.clear_history(now + 4000);
assert!(cleared.task.runs.is_empty());
assert_eq!(cleared.task.fire_count, 1);
}
}