use std::collections::{BTreeMap, HashMap};
use super::state::WorkflowState;
use super::types::{TaskId, TaskStatus};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Trigger {
AfterTask(TaskId),
IfResult {
task: TaskId,
key: String,
value: String,
},
AfterTerminal(TaskId),
AtTick(u64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Submit(TaskId),
Start(TaskId),
}
pub struct TriggerWorld<'a> {
pub tasks: &'a WorkflowState,
pub tick: u64,
pub results: &'a HashMap<TaskId, HashMap<String, String>>,
}
impl<'a> TriggerWorld<'a> {
pub fn new(tasks: &'a WorkflowState, tick: u64) -> Self {
Self {
tasks,
tick,
results: EMPTY_RESULTS.get_or_init(HashMap::new),
}
}
pub fn with_results(
tasks: &'a WorkflowState,
tick: u64,
results: &'a HashMap<TaskId, HashMap<String, String>>,
) -> Self {
Self {
tasks,
tick,
results,
}
}
fn task_done(&self, task: TaskId) -> bool {
self.tasks
.get(task)
.map(|s| s.status == TaskStatus::Done)
.unwrap_or(false)
}
fn task_terminal(&self, task: TaskId) -> bool {
self.tasks
.get(task)
.map(|s| s.status.is_terminal())
.unwrap_or(false)
}
}
static EMPTY_RESULTS: std::sync::OnceLock<HashMap<TaskId, HashMap<String, String>>> =
std::sync::OnceLock::new();
impl Trigger {
pub fn is_satisfied(&self, world: &TriggerWorld<'_>) -> bool {
match self {
Trigger::AfterTask(task) => world.task_done(*task),
Trigger::IfResult { task, key, value } => {
world.task_done(*task)
&& world
.results
.get(task)
.and_then(|m| m.get(key))
.map(|v| v == value)
.unwrap_or(false)
}
Trigger::AfterTerminal(task) => world.task_terminal(*task),
Trigger::AtTick(tick) => world.tick >= *tick,
}
}
fn is_dead(&self, world: &TriggerWorld<'_>) -> bool {
match self {
Trigger::AfterTask(task) | Trigger::IfResult { task, .. } => {
world.task_terminal(*task) && !self.is_satisfied(world)
}
Trigger::AfterTerminal(_) | Trigger::AtTick(_) => false,
}
}
fn key(&self) -> TriggerKey {
match self {
Trigger::AfterTask(task)
| Trigger::IfResult { task, .. }
| Trigger::AfterTerminal(task) => TriggerKey::Task(*task),
Trigger::AtTick(tick) => TriggerKey::Tick(*tick),
}
}
}
enum TriggerKey {
Task(TaskId),
Tick(u64),
}
#[derive(Default)]
pub struct TriggerEngine {
by_task: HashMap<TaskId, Vec<(Trigger, Action)>>,
by_tick: BTreeMap<u64, Vec<(Trigger, Action)>>,
}
impl TriggerEngine {
pub fn new() -> Self {
Self::default()
}
pub fn arm(&mut self, trigger: Trigger, action: Action) {
match trigger.key() {
TriggerKey::Task(task) => self
.by_task
.entry(task)
.or_default()
.push((trigger, action)),
TriggerKey::Tick(tick) => self
.by_tick
.entry(tick)
.or_default()
.push((trigger, action)),
}
}
pub fn on_task_change(&mut self, task: TaskId, world: &TriggerWorld<'_>) -> Vec<Action> {
let Some(armed) = self.by_task.remove(&task) else {
return Vec::new();
};
let (fired, rest): (Vec<_>, Vec<_>) =
armed.into_iter().partition(|(t, _)| t.is_satisfied(world));
let still_armed: Vec<_> = rest
.into_iter()
.filter(|(t, _)| !t.is_dead(world))
.collect();
if !still_armed.is_empty() {
self.by_task.insert(task, still_armed);
}
fired.into_iter().map(|(_, action)| action).collect()
}
pub fn on_tick(&mut self, world: &TriggerWorld<'_>) -> Vec<Action> {
let future = self.by_tick.split_off(&world.tick.saturating_add(1));
let due = std::mem::replace(&mut self.by_tick, future);
due.into_values()
.flatten()
.map(|(_, action)| action)
.collect()
}
pub fn on_delete(&mut self, task: TaskId) -> usize {
self.by_task.remove(&task).map(|v| v.len()).unwrap_or(0)
}
pub fn armed_count(&self) -> usize {
self.by_task.values().map(Vec::len).sum::<usize>()
+ self.by_tick.values().map(Vec::len).sum::<usize>()
}
}
#[cfg(test)]
mod tests {
use super::super::types::TaskState;
use super::*;
fn results(pairs: &[(TaskId, &str, &str)]) -> HashMap<TaskId, HashMap<String, String>> {
let mut m: HashMap<TaskId, HashMap<String, String>> = HashMap::new();
for (task, k, v) in pairs {
m.entry(*task)
.or_default()
.insert((*k).to_string(), (*v).to_string());
}
m
}
fn state_with(pairs: &[(TaskId, TaskStatus)]) -> WorkflowState {
let mut s = WorkflowState::new();
for (id, status) in pairs {
s.tasks.insert(
*id,
TaskState {
step: 0,
status: *status,
attempts: 0,
},
);
}
s
}
#[test]
fn after_task_satisfied_only_when_done() {
let pending = state_with(&[(1, TaskStatus::Running)]);
let done = state_with(&[(1, TaskStatus::Done)]);
let t = Trigger::AfterTask(1);
assert!(!t.is_satisfied(&TriggerWorld::new(&pending, 0)));
assert!(t.is_satisfied(&TriggerWorld::new(&done, 0)));
assert!(!t.is_satisfied(&TriggerWorld::new(&WorkflowState::new(), 0)));
}
#[test]
fn if_result_needs_done_and_matching_value() {
let done = state_with(&[(1, TaskStatus::Done)]);
let res = results(&[(1, "branch", "left")]);
let world = TriggerWorld {
tasks: &done,
tick: 0,
results: &res,
};
let left = Trigger::IfResult {
task: 1,
key: "branch".into(),
value: "left".into(),
};
let right = Trigger::IfResult {
task: 1,
key: "branch".into(),
value: "right".into(),
};
assert!(left.is_satisfied(&world));
assert!(!right.is_satisfied(&world)); let empty = HashMap::new();
let world2 = TriggerWorld {
tasks: &done,
tick: 0,
results: &empty,
};
assert!(!left.is_satisfied(&world2));
}
#[test]
fn after_terminal_fires_on_done_or_failed_but_not_running() {
let t = Trigger::AfterTerminal(1);
let running = state_with(&[(1, TaskStatus::Running)]);
let waiting = state_with(&[(1, TaskStatus::Waiting)]);
let done = state_with(&[(1, TaskStatus::Done)]);
let failed = state_with(&[(1, TaskStatus::Failed)]);
assert!(!t.is_satisfied(&TriggerWorld::new(&running, 0)));
assert!(!t.is_satisfied(&TriggerWorld::new(&waiting, 0)));
assert!(t.is_satisfied(&TriggerWorld::new(&done, 0)));
assert!(t.is_satisfied(&TriggerWorld::new(&failed, 0)));
assert!(!Trigger::AfterTask(1).is_satisfied(&TriggerWorld::new(&failed, 0)));
assert!(!t.is_satisfied(&TriggerWorld::new(&WorkflowState::new(), 0)));
}
#[test]
fn at_tick_fires_once_clock_reaches_it() {
let s = WorkflowState::new();
let t = Trigger::AtTick(5);
assert!(!t.is_satisfied(&TriggerWorld::new(&s, 4)));
assert!(t.is_satisfied(&TriggerWorld::new(&s, 5)));
assert!(t.is_satisfied(&TriggerWorld::new(&s, 9)));
}
#[test]
fn engine_fires_only_triggers_keyed_to_the_event() {
let mut eng = TriggerEngine::new();
eng.arm(Trigger::AfterTask(1), Action::Submit(10));
eng.arm(Trigger::AfterTask(2), Action::Submit(20));
assert_eq!(eng.armed_count(), 2);
let done1 = state_with(&[(1, TaskStatus::Done), (2, TaskStatus::Running)]);
let fired = eng.on_task_change(1, &TriggerWorld::new(&done1, 0));
assert_eq!(fired, vec![Action::Submit(10)]);
assert_eq!(eng.armed_count(), 1);
}
#[test]
fn unsatisfied_trigger_stays_armed_until_condition_holds() {
let mut eng = TriggerEngine::new();
eng.arm(Trigger::AfterTask(1), Action::Start(10));
let running = state_with(&[(1, TaskStatus::Running)]);
assert!(eng
.on_task_change(1, &TriggerWorld::new(&running, 0))
.is_empty());
assert_eq!(eng.armed_count(), 1);
let done = state_with(&[(1, TaskStatus::Done)]);
assert_eq!(
eng.on_task_change(1, &TriggerWorld::new(&done, 0)),
vec![Action::Start(10)]
);
assert_eq!(eng.armed_count(), 0);
}
#[test]
fn branch_fires_exactly_the_matching_arm() {
let mut eng = TriggerEngine::new();
eng.arm(
Trigger::IfResult {
task: 1,
key: "branch".into(),
value: "left".into(),
},
Action::Submit(10),
);
eng.arm(
Trigger::IfResult {
task: 1,
key: "branch".into(),
value: "right".into(),
},
Action::Submit(20),
);
let done = state_with(&[(1, TaskStatus::Done)]);
let res = results(&[(1, "branch", "left")]);
let world = TriggerWorld {
tasks: &done,
tick: 0,
results: &res,
};
assert_eq!(eng.on_task_change(1, &world), vec![Action::Submit(10)]);
assert_eq!(eng.armed_count(), 0);
}
#[test]
fn after_task_on_a_failed_task_is_disarmed() {
let mut eng = TriggerEngine::new();
eng.arm(Trigger::AfterTask(1), Action::Submit(10));
assert_eq!(eng.armed_count(), 1);
let failed = state_with(&[(1, TaskStatus::Failed)]);
let world = TriggerWorld::new(&failed, 0);
assert!(eng.on_task_change(1, &world).is_empty());
assert_eq!(eng.armed_count(), 0);
}
#[test]
fn on_delete_prunes_triggers_waiting_on_the_task() {
let mut eng = TriggerEngine::new();
eng.arm(Trigger::AfterTask(1), Action::Submit(10));
eng.arm(Trigger::AfterTerminal(1), Action::Submit(11));
eng.arm(Trigger::AfterTask(2), Action::Submit(20));
assert_eq!(eng.armed_count(), 3);
assert_eq!(eng.on_delete(1), 2);
assert_eq!(eng.armed_count(), 1);
assert_eq!(eng.on_delete(99), 0);
let done2 = state_with(&[(2, TaskStatus::Done)]);
assert_eq!(
eng.on_task_change(2, &TriggerWorld::new(&done2, 0)),
vec![Action::Submit(20)]
);
}
#[test]
fn tick_triggers_fire_on_clock_advance() {
let mut eng = TriggerEngine::new();
eng.arm(Trigger::AtTick(3), Action::Submit(1));
let s = WorkflowState::new();
assert!(eng.on_tick(&TriggerWorld::new(&s, 2)).is_empty());
assert_eq!(eng.armed_count(), 1);
assert_eq!(
eng.on_tick(&TriggerWorld::new(&s, 3)),
vec![Action::Submit(1)]
);
assert_eq!(eng.armed_count(), 0);
}
#[test]
fn on_tick_drains_only_the_due_prefix_and_keeps_future_triggers() {
let mut eng = TriggerEngine::new();
eng.arm(Trigger::AtTick(3), Action::Submit(1));
eng.arm(Trigger::AtTick(5), Action::Submit(2));
eng.arm(Trigger::AtTick(5), Action::Submit(3)); eng.arm(Trigger::AtTick(9), Action::Submit(4));
assert_eq!(eng.armed_count(), 4);
let s = WorkflowState::new();
assert_eq!(
eng.on_tick(&TriggerWorld::new(&s, 5)),
vec![Action::Submit(1), Action::Submit(2), Action::Submit(3)],
);
assert_eq!(eng.armed_count(), 1);
assert!(eng.on_tick(&TriggerWorld::new(&s, 8)).is_empty());
assert_eq!(eng.armed_count(), 1);
assert_eq!(
eng.on_tick(&TriggerWorld::new(&s, 9)),
vec![Action::Submit(4)]
);
assert_eq!(eng.armed_count(), 0);
}
#[tokio::test]
async fn dependent_task_auto_submits_on_predecessor_done() {
use super::super::WorkflowAdapter;
use crate::adapter::net::redex::Redex;
const A: TaskId = 1;
const B: TaskId = 2;
let redex = Redex::new();
let wf = WorkflowAdapter::open(&redex, 0x0F10_00B1).await.unwrap();
let mut eng = TriggerEngine::new();
eng.arm(Trigger::AfterTask(A), Action::Submit(B));
wf.submit(A).unwrap();
wf.start(A).unwrap();
let seq = wf.complete(A).unwrap();
wf.wait_for_seq(seq).await.unwrap();
assert!(wf.get(B).is_none(), "B not submitted until A is Done");
let actions = {
let state = wf.state();
let guard = state.read();
eng.on_task_change(A, &TriggerWorld::new(&guard, 0))
};
assert_eq!(actions, vec![Action::Submit(B)]);
let mut last = 0;
for action in actions {
last = match action {
Action::Submit(id) => wf.submit(id).unwrap(),
Action::Start(id) => wf.start(id).unwrap(),
};
}
wf.wait_for_seq(last).await.unwrap();
assert!(wf.get(B).is_some(), "B auto-submitted on A's Done");
assert_eq!(eng.armed_count(), 0);
}
}