mod common;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use common::{EchoTool, event_kinds, fixed_clock, fixed_random, fixed_run_id};
use salvor_core::{Effect, Event, EventEnvelope};
use salvor_engine::{ForkError, graph_hash};
use salvor_engine::{GraphOutcome, plan_fork, run_graph};
use salvor_graph::{Graph, GraphBuilder, MapBody, MapSpec, ToolSpec};
use salvor_replay::{MapIteration, NodeState, derive_graph_projection};
use salvor_runtime::RunCtx;
use salvor_store::{EventStore, SqliteStore};
use salvor_tools::DynTool;
use serde_json::{Value, json};
fn map_only_graph() -> Graph {
GraphBuilder::new()
.map(MapSpec::new(
"fanout",
"items",
2,
MapBody::Node("worker".into()),
))
.tool(ToolSpec::new("worker", "worker_tool"))
.build()
}
fn worker_tools() -> (
HashMap<String, Box<dyn DynTool>>,
Arc<std::sync::atomic::AtomicUsize>,
) {
let (worker, calls) = EchoTool::new("worker_tool", Effect::Idempotent);
let mut tools: HashMap<String, Box<dyn DynTool>> = HashMap::new();
tools.insert("worker_tool".to_owned(), Box::new(worker));
(tools, calls)
}
fn no_agents() -> HashMap<String, salvor_runtime::Agent> {
HashMap::new()
}
async fn drive_fresh(
graph: &Graph,
input: &Value,
run_id: salvor_core::RunId,
) -> Vec<EventEnvelope> {
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let (tools, _calls) = worker_tools();
let agents = no_agents();
let mut ctx = RunCtx::with_hooks(store.clone(), run_id, vec![], fixed_clock(), fixed_random())
.expect("ctx builds");
let outcome = run_graph(&mut ctx, graph, input, &agents, &tools)
.await
.expect("graph drives");
assert!(
matches!(outcome, GraphOutcome::Completed { .. }),
"map graph completes, got {outcome:?}"
);
store.read_log(run_id).await.expect("log reads")
}
#[tokio::test]
async fn map_fan_out_holds_the_property_at_every_kill_boundary() {
let graph = map_only_graph();
let input = json!({"items": ["a", "b", "c"]});
let run_id = fixed_run_id(30);
let full = drive_fresh(&graph, &input, run_id).await;
assert_eq!(
event_kinds(&full),
[
"GraphRunStarted",
"NodeEntered", "MapFannedOut", "MapIterationStarted", "ToolCallRequested",
"ToolCallCompleted",
"MapIterationJoined", "MapIterationStarted",
"ToolCallRequested",
"ToolCallCompleted",
"MapIterationJoined", "MapIterationStarted",
"ToolCallRequested",
"ToolCallCompleted",
"MapIterationJoined", "NodeExited", "RunCompleted",
]
);
let completions_at_or_after = |k: usize| -> usize {
full.iter()
.filter(|env| (env.seq.get() as usize) >= k)
.filter(|env| matches!(env.event, Event::ToolCallCompleted { .. }))
.count()
};
for k in 0..=full.len() {
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
for env in &full[..k] {
store.append(env).await.expect("seed append");
}
let prefix: Vec<EventEnvelope> = full[..k].to_vec();
let (tools, calls) = worker_tools();
let agents = no_agents();
let mut ctx =
RunCtx::with_hooks(store.clone(), run_id, prefix, fixed_clock(), fixed_random())
.expect("resume ctx builds");
let outcome = run_graph(&mut ctx, &graph, &input, &agents, &tools)
.await
.unwrap_or_else(|e| panic!("resume from cut {k} drives: {e}"));
assert!(
matches!(outcome, GraphOutcome::Completed { .. }),
"resume from cut {k} completes"
);
let recovered = store.read_log(run_id).await.expect("log reads");
assert_eq!(
serde_json::to_string(&recovered).unwrap(),
serde_json::to_string(&full).unwrap(),
"resume from cut {k} must reproduce the byte-identical parent log"
);
assert_eq!(
calls.load(Ordering::SeqCst),
completions_at_or_after(k),
"resume from cut {k} executed exactly the not-yet-completed body calls"
);
}
}
#[tokio::test]
async fn map_joins_in_index_order() {
let graph = map_only_graph();
let input = json!({"items": ["a", "b", "c"]});
let log = drive_fresh(&graph, &input, fixed_run_id(31)).await;
let join_indices: Vec<u64> = log
.iter()
.filter_map(|env| match &env.event {
Event::MapIterationJoined { node, index } if node == "fanout" => Some(*index),
_ => None,
})
.collect();
assert_eq!(join_indices, [0, 1, 2], "joins recorded in index order");
let Event::RunCompleted { output } = &log.last().unwrap().event else {
panic!("last event is the terminal");
};
assert_eq!(
output,
&json!([
{"published": "a"},
{"published": "b"},
{"published": "c"},
]),
"joined output is the item-order list of per-element outputs"
);
}
#[tokio::test]
async fn a_completed_map_run_replays_free_and_byte_identical() {
let graph = map_only_graph();
let input = json!({"items": ["x", "y"]});
let run_id = fixed_run_id(32);
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let (tools, calls) = worker_tools();
let agents = no_agents();
let mut ctx = RunCtx::with_hooks(store.clone(), run_id, vec![], fixed_clock(), fixed_random())
.expect("ctx builds");
run_graph(&mut ctx, &graph, &input, &agents, &tools)
.await
.expect("graph drives");
let live_log = store.read_log(run_id).await.expect("log reads");
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"two body calls executed live"
);
let (replay_tools, replay_calls) = worker_tools();
let mut ctx2 = RunCtx::with_hooks(
store.clone(),
run_id,
live_log.clone(),
fixed_clock(),
fixed_random(),
)
.expect("replay ctx builds");
let outcome = run_graph(&mut ctx2, &graph, &input, &agents, &replay_tools)
.await
.expect("replay drives");
assert!(matches!(outcome, GraphOutcome::Completed { .. }));
assert_eq!(
replay_calls.load(Ordering::SeqCst),
0,
"replay makes zero live body calls"
);
let replay_log = store.read_log(run_id).await.expect("log reads");
assert_eq!(
serde_json::to_string(&replay_log).unwrap(),
serde_json::to_string(&live_log).unwrap(),
"replay leaves the log byte-identical"
);
}
#[tokio::test]
async fn map_projection_reads_back() {
let graph = map_only_graph();
let input = json!({"items": ["a", "b"]});
let log = drive_fresh(&graph, &input, fixed_run_id(33)).await;
let projection = derive_graph_projection(&log);
let fanout = projection.node("fanout").expect("fanout was reached");
assert_eq!(fanout.state, NodeState::Exited, "the map exited");
let map = fanout.map.as_ref().expect("the map fanned out");
assert_eq!(
map.items,
json!(["a", "b"]),
"the resolved item list reads back"
);
assert_eq!(map.iterations.len(), 2);
for (i, iteration) in map.iterations.iter().enumerate() {
assert_eq!(
iteration,
&MapIteration {
index: i as u64,
child_run: iteration.child_run.clone(),
joined: true,
}
);
assert!(
iteration.child_run.starts_with("sha256:"),
"the derived child run id is a canonical hash: {}",
iteration.child_run
);
}
assert!(
projection.node("worker").is_none(),
"the body node is not walked"
);
}
#[tokio::test]
async fn map_child_ids_are_derived_and_index_distinct() {
let graph = map_only_graph();
let input = json!({"items": ["a", "b", "c"]});
let child_ids = |log: &[EventEnvelope]| -> Vec<String> {
log.iter()
.filter_map(|env| match &env.event {
Event::MapIterationStarted { child_run, .. } => Some(child_run.clone()),
_ => None,
})
.collect()
};
let log_a = drive_fresh(&graph, &input, fixed_run_id(34)).await;
let log_b = drive_fresh(&graph, &input, fixed_run_id(35)).await;
let ids_a = child_ids(&log_a);
let ids_b = child_ids(&log_b);
assert_eq!(ids_a.len(), 3);
let mut sorted = ids_a.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), 3, "the three iteration ids are distinct");
assert_ne!(
ids_a, ids_b,
"child ids incorporate the parent run id, so they differ across runs"
);
}
#[tokio::test]
async fn fork_after_a_completed_map_includes_the_map_markers() {
let graph = GraphBuilder::new()
.map(MapSpec::new(
"fanout",
"items",
2,
MapBody::Node("worker".into()),
))
.tool(ToolSpec::new("worker", "worker_tool"))
.tool(ToolSpec::new("collect", "collect_tool"))
.edge("fanout", "collect")
.build();
let input = json!({"items": ["a", "b"]});
let origin_id = fixed_run_id(36);
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let (worker, _wc) = EchoTool::new("worker_tool", Effect::Idempotent);
let (collect, _cc) = EchoTool::new("collect_tool", Effect::Read);
let mut tools: HashMap<String, Box<dyn DynTool>> = HashMap::new();
tools.insert("worker_tool".to_owned(), Box::new(worker));
tools.insert("collect_tool".to_owned(), Box::new(collect));
let agents = no_agents();
let mut ctx = RunCtx::with_hooks(
store.clone(),
origin_id,
vec![],
fixed_clock(),
fixed_random(),
)
.expect("ctx builds");
run_graph(&mut ctx, &graph, &input, &agents, &tools)
.await
.expect("origin drives");
let origin_log = store.read_log(origin_id).await.expect("log reads");
let plan = plan_fork(&origin_log, "collect").expect("collect was entered");
assert!(plan.hazards().is_empty(), "no Write intents to acknowledge");
let child_id = fixed_run_id(37);
let child_prefix = plan.build_child_prefix(child_id, plan.hazard_seqs());
let prefix_kinds = event_kinds(&child_prefix);
assert!(
prefix_kinds.contains(&"MapFannedOut")
&& prefix_kinds
.iter()
.filter(|k| **k == "MapIterationStarted")
.count()
== 2
&& prefix_kinds
.iter()
.filter(|k| **k == "MapIterationJoined")
.count()
== 2,
"the fork prefix includes the completed map's markers: {prefix_kinds:?}"
);
assert!(
!child_prefix.iter().any(|env| matches!(
&env.event,
Event::NodeEntered { node } if node == "collect"
)),
"the prefix stops at the fork boundary, before collect is entered"
);
let child_store = Arc::new(SqliteStore::in_memory().expect("store opens"));
for env in &child_prefix {
child_store.append(env).await.expect("seed child prefix");
}
let (worker2, worker2_calls) = EchoTool::new("worker_tool", Effect::Idempotent);
let (collect2, collect2_calls) = EchoTool::new("collect_tool", Effect::Read);
let mut child_tools: HashMap<String, Box<dyn DynTool>> = HashMap::new();
child_tools.insert("worker_tool".to_owned(), Box::new(worker2));
child_tools.insert("collect_tool".to_owned(), Box::new(collect2));
let mut child_ctx = RunCtx::with_hooks(
child_store.clone(),
child_id,
child_prefix.clone(),
fixed_clock(),
fixed_random(),
)
.expect("child ctx builds");
let outcome = run_graph(&mut child_ctx, &graph, &input, &agents, &child_tools)
.await
.expect("child drives");
assert!(matches!(outcome, GraphOutcome::Completed { .. }));
assert_eq!(
worker2_calls.load(Ordering::SeqCst),
0,
"the map replays in the fork with zero live worker calls"
);
assert_eq!(
collect2_calls.load(Ordering::SeqCst),
1,
"the fork ran collect live exactly once"
);
let child_log = child_store.read_log(child_id).await.expect("log reads");
let origin_hash = graph_hash(&graph).expect("hash");
assert_eq!(
recorded_graph_hash(&child_log),
Some(origin_hash),
"the fork reuses the origin's graph hash"
);
}
#[tokio::test]
async fn forking_into_a_map_iteration_is_refused_but_the_map_node_is_a_boundary() {
let graph = map_only_graph();
let input = json!({"items": ["a", "b"]});
let origin_log = drive_fresh(&graph, &input, fixed_run_id(38)).await;
let error = plan_fork(&origin_log, "worker").expect_err("worker is not a node boundary");
match error {
ForkError::NodeNeverEntered { node } => assert_eq!(node, "worker"),
other => panic!("expected NodeNeverEntered for the iteration body, got {other:?}"),
}
let plan = plan_fork(&origin_log, "fanout").expect("fanout is a real node boundary");
assert_eq!(plan.from_node(), "fanout");
}
fn recorded_graph_hash(log: &[EventEnvelope]) -> Option<String> {
log.iter().find_map(|env| match &env.event {
Event::GraphRunStarted { graph_hash, .. } => Some(graph_hash.clone()),
_ => None,
})
}