mod common;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use common::{
EchoTool, ScriptedModel, agent_builder, event_kinds, fixed_clock, fixed_random, fixed_run_id,
text_response,
};
use salvor_core::{Effect, EventEnvelope};
use salvor_engine::{EngineError, GraphOutcome, graph_hash, run_graph};
use salvor_graph::{AgentSpec, Graph, GraphBuilder, MapBody, MapSpec, ToolSpec};
use salvor_replay::{NodeState, derive_graph_projection};
use salvor_runtime::RunCtx;
use salvor_store::{EventStore, SqliteStore};
use salvor_tools::DynTool;
use serde_json::{Value, json};
const RESEARCH_HASH: &str =
"sha256:1111111111111111111111111111111111111111111111111111111111111111";
const REVIEW_HASH: &str = "sha256:2222222222222222222222222222222222222222222222222222222222222222";
fn linear_fixture() -> Graph {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../examples/graphs/linear-research-publish.json"
);
let text = std::fs::read_to_string(path).expect("fixture reads");
serde_json::from_str(&text).expect("fixture parses")
}
#[tokio::test]
async fn linear_graph_runs_replays_and_projects() {
let research_server =
ScriptedModel::mount(vec![(1, text_response("a draft about otters", 5, 3))]).await;
let review_server =
ScriptedModel::mount(vec![(1, text_response("reviewed: publish it", 4, 2))]).await;
let research_agent = agent_builder(&research_server.uri()).build().unwrap();
let review_agent = agent_builder(&review_server.uri()).build().unwrap();
let mut agents: HashMap<String, salvor_runtime::Agent> = HashMap::new();
agents.insert(RESEARCH_HASH.to_owned(), research_agent);
agents.insert(REVIEW_HASH.to_owned(), review_agent);
let (publish, publish_calls) = EchoTool::new("http_post", Effect::Write);
let mut tools: HashMap<String, Box<dyn DynTool>> = HashMap::new();
tools.insert("http_post".to_owned(), Box::new(publish));
let graph = linear_fixture();
let input = json!({"topic": "otters"});
let run_id = fixed_run_id(1);
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
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");
let GraphOutcome::Completed { output } = outcome else {
panic!("expected completion, got {outcome:?}");
};
assert_eq!(output, json!({"published": "reviewed: publish it"}));
let log1 = store.read_log(run_id).await.expect("log reads");
assert_eq!(
event_kinds(&log1),
[
"GraphRunStarted",
"NodeEntered", "NowObserved",
"ModelCallRequested",
"ModelCallCompleted",
"NodeExited", "NodeEntered", "NowObserved",
"ModelCallRequested",
"ModelCallCompleted",
"NodeExited", "NodeEntered", "ToolCallRequested",
"ToolCallCompleted",
"NodeExited", "RunCompleted",
]
);
let expected_hash = graph_hash(&graph).unwrap();
match &log1[0].event {
salvor_core::Event::GraphRunStarted { graph_hash, .. } => {
assert_eq!(graph_hash, &expected_hash);
}
other => panic!("head is not GraphRunStarted: {other:?}"),
}
let projection = derive_graph_projection(&log1);
assert_eq!(
projection.graph_hash.as_deref(),
Some(expected_hash.as_str())
);
assert_eq!(projection.current_node, None);
let ids: Vec<&str> = projection.nodes.iter().map(|n| n.node.as_str()).collect();
assert_eq!(ids, ["research", "review", "publish"]);
for node in &projection.nodes {
assert_eq!(
node.state,
NodeState::Exited,
"node {} not exited",
node.node
);
}
let calls_before = publish_calls.load(Ordering::SeqCst);
let research_reqs_before = research_server.received_requests().await.unwrap().len();
let review_reqs_before = review_server.received_requests().await.unwrap().len();
assert_eq!(calls_before, 1, "the write tool executed once live");
let mut ctx2 = RunCtx::with_hooks(
store.clone(),
run_id,
log1.clone(),
fixed_clock(),
fixed_random(),
)
.expect("replay ctx builds");
let replayed = run_graph(&mut ctx2, &graph, &input, &agents, &tools)
.await
.expect("graph replays");
let GraphOutcome::Completed {
output: replayed_output,
} = replayed
else {
panic!("expected completion on replay");
};
assert_eq!(replayed_output, output, "replay reproduces the output");
assert_eq!(
publish_calls.load(Ordering::SeqCst),
calls_before,
"the write tool must not re-execute on replay"
);
assert_eq!(
research_server.received_requests().await.unwrap().len(),
research_reqs_before,
"the research model must not be re-called on replay"
);
assert_eq!(
review_server.received_requests().await.unwrap().len(),
review_reqs_before,
"the review model must not be re-called on replay"
);
assert!(!ctx2.is_replaying(), "history fully consumed");
let log2 = store.read_log(run_id).await.expect("log reads");
assert_eq!(
serde_json::to_string(&log1).unwrap(),
serde_json::to_string(&log2).unwrap(),
"the replay produced a byte-identical log"
);
}
#[tokio::test]
async fn a_map_over_a_non_list_is_refused_before_it_records_anything() {
let research_server = ScriptedModel::mount(vec![(1, text_response("a draft", 3, 2))]).await;
let research_agent = agent_builder(&research_server.uri()).build().unwrap();
let mut agents: HashMap<String, salvor_runtime::Agent> = HashMap::new();
agents.insert(RESEARCH_HASH.to_owned(), research_agent);
let tools: HashMap<String, Box<dyn DynTool>> = HashMap::new();
let graph = GraphBuilder::new()
.agent(AgentSpec::new("research", RESEARCH_HASH))
.map(MapSpec::new(
"fanout",
"items",
2,
MapBody::Node("worker".into()),
))
.tool(ToolSpec::new("worker", "worker_tool"))
.edge("research", "fanout")
.build();
let run_id = fixed_run_id(2);
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let mut ctx = RunCtx::with_hooks(store.clone(), run_id, vec![], fixed_clock(), fixed_random())
.expect("ctx builds");
let error = run_graph(&mut ctx, &graph, &json!({}), &agents, &tools)
.await
.expect_err("a map over a non-list must be refused");
match error {
EngineError::MapOverNotAList { node, over } => {
assert_eq!(node, "fanout");
assert_eq!(over, "items");
}
other => panic!("expected MapOverNotAList, got {other:?}"),
}
let log = store.read_log(run_id).await.expect("log reads");
assert_eq!(
event_kinds(&log),
[
"GraphRunStarted",
"NodeEntered", "NowObserved",
"ModelCallRequested",
"ModelCallCompleted",
"NodeExited", ],
"no NodeEntered for the map, and no terminal"
);
assert!(
!log.iter().any(|e| matches!(
&e.event,
salvor_core::Event::NodeEntered { node } if node == "fanout" || node == "worker"
)),
"the map (and its map-owned worker) must not have been entered"
);
}
#[tokio::test]
async fn a_map_with_a_subgraph_body_refuses_with_only_the_head_recorded() {
let agents: HashMap<String, salvor_runtime::Agent> = HashMap::new();
let tools: HashMap<String, Box<dyn DynTool>> = HashMap::new();
let subgraph = GraphBuilder::new()
.tool(ToolSpec::new("inner", "inner_tool"))
.build();
let graph = GraphBuilder::new()
.map(MapSpec::new(
"fanout",
"items",
1,
MapBody::Subgraph(Box::new(subgraph)),
))
.build();
let run_id = fixed_run_id(3);
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let mut ctx = RunCtx::with_hooks(store.clone(), run_id, vec![], fixed_clock(), fixed_random())
.expect("ctx builds");
let error = run_graph(&mut ctx, &graph, &json!({"items": [1, 2]}), &agents, &tools)
.await
.expect_err("a subgraph body must be refused");
match error {
EngineError::UnsupportedMapBody { node, .. } => assert_eq!(node, "fanout"),
other => panic!("expected UnsupportedMapBody, got {other:?}"),
}
let log = store.read_log(run_id).await.expect("log reads");
assert_eq!(event_kinds(&log), ["GraphRunStarted"]);
}
#[tokio::test]
async fn an_idempotent_tool_node_key_is_position_derived_not_random() {
let graph = GraphBuilder::new()
.tool(salvor_graph::ToolSpec::new("notify", "notify_tool"))
.build();
let input = json!({"to": "ops"});
let drive = |run_id, random: salvor_runtime::RandomFn| {
let graph = graph.clone();
let input = input.clone();
async move {
let (notify, _calls) = EchoTool::new("notify_tool", Effect::Idempotent);
let mut tools: HashMap<String, Box<dyn DynTool>> = HashMap::new();
tools.insert("notify_tool".to_owned(), Box::new(notify));
let agents: HashMap<String, salvor_runtime::Agent> = HashMap::new();
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let mut ctx = RunCtx::with_hooks(store.clone(), run_id, vec![], fixed_clock(), random)
.expect("ctx builds");
run_graph(&mut ctx, &graph, &input, &agents, &tools)
.await
.expect("graph drives");
store.read_log(run_id).await.expect("log reads")
}
};
let log_a = drive(fixed_run_id(20), Arc::new(|| 11)).await;
let log_b = drive(fixed_run_id(21), Arc::new(|| 999)).await;
let key_of = |log: &[EventEnvelope]| -> Option<String> {
log.iter().find_map(|e| match &e.event {
salvor_core::Event::ToolCallRequested {
idempotency_key, ..
} => idempotency_key.clone(),
_ => None,
})
};
let key_a = key_of(&log_a).expect("an idempotent tool records a key");
let key_b = key_of(&log_b).expect("an idempotent tool records a key");
assert_eq!(
key_a, key_b,
"the idempotent key must be identical across independent runs (position-derived, not random)"
);
assert!(
key_a.starts_with("sha256:"),
"the derived key is a canonical hash: {key_a}"
);
assert!(
!log_a
.iter()
.any(|e| matches!(&e.event, salvor_core::Event::RandomObserved { .. })),
"a position-derived key must not record a RandomObserved"
);
}
#[tokio::test]
async fn an_unresolved_agent_is_a_typed_error() {
let agents: HashMap<String, salvor_runtime::Agent> = HashMap::new();
let tools: HashMap<String, Box<dyn DynTool>> = HashMap::new();
let graph = GraphBuilder::new()
.agent(AgentSpec::new("research", RESEARCH_HASH))
.build();
let run_id = fixed_run_id(4);
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let mut ctx = RunCtx::with_hooks(store.clone(), run_id, vec![], fixed_clock(), fixed_random())
.expect("ctx builds");
let error = run_graph(&mut ctx, &graph, &Value::Null, &agents, &tools)
.await
.expect_err("an unresolved agent must error");
assert!(matches!(error, EngineError::UnknownAgent { node, .. } if node == "research"));
}