mod common;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use common::{EchoTool, TestClock, event_kinds, fixed_random, fixed_run_id};
use salvor_core::{Effect, Event, EventEnvelope, RunStatus, derive_state};
use salvor_engine::{GraphOutcome, run_graph};
use salvor_graph::{DelaySpec, Graph, GraphBuilder, ToolSpec};
use salvor_replay::{NodeState, ParkReason, derive_graph_projection};
use salvor_runtime::{Agent, RunCtx};
use salvor_store::{EventStore, SqliteStore};
use salvor_tools::DynTool;
use serde_json::{Value, json};
use time::OffsetDateTime;
use time::macros::datetime;
type ToolRegistry = HashMap<String, Box<dyn DynTool>>;
const START: OffsetDateTime = datetime!(2026-08-14 08:00:00 UTC);
const WAKE_AT: OffsetDateTime = datetime!(2026-08-14 09:00:00 UTC);
const WAIT_SECONDS: u64 = 3600;
fn input() -> Value {
json!({"order": "A-1"})
}
fn delay_graph() -> Graph {
GraphBuilder::new()
.tool(ToolSpec::new("assess", "assess_tool"))
.delay(DelaySpec::new("cooloff", WAIT_SECONDS).name("Cool off before publishing"))
.tool(ToolSpec::new("publish", "publish_tool"))
.edge("assess", "cooloff")
.edge("cooloff", "publish")
.build()
}
fn tools() -> (ToolRegistry, Arc<AtomicUsize>, Arc<AtomicUsize>) {
let (assess, assess_calls) = EchoTool::new("assess_tool", Effect::Read);
let (publish, publish_calls) = EchoTool::new("publish_tool", Effect::Read);
let mut registry: ToolRegistry = HashMap::new();
registry.insert("assess_tool".to_owned(), Box::new(assess));
registry.insert("publish_tool".to_owned(), Box::new(publish));
(registry, assess_calls, publish_calls)
}
fn no_agents() -> HashMap<String, Agent> {
HashMap::new()
}
const EXPECTED_KINDS: [&str; 15] = [
"GraphRunStarted",
"NodeEntered", "ToolCallRequested",
"ToolCallCompleted",
"NodeExited", "NodeEntered", "NowObserved",
"SleepStarted",
"SleepCompleted",
"NodeExited", "NodeEntered", "ToolCallRequested",
"ToolCallCompleted",
"NodeExited", "RunCompleted",
];
#[tokio::test]
async fn a_delay_node_parks_on_its_own_timer_and_the_walk_continues() {
let graph = delay_graph();
let run_id = fixed_run_id(80);
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let clock = TestClock::new(START);
let (registry, assess_calls, publish_calls) = tools();
let mut ctx = RunCtx::with_hooks(
store.clone(),
run_id,
vec![],
clock.injected(),
fixed_random(),
)
.expect("ctx builds");
let outcome = run_graph(&mut ctx, &graph, &input(), &no_agents(), ®istry)
.await
.expect("the graph drives");
match outcome {
GraphOutcome::Parked {
node,
reason: ParkReason::Sleeping { wake_at },
} => {
assert_eq!(node, "cooloff", "the node the run is parked at");
assert_eq!(wake_at, WAKE_AT, "an hour past the recorded reading");
}
other => panic!("expected a timer park, got {other:?}"),
}
let log = store.read_log(run_id).await.expect("log reads");
assert_eq!(
event_kinds(&log),
&EXPECTED_KINDS[..8],
"no NodeExited: the node is still the one the run is in"
);
assert!(
matches!(log[6].event, Event::NowObserved { .. })
&& matches!(log[7].event, Event::SleepStarted { wake_at } if wake_at == WAKE_AT)
);
assert_eq!(
derive_state(&log).status,
RunStatus::Sleeping { wake_at: WAKE_AT }
);
let projection = derive_graph_projection(&log);
assert_eq!(
projection.current_node.as_deref(),
Some("cooloff"),
"the projection shows the run sitting inside the delay"
);
assert_eq!(
projection
.nodes
.iter()
.find(|node| node.node == "cooloff")
.map(|node| node.state.clone()),
Some(NodeState::Entered),
"entered, never exited"
);
clock.set(WAKE_AT - time::Duration::minutes(1));
let mut ctx = RunCtx::with_hooks(
store.clone(),
run_id,
log.clone(),
clock.injected(),
fixed_random(),
)
.expect("ctx builds");
let early = run_graph(&mut ctx, &graph, &input(), &no_agents(), ®istry)
.await
.expect("an early drive is not an error");
assert!(
matches!(
early,
GraphOutcome::Parked {
reason: ParkReason::Sleeping { .. },
..
}
),
"still asleep: {early:?}"
);
assert_eq!(
store.read_log(run_id).await.expect("log reads"),
log,
"an early drive appends nothing"
);
assert_eq!(
assess_calls.load(Ordering::SeqCst),
1,
"and re-executes nothing"
);
assert_eq!(publish_calls.load(Ordering::SeqCst), 0);
clock.set(WAKE_AT);
let mut ctx = RunCtx::with_hooks(store.clone(), run_id, log, clock.injected(), fixed_random())
.expect("ctx builds");
let outcome = run_graph(&mut ctx, &graph, &input(), &no_agents(), ®istry)
.await
.expect("the woken graph drives");
let GraphOutcome::Completed { output } = outcome else {
panic!("expected completion, got {outcome:?}");
};
assert_eq!(
output,
json!({"published": {"published": {"order": "A-1"}}}),
"the delay's output is its input verbatim"
);
let log = store.read_log(run_id).await.expect("log reads");
assert_eq!(event_kinds(&log), EXPECTED_KINDS);
assert_eq!(assess_calls.load(Ordering::SeqCst), 1);
assert_eq!(publish_calls.load(Ordering::SeqCst), 1);
let mut ctx = RunCtx::with_hooks(
store.clone(),
run_id,
log.clone(),
clock.injected(),
fixed_random(),
)
.expect("ctx builds");
let replayed = run_graph(&mut ctx, &graph, &input(), &no_agents(), ®istry)
.await
.expect("the replay is divergence free");
assert!(matches!(replayed, GraphOutcome::Completed { .. }));
assert_eq!(
store.read_log(run_id).await.expect("log reads"),
log,
"a replay appends nothing"
);
assert_eq!(assess_calls.load(Ordering::SeqCst), 1);
assert_eq!(publish_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_delay_hands_on_exactly_what_reached_it() {
let log = uninterrupted_run(fixed_run_id(81)).await;
let assessed = log
.iter()
.find_map(|envelope| match &envelope.event {
Event::ToolCallCompleted { output, .. } => Some(output.clone()),
_ => None,
})
.expect("assess completed");
let published = log
.iter()
.filter_map(|envelope| match &envelope.event {
Event::ToolCallRequested { input, .. } => Some(input.clone()),
_ => None,
})
.nth(1)
.expect("publish was called");
assert_eq!(
published, assessed,
"the delay passed the upstream output through untouched"
);
}
#[tokio::test]
async fn a_delay_run_holds_the_property_at_every_kill_boundary() {
let graph = delay_graph();
let run_id = fixed_run_id(82);
let control = uninterrupted_run(run_id).await;
assert_eq!(event_kinds(&control), EXPECTED_KINDS);
let woke_at_index = control
.iter()
.position(|envelope| matches!(envelope.event, Event::SleepCompleted {}))
.expect("the control run woke");
let completions_at_or_after = |k: usize| -> usize {
control
.iter()
.filter(|envelope| (envelope.seq.get() as usize) >= k)
.filter(|envelope| matches!(envelope.event, Event::ToolCallCompleted { .. }))
.count()
};
for k in 0..=control.len() {
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
for envelope in &control[..k] {
store.append(envelope).await.expect("seed append");
}
let prefix: Vec<EventEnvelope> = control[..k].to_vec();
let (registry, assess_calls, publish_calls) = tools();
let asleep_in_prefix = k > woke_at_index;
let clock = TestClock::new(if asleep_in_prefix { WAKE_AT } else { START });
let mut ctx = RunCtx::with_hooks(
store.clone(),
run_id,
prefix.clone(),
clock.injected(),
fixed_random(),
)
.expect("resume ctx builds");
let outcome = run_graph(&mut ctx, &graph, &input(), &no_agents(), ®istry)
.await
.unwrap_or_else(|error| panic!("resume from cut {k} drives: {error}"));
if asleep_in_prefix {
assert!(
matches!(outcome, GraphOutcome::Completed { .. }),
"resume from cut {k}, already past the wake, completes in one drive"
);
} else {
assert!(
matches!(
outcome,
GraphOutcome::Parked {
reason: ParkReason::Sleeping { .. },
..
}
),
"resume from cut {k} must park on the delay, got {outcome:?}"
);
clock.set(WAKE_AT);
let log = store.read_log(run_id).await.expect("log reads");
let mut ctx =
RunCtx::with_hooks(store.clone(), run_id, log, clock.injected(), fixed_random())
.expect("woken ctx builds");
let outcome = run_graph(&mut ctx, &graph, &input(), &no_agents(), ®istry)
.await
.unwrap_or_else(|error| panic!("cut {k} does not finish after the wake: {error}"));
assert!(
matches!(outcome, GraphOutcome::Completed { .. }),
"resume from cut {k} completes once the deadline passes"
);
}
let recovered = store.read_log(run_id).await.expect("log reads");
assert_eq!(
serde_json::to_string(&recovered).expect("serialize"),
serde_json::to_string(&control).expect("serialize"),
"resume from cut {k} must reproduce the byte-identical log"
);
assert_eq!(
assess_calls.load(Ordering::SeqCst) + publish_calls.load(Ordering::SeqCst),
completions_at_or_after(k),
"resume from cut {k} executed exactly the not-yet-completed calls"
);
}
}
async fn uninterrupted_run(run_id: salvor_core::RunId) -> Vec<EventEnvelope> {
let graph = delay_graph();
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let clock = TestClock::new(START);
let (registry, _, _) = tools();
let mut ctx = RunCtx::with_hooks(
store.clone(),
run_id,
vec![],
clock.injected(),
fixed_random(),
)
.expect("ctx builds");
run_graph(&mut ctx, &graph, &input(), &no_agents(), ®istry)
.await
.expect("the graph parks");
clock.set(WAKE_AT);
let log = store.read_log(run_id).await.expect("log reads");
let mut ctx = RunCtx::with_hooks(store.clone(), run_id, log, clock.injected(), fixed_random())
.expect("ctx builds");
run_graph(&mut ctx, &graph, &input(), &no_agents(), ®istry)
.await
.expect("the woken graph completes");
store.read_log(run_id).await.expect("log reads")
}