mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use async_trait::async_trait;
use common::{TestClock, TestTool, ToolBehavior, event_kinds, fixed_random, fixed_run_id};
use salvor_core::{Effect, Event, EventEnvelope, RunId, RunStatus, derive_state};
use salvor_runtime::{RunCtx, RuntimeError, ToolCallResult, Waking};
use salvor_store::{EventStore, RunSummary, SqliteStore, StoreError};
use salvor_tools::DynTool;
use serde_json::{Value, json};
use time::macros::datetime;
use time::{Duration, OffsetDateTime};
const START: OffsetDateTime = datetime!(2026-07-09 12:00:00 UTC);
const NAP: Duration = Duration::minutes(30);
const AGENT_HASH: &str = "sha256:nap-flow-v1";
#[derive(Debug)]
enum FlowOutcome {
Sleeping(OffsetDateTime),
Completed(Value),
}
async fn nap_flow(
ctx: &mut RunCtx,
poll: &dyn DynTool,
nap: Duration,
) -> Result<FlowOutcome, RuntimeError> {
ctx.begin(AGENT_HASH, &json!({"order": "A-1"})).await?;
let before = tool_output(
ctx.tool_call(poll, &json!({"phase": "before"}), None)
.await?,
);
let wake_at = ctx.sleep_for(nap).await?;
match ctx.await_wake().await? {
Waking::Asleep { wake_at } => return Ok(FlowOutcome::Sleeping(wake_at)),
Waking::Woken => {}
}
let after = tool_output(
ctx.tool_call(poll, &json!({"phase": "after"}), None)
.await?,
);
let output = json!({"before": before, "after": after, "woke_at": wake_at.to_string()});
ctx.complete_run(&output).await?;
Ok(FlowOutcome::Completed(output))
}
fn tool_output(result: ToolCallResult) -> Value {
match result {
ToolCallResult::Output(output) => output,
other => panic!("the echo tool must produce output, got {other:?}"),
}
}
async fn drive_once(
store: Arc<dyn EventStore>,
run_id: RunId,
clock: &TestClock,
poll: &dyn DynTool,
nap: Duration,
) -> Result<FlowOutcome, RuntimeError> {
let log = store.read_log(run_id).await?;
let mut ctx = RunCtx::with_hooks(store, run_id, log, clock.injected(), fixed_random())?;
nap_flow(&mut ctx, poll, nap).await
}
async fn drive_to_completion(
store: Arc<dyn EventStore>,
run_id: RunId,
clock: &TestClock,
poll: &dyn DynTool,
) -> Result<Value, RuntimeError> {
for _ in 0..8 {
match drive_once(store.clone(), run_id, clock, poll, NAP).await? {
FlowOutcome::Completed(output) => return Ok(output),
FlowOutcome::Sleeping(wake_at) => {
assert!(
clock.read() < wake_at,
"a run reported asleep past its own deadline"
);
clock.set(wake_at);
}
}
}
panic!("the flow neither completed nor made progress");
}
fn poll_tool() -> (TestTool, Arc<AtomicUsize>) {
TestTool::new("poll", Effect::Read, ToolBehavior::Echo)
}
fn store() -> Arc<dyn EventStore> {
Arc::new(SqliteStore::in_memory().expect("store opens"))
}
const COMPLETED_KINDS: [&str; 9] = [
"RunStarted",
"ToolCallRequested",
"ToolCallCompleted",
"NowObserved",
"SleepStarted",
"SleepCompleted",
"ToolCallRequested",
"ToolCallCompleted",
"RunCompleted",
];
#[tokio::test]
async fn a_run_sleeps_parks_and_cannot_be_woken_early() {
let store = store();
let run_id = fixed_run_id(60);
let clock = TestClock::new(START);
let (poll, calls) = poll_tool();
let outcome = drive_once(store.clone(), run_id, &clock, &poll, NAP)
.await
.expect("the first drive parks");
let FlowOutcome::Sleeping(wake_at) = outcome else {
panic!("expected the run to park on its timer");
};
assert_eq!(wake_at, START + NAP, "the deadline is the recorded reading");
let parked = store.read_log(run_id).await.expect("log reads");
assert_eq!(
event_kinds(&parked),
[
"RunStarted",
"ToolCallRequested",
"ToolCallCompleted",
"NowObserved",
"SleepStarted"
],
"nothing is recorded past the started sleep"
);
assert_eq!(
derive_state(&parked).status,
RunStatus::Sleeping { wake_at }
);
let outcome = drive_once(store.clone(), run_id, &clock, &poll, NAP)
.await
.expect("an early drive parks again");
assert!(matches!(outcome, FlowOutcome::Sleeping(again) if again == wake_at));
assert_eq!(
store.read_log(run_id).await.expect("log reads"),
parked,
"an early drive appends nothing at all"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"the replayed poll never re-executed"
);
}
#[tokio::test]
async fn a_recorded_wake_continues_the_run_and_replays() {
let store = store();
let run_id = fixed_run_id(61);
let clock = TestClock::new(START);
let (poll, calls) = poll_tool();
let output = drive_to_completion(store.clone(), run_id, &clock, &poll)
.await
.expect("the run completes once its deadline passes");
assert_eq!(output["before"], json!({"echo": {"phase": "before"}}));
assert_eq!(output["after"], json!({"echo": {"phase": "after"}}));
let finished = store.read_log(run_id).await.expect("log reads");
assert_eq!(event_kinds(&finished), COMPLETED_KINDS);
assert!(matches!(
derive_state(&finished).status,
RunStatus::Completed { .. }
));
assert_eq!(calls.load(Ordering::SeqCst), 2, "each poll executed once");
clock.set(START + Duration::days(30));
let replayed = drive_once(store.clone(), run_id, &clock, &poll, NAP)
.await
.expect("the finished run replays");
assert!(matches!(replayed, FlowOutcome::Completed(ref again) if *again == output));
assert_eq!(
store.read_log(run_id).await.expect("log reads"),
finished,
"a replayed drive appends nothing"
);
assert_eq!(calls.load(Ordering::SeqCst), 2, "and executes nothing");
}
#[tokio::test]
async fn selection_and_a_re_drive_are_the_whole_of_waking() {
let store = store();
let run_id = fixed_run_id(63);
let clock = TestClock::new(START);
let (poll, calls) = poll_tool();
let outcome = drive_once(store.clone(), run_id, &clock, &poll, NAP)
.await
.expect("the first drive parks");
let FlowOutcome::Sleeping(wake_at) = outcome else {
panic!("expected the run to park on its timer");
};
assert!(
salvor_runtime::due_runs(store.as_ref(), clock.read())
.await
.expect("selection reads")
.is_empty(),
"a sleeping run is not due before its instant"
);
clock.set(wake_at);
let due = salvor_runtime::due_runs(store.as_ref(), clock.read())
.await
.expect("selection reads");
assert_eq!(due.len(), 1, "exactly the one sleeping run");
assert_eq!(due[0].run_id, run_id);
assert_eq!(due[0].wake_at, wake_at);
let outcome = drive_once(store.clone(), run_id, &clock, &poll, NAP)
.await
.expect("the due run drives");
assert!(
matches!(outcome, FlowOutcome::Completed(_)),
"a run driven past its deadline wakes and continues, got {outcome:?}"
);
let finished = store.read_log(run_id).await.expect("log reads");
assert_eq!(event_kinds(&finished), COMPLETED_KINDS);
assert_eq!(calls.load(Ordering::SeqCst), 2, "each poll executed once");
clock.set(wake_at + Duration::days(30));
assert!(
salvor_runtime::due_runs(store.as_ref(), clock.read())
.await
.expect("selection reads")
.is_empty(),
"a woken run is not due a second time"
);
}
#[tokio::test]
async fn sleep_for_derives_its_instant_from_the_recorded_reading() {
let store = store();
let run_id = fixed_run_id(62);
let clock = TestClock::new(START);
let (poll, _calls) = poll_tool();
drive_to_completion(store.clone(), run_id, &clock, &poll)
.await
.expect("the run completes");
let finished = store.read_log(run_id).await.expect("log reads");
assert_eq!(
finished[3].event,
Event::NowObserved { now: START },
"the reading the instant is derived from is itself recorded"
);
assert_eq!(
finished[4].event,
Event::SleepStarted {
wake_at: START + NAP
},
"and the recorded instant is that reading plus the duration"
);
let before = serde_json::to_string(&finished).expect("serialize");
clock.set(START - Duration::days(365));
drive_once(store.clone(), run_id, &clock, &poll, NAP)
.await
.expect("the recorded run replays under a different clock");
let after = serde_json::to_string(&store.read_log(run_id).await.expect("log reads"))
.expect("serialize");
assert_eq!(before, after, "the replayed log is byte for byte the same");
}
#[tokio::test]
async fn a_recomputed_wake_instant_diverges() {
let store = store();
let run_id = fixed_run_id(63);
let clock = TestClock::new(START);
let (poll, _calls) = poll_tool();
drive_once(store.clone(), run_id, &clock, &poll, NAP)
.await
.expect("the first drive parks");
let parked = store.read_log(run_id).await.expect("log reads");
let error = drive_once(
store.clone(),
run_id,
&clock,
&poll,
NAP + Duration::nanoseconds(1),
)
.await
.expect_err("a different derivation must not replay");
assert!(
matches!(error, RuntimeError::Replay(_)),
"expected a divergence, got {error}"
);
assert_eq!(
store.read_log(run_id).await.expect("log reads"),
parked,
"a diverging drive records nothing"
);
}
struct KillStore {
inner: Arc<dyn EventStore>,
remaining: AtomicI64,
}
#[async_trait]
impl EventStore for KillStore {
async fn append(&self, envelope: &EventEnvelope) -> Result<(), StoreError> {
if self.remaining.fetch_sub(1, Ordering::SeqCst) <= 0 {
return Err(StoreError::Backend("simulated crash".to_owned()));
}
self.inner.append(envelope).await
}
async fn read_log(&self, run_id: RunId) -> Result<Vec<EventEnvelope>, StoreError> {
self.inner.read_log(run_id).await
}
async fn list_runs(&self) -> Result<Vec<RunSummary>, StoreError> {
self.inner.list_runs().await
}
async fn claim_call(
&self,
claimant: salvor_store::CallClaimant<'_>,
) -> Result<salvor_store::CallClaim, StoreError> {
self.inner.claim_call(claimant).await
}
async fn lookup_call(
&self,
tool: &str,
idempotency_key: &str,
) -> Result<Option<salvor_store::CallCommitment>, StoreError> {
self.inner.lookup_call(tool, idempotency_key).await
}
async fn append_settling_call(
&self,
envelope: &EventEnvelope,
claimant: salvor_store::CallClaimant<'_>,
) -> Result<(), StoreError> {
if self.remaining.fetch_sub(1, Ordering::SeqCst) <= 0 {
return Err(StoreError::Backend("simulated crash".to_owned()));
}
self.inner.append_settling_call(envelope, claimant).await
}
}
#[tokio::test]
async fn a_kill_at_every_boundary_recovers_the_same_log() {
let control_store = store();
let control_clock = TestClock::new(START);
let (poll, _calls) = poll_tool();
drive_to_completion(
control_store.clone(),
fixed_run_id(64),
&control_clock,
&poll,
)
.await
.expect("the control run completes");
let control = control_store
.read_log(fixed_run_id(64))
.await
.expect("log reads");
assert_eq!(control.len(), COMPLETED_KINDS.len());
for allow in 1..control.len() {
let store = store();
let run_id = fixed_run_id(70 + u8::try_from(allow).expect("small"));
let clock = TestClock::new(START);
let (poll, _calls) = poll_tool();
let killed: Arc<dyn EventStore> = Arc::new(KillStore {
inner: store.clone(),
remaining: AtomicI64::new(i64::try_from(allow).expect("small")),
});
let error = drive_to_completion(killed, run_id, &clock, &poll)
.await
.expect_err("the kill store aborts the drive");
assert!(
matches!(error, RuntimeError::Store(_)),
"cut at {allow}: {error}"
);
assert_eq!(
store.read_log(run_id).await.expect("log reads").len(),
allow,
"cut at {allow}: exactly the budgeted number of events persisted"
);
drive_to_completion(store.clone(), run_id, &clock, &poll)
.await
.expect("recovery completes the run");
let recovered = store.read_log(run_id).await.expect("log reads");
assert_eq!(
recovered.len(),
control.len(),
"cut at {allow}: the recovered run records the same events"
);
for (index, (got, want)) in recovered.iter().zip(control.iter()).enumerate() {
assert_eq!(got.seq, want.seq, "cut at {allow}, event {index}");
assert_eq!(got.event, want.event, "cut at {allow}, event {index}");
assert_eq!(
got.recorded_at, want.recorded_at,
"cut at {allow}, event {index}"
);
}
}
}