use std::collections::HashMap;
use axum::Json;
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use salvor_core::{Event, EventEnvelope, PendingCall, RunId, RunStatus, derive_state};
use salvor_engine::{
ForkError, ForkPlan, GraphOutcome, WriteHazard, graph_hash, plan_fork, run_graph,
};
use salvor_graph::{Graph, GraphError, GraphSummary, Node};
use salvor_replay::{GraphProjection, NodeState, derive_graph_projection};
use salvor_runtime::{Agent, RunCtx, validate_labels};
use salvor_tools::mcp::McpServer;
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::error::ApiError;
use crate::state::{AppState, BuiltAgent};
use crate::tool_registry::ToolRegistry;
impl salvor_engine::ToolResolver for ToolRegistry {
fn resolve_tool(&self, name: &str) -> Option<&dyn salvor_tools::DynTool> {
self.resolve(name)
}
}
#[derive(Debug, Deserialize)]
struct StartRunRequest {
graph_hash: String,
#[serde(default)]
input: Value,
#[serde(default)]
labels: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Default, Deserialize)]
struct ForkRequest {
from_node: String,
#[serde(default)]
acknowledge_writes: Vec<u64>,
#[serde(default)]
dry_run: bool,
}
enum GraphVerb {
Start {
input: Value,
labels: Option<BTreeMap<String, String>>,
},
Resume(Value),
Recover,
}
pub async fn submit(
State(state): State<AppState>,
body: Bytes,
) -> Result<impl IntoResponse, ApiError> {
let graph = parse_and_validate(&body)?;
let hash = graph_hash(&graph).map_err(|error| ApiError::Internal(error.to_string()))?;
let created = state.store_graph(hash.clone(), graph);
Ok((
StatusCode::CREATED,
Json(json!({ "graph": hash, "created": created })),
))
}
pub async fn list(State(state): State<AppState>) -> impl IntoResponse {
let graphs: Vec<Value> = state
.graph_hashes()
.into_iter()
.filter_map(|hash| state.graph(&hash).map(|graph| (hash, graph)))
.map(|(hash, graph)| {
let mut entry = json!({ "graph": hash });
if let Ok(summary) = salvor_graph::validate(&graph) {
let object = entry.as_object_mut().expect("entry is a JSON object");
for (key, value) in summary_fields(&summary) {
object.insert(key, value);
}
}
entry
})
.collect();
Json(json!({ "graphs": graphs }))
}
pub async fn get(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let graph = state
.graph(&hash)
.ok_or_else(|| ApiError::UnknownGraph(format!("no graph stored under `{hash}`")))?;
let document = serde_json::to_value(&graph)
.map_err(|error| ApiError::Internal(format!("re-encoding stored graph: {error}")))?;
Ok(Json(json!({ "graph": hash, "document": document })))
}
pub async fn validate_only(body: Bytes) -> impl IntoResponse {
match parse_and_validate(&body) {
Ok(graph) => {
let hash = graph_hash(&graph).unwrap_or_default();
let summary = salvor_graph::validate(&graph).expect("just validated");
let mut object = serde_json::Map::new();
object.insert("valid".to_owned(), json!(true));
object.insert("graph".to_owned(), json!(hash));
object.insert("summary".to_owned(), json!(summary_object(&summary)));
Json(Value::Object(object))
}
Err(ApiError::InvalidGraph { errors, .. }) => {
Json(json!({ "valid": false, "errors": errors }))
}
Err(_) => Json(json!({ "valid": false, "errors": [] })),
}
}
pub async fn start_run(
State(state): State<AppState>,
body: Bytes,
) -> Result<impl IntoResponse, ApiError> {
let request: StartRunRequest = parse_body(&body)?;
if let Some(labels) = &request.labels {
validate_labels(labels).map_err(ApiError::BadRequest)?;
}
let graph = state.graph(&request.graph_hash).ok_or_else(|| {
ApiError::UnknownGraph(format!(
"no graph stored under `{}`; submit it to POST /v1/graphs first",
request.graph_hash
))
})?;
let registry = require_tools(&state, &graph)?;
let (agents, servers) = build_agents(&state, &graph).await?;
let run_id = RunId::new();
let log = state.store().read_log(run_id).await.map_err(store_error)?;
if !log.is_empty() {
close_servers(servers).await;
return Err(ApiError::RunExists(format!(
"run {} already has recorded history",
run_id.as_uuid()
)));
}
spawn_graph_drive(
state,
run_id,
graph,
agents,
servers,
registry,
GraphVerb::Start {
input: request.input,
labels: request.labels,
},
);
Ok((
StatusCode::CREATED,
Json(json!({ "run": run_id.as_uuid().to_string(), "status": "running" })),
))
}
pub async fn projection(
State(state): State<AppState>,
Path(run_id_text): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let run_id = parse_run_id(&run_id_text)?;
let log = state.store().read_log(run_id).await.map_err(store_error)?;
if log.is_empty() {
return Err(ApiError::UnknownRun(format!(
"no run {} in this store",
run_id.as_uuid()
)));
}
if !is_graph_run(&log) {
return Err(ApiError::NotAGraphRun(format!(
"run {} is an agent run, not a graph run; it has no graph projection",
run_id.as_uuid()
)));
}
Ok(Json(projection_json(&derive_graph_projection(&log))))
}
pub async fn fork(
State(state): State<AppState>,
Path(run_id_text): Path<String>,
body: Bytes,
) -> Result<Response, ApiError> {
let origin_id = parse_run_id(&run_id_text)?;
let request: ForkRequest = parse_body(&body)?;
let origin_log = state
.store()
.read_log(origin_id)
.await
.map_err(store_error)?;
if origin_log.is_empty() {
return Err(ApiError::UnknownRun(format!(
"no run {} in this store",
origin_id.as_uuid()
)));
}
let derived = derive_state(&origin_log);
if matches!(derived.status, RunStatus::NeedsReconciliation) {
return Err(ApiError::OriginNeedsReconciliation {
message: format!(
"origin run {id} is parked at a dangling write; resolve it (POST /v1/runs/{id}/resolve) \
before forking, so the fork does not inherit an unsettled write",
id = origin_id.as_uuid()
),
intent: origin_reconcile_intent(&origin_log, derived.pending_call.as_ref()),
});
}
let plan = plan_fork(&origin_log, &request.from_node).map_err(|error| match error {
ForkError::NotAGraphRun => ApiError::NotAGraphRun(format!(
"run {} is an agent run, not a graph run; only a graph run has node boundaries to fork from",
origin_id.as_uuid()
)),
ForkError::NodeNeverEntered { node } => ApiError::InvalidForkNode(format!(
"run {} never entered node `{node}`; fork from a node boundary the run reached",
origin_id.as_uuid()
)),
})?;
let graph = state.graph(plan.graph_hash()).ok_or_else(|| {
ApiError::UnknownGraph(format!(
"the graph {} this run executes is not stored on this server (graphs do not survive a \
restart); resubmit the identical document to POST /v1/graphs, then fork",
plan.graph_hash()
))
})?;
let hazard_seqs = plan.hazard_seqs();
let acknowledged: HashSet<u64> = request.acknowledge_writes.iter().copied().collect();
let missing: Vec<u64> = hazard_seqs
.iter()
.copied()
.filter(|seq| !acknowledged.contains(seq))
.collect();
if request.dry_run {
return Ok(Json(fork_preview_json(&plan, &missing)).into_response());
}
if !missing.is_empty() {
let unacked: Vec<&WriteHazard> = plan
.hazards()
.iter()
.filter(|hazard| missing.contains(&hazard.seq))
.collect();
return Err(ApiError::WriteReplayHazard {
message: format!(
"forking run {} from node `{}` would re-execute {} recorded write(s) the segment \
re-walks; acknowledge them (acknowledge_writes: [{}]) to record that you accept \
they may re-fire, then fork",
origin_id.as_uuid(),
request.from_node,
unacked.len(),
missing
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(", "),
),
writes: write_hazards_json(unacked.into_iter()),
});
}
let registry = require_tools(&state, &graph)?;
let (agents, servers) = build_agents(&state, &graph).await?;
let child_id = RunId::new();
let existing = state
.store()
.read_log(child_id)
.await
.map_err(store_error)?;
if !existing.is_empty() {
close_servers(servers).await;
return Err(ApiError::RunExists(format!(
"run {} already has recorded history",
child_id.as_uuid()
)));
}
let child_prefix = plan.build_child_prefix(child_id, hazard_seqs.clone());
for envelope in &child_prefix {
if let Err(error) = state.store().append(envelope).await {
close_servers(servers).await;
return Err(store_error(error));
}
}
spawn_graph_drive(
state,
child_id,
graph,
agents,
servers,
registry,
GraphVerb::Recover,
);
Ok((
StatusCode::CREATED,
Json(json!({
"run": child_id.as_uuid().to_string(),
"status": "running",
"forked_from": {
"run_id": origin_id.as_uuid().to_string(),
"through_seq": plan.through_seq().get(),
"from_node": request.from_node,
"graph_hash": plan.graph_hash(),
"acknowledged_writes": hazard_seqs,
},
})),
)
.into_response())
}
pub async fn forks(
State(state): State<AppState>,
Path(run_id_text): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let origin_id = parse_run_id(&run_id_text)?;
let origin_log = state
.store()
.read_log(origin_id)
.await
.map_err(store_error)?;
if origin_log.is_empty() {
return Err(ApiError::UnknownRun(format!(
"no run {} in this store",
origin_id.as_uuid()
)));
}
let summaries = state.store().list_runs().await.map_err(store_error)?;
let mut forks = Vec::new();
for summary in summaries {
if summary.run_id == origin_id {
continue;
}
let Ok(log) = state.store().read_log(summary.run_id).await else {
continue;
};
if let Some(Event::GraphRunStarted {
forked_from: Some(origin),
..
}) = log.first().map(|envelope| &envelope.event)
&& origin.run_id == origin_id
{
forks.push(json!({
"run": summary.run_id.as_uuid().to_string(),
"from_node": origin.from_node,
"through_seq": origin.through_seq.get(),
"acknowledged_writes": origin.acknowledged_writes,
}));
}
}
Ok(Json(json!({
"run": origin_id.as_uuid().to_string(),
"derived": true,
"forks": forks,
})))
}
pub async fn drive_resume(
state: AppState,
run_id: RunId,
log: &[EventEnvelope],
input: Option<Value>,
) -> Result<Response, ApiError> {
let hash = recorded_graph_hash(log).ok_or_else(|| {
ApiError::Internal("graph run log has no GraphRunStarted event".to_owned())
})?;
let graph = state.graph(&hash).ok_or_else(|| {
ApiError::UnknownGraph(format!(
"the graph {hash} this run executes is not stored on this server; submit it, then resume"
))
})?;
let registry = require_tools(&state, &graph)?;
let (agents, servers) = build_agents(&state, &graph).await?;
let verb = match input {
Some(input) => GraphVerb::Resume(input),
None => GraphVerb::Recover,
};
spawn_graph_drive(state, run_id, graph, agents, servers, registry, verb);
Ok(driving(run_id).into_response())
}
#[must_use]
pub fn is_graph_run(log: &[EventEnvelope]) -> bool {
matches!(
log.first().map(|envelope| &envelope.event),
Some(Event::GraphRunStarted { .. })
)
}
fn spawn_graph_drive(
state: AppState,
run_id: RunId,
graph: Graph,
agents: HashMap<String, Agent>,
servers: Vec<McpServer>,
registry: Arc<ToolRegistry>,
verb: GraphVerb,
) {
state.begin_run(run_id);
let task_state = state.clone();
let handle = tokio::spawn(async move {
let result = drive_graph(&task_state, run_id, &graph, &agents, ®istry, verb).await;
close_servers(servers).await;
if let Err(error) = result {
tracing::error!(run_id = %run_id.as_uuid(), %error, "graph run drive ended with an error");
}
task_state.end_run(run_id);
});
state.set_handle(run_id, handle);
}
async fn drive_graph(
state: &AppState,
run_id: RunId,
graph: &Graph,
agents: &HashMap<String, Agent>,
registry: &ToolRegistry,
verb: GraphVerb,
) -> Result<GraphOutcome, salvor_engine::EngineError> {
let log = state
.store()
.read_log(run_id)
.await
.map_err(salvor_runtime::RuntimeError::Store)?;
let mut ctx: RunCtx = state.run_ctx(run_id, log)?;
let input = match verb {
GraphVerb::Start { input, labels } => {
if let Some(labels) = labels {
ctx = ctx.with_labels(labels);
}
input
}
GraphVerb::Resume(input) => {
ctx.set_resume_input(input);
Value::Null
}
GraphVerb::Recover => Value::Null,
};
run_graph(&mut ctx, graph, &input, agents, registry).await
}
async fn build_agents(
state: &AppState,
graph: &Graph,
) -> Result<(HashMap<String, Agent>, Vec<McpServer>), ApiError> {
let mut agents: HashMap<String, Agent> = HashMap::new();
let mut servers: Vec<McpServer> = Vec::new();
for (node_id, hash) in referenced_agents(graph) {
if agents.contains_key(hash) {
continue;
}
let registered = state.agent(hash).ok_or_else(|| {
ApiError::UnknownAgent(format!(
"agent node `{node_id}` references agent `{hash}`, which is not registered on this \
server; register its definition, then start the run"
))
})?;
match state.build_agent(registered.definition).await {
Ok(BuiltAgent { agent, servers: s }) => {
agents.insert(hash.to_owned(), agent);
servers.extend(s);
}
Err(message) => {
close_servers(servers).await;
return Err(ApiError::BadRequest(format!(
"agent node `{node_id}` references agent `{hash}`, which failed to build: \
{message}"
)));
}
}
}
Ok((agents, servers))
}
fn require_tools(state: &AppState, graph: &Graph) -> Result<Arc<ToolRegistry>, ApiError> {
let registry = state.tool_registry().ok_or_else(|| {
ApiError::ToolRegistryUnavailable(
"this server has no tool registry wired, so it cannot run a graph with `tool` nodes"
.to_owned(),
)
})?;
for node in &graph.nodes {
if let Node::Tool(tool) = node
&& registry.get(&tool.tool).is_none()
{
return Err(ApiError::UnknownTool(format!(
"tool node `{}` names tool `{}`, which is not registered on this server",
tool.id, tool.tool
)));
}
}
Ok(registry)
}
fn referenced_agents(graph: &Graph) -> Vec<(&str, &str)> {
graph
.nodes
.iter()
.filter_map(|node| match node {
Node::Agent(agent) => Some((agent.id.as_str(), agent.agent_hash.as_str())),
Node::Branch(branch) => branch
.agent_hash
.as_deref()
.map(|hash| (branch.id.as_str(), hash)),
_ => None,
})
.collect()
}
fn recorded_graph_hash(log: &[EventEnvelope]) -> Option<String> {
log.iter().find_map(|envelope| match &envelope.event {
Event::GraphRunStarted { graph_hash, .. } => Some(graph_hash.clone()),
_ => None,
})
}
fn parse_and_validate(body: &Bytes) -> Result<Graph, ApiError> {
let graph: Graph = match serde_json::from_slice(body) {
Ok(graph) => graph,
Err(error) => {
return Err(ApiError::InvalidGraph {
message: "the graph document is not well formed".to_owned(),
errors: json!([{ "code": "malformed_document", "message": error.to_string() }]),
});
}
};
match salvor_graph::validate(&graph) {
Ok(_) => Ok(graph),
Err(errors) => Err(ApiError::InvalidGraph {
message: format!(
"the graph document has {} validation error(s)",
errors.len()
),
errors: Value::Array(errors.iter().map(graph_error_json).collect()),
}),
}
}
fn graph_error_json(error: &GraphError) -> Value {
let message = error.to_string();
match error {
GraphError::UnsupportedSchemaVersion { found, supported } => json!({
"code": "unsupported_schema_version", "message": message,
"found": found, "supported": supported,
}),
GraphError::DuplicateNodeId { id } => json!({
"code": "duplicate_node_id", "message": message, "node": id,
}),
GraphError::DanglingEdge {
from,
to,
missing,
suggestion,
} => json!({
"code": "dangling_edge", "message": message,
"edge": { "from": from, "to": to }, "missing": missing, "suggestion": suggestion,
}),
GraphError::DanglingMapBody {
id,
missing,
suggestion,
} => json!({
"code": "dangling_map_body", "message": message,
"node": id, "missing": missing, "suggestion": suggestion,
}),
GraphError::DanglingFoldBody {
id,
missing,
suggestion,
} => json!({
"code": "dangling_fold_body", "message": message,
"node": id, "missing": missing, "suggestion": suggestion,
}),
GraphError::MalformedAgentHash { id, hash } => json!({
"code": "malformed_agent_hash", "message": message, "node": id, "hash": hash,
}),
GraphError::NonPositiveConcurrency { id, found } => json!({
"code": "non_positive_concurrency", "message": message, "node": id, "found": found,
}),
GraphError::NonPositiveMaxIterations { id, found } => json!({
"code": "non_positive_max_iterations", "message": message, "node": id, "found": found,
}),
GraphError::ApprovalSchemaNotObject { id } => json!({
"code": "approval_schema_not_object", "message": message, "node": id,
}),
GraphError::Cycle { path } => json!({
"code": "cycle", "message": message, "path": path,
}),
GraphError::EdgeTypeMismatch { from, to } => json!({
"code": "edge_type_mismatch", "message": message, "edge": { "from": from, "to": to },
}),
GraphError::InvalidBranchExpression { node, case, error } => json!({
"code": "invalid_branch_expression", "message": message,
"node": node, "case": case, "error": error,
}),
GraphError::ModelDecisionWithoutAgent { node, case } => json!({
"code": "model_decision_without_agent", "message": message, "node": node, "case": case,
}),
GraphError::InvalidFoldStopExpression { node, error } => json!({
"code": "invalid_fold_stop_expression", "message": message, "node": node, "error": error,
}),
GraphError::InvalidFoldJoinReference {
node,
reference,
error,
} => json!({
"code": "invalid_fold_join_reference", "message": message,
"node": node, "reference": reference, "error": error,
}),
GraphError::NodeNameTooLong { id, len, max } => json!({
"code": "node_name_too_long", "message": message, "node": id, "len": len, "max": max,
}),
GraphError::BlankNodeName { id } => json!({
"code": "blank_node_name", "message": message, "node": id,
}),
}
}
fn summary_fields(summary: &GraphSummary) -> Vec<(String, Value)> {
vec![
("node_count".to_owned(), json!(summary.node_count)),
("edge_count".to_owned(), json!(summary.edge_count)),
("entry_nodes".to_owned(), json!(summary.entry_nodes)),
("terminal_nodes".to_owned(), json!(summary.terminal_nodes)),
]
}
fn summary_object(summary: &GraphSummary) -> Value {
Value::Object(summary_fields(summary).into_iter().collect())
}
fn projection_json(projection: &GraphProjection) -> Value {
let nodes: Vec<Value> = projection
.nodes
.iter()
.map(|node| {
let mut object = serde_json::Map::new();
object.insert("node".to_owned(), json!(node.node));
match &node.state {
NodeState::Entered => {
object.insert("state".to_owned(), json!("entered"));
}
NodeState::Exited => {
object.insert("state".to_owned(), json!("exited"));
}
NodeState::Skipped { reason } => {
object.insert("state".to_owned(), json!("skipped"));
object.insert("reason".to_owned(), json!(reason));
}
}
if let Some(case) = &node.branch_case {
object.insert("branch_case".to_owned(), json!(case));
}
if let Some(map) = &node.map {
let iterations: Vec<Value> = map
.iterations
.iter()
.map(|it| {
json!({ "index": it.index, "child_run": it.child_run, "joined": it.joined })
})
.collect();
object.insert(
"map".to_owned(),
json!({ "items": map.items, "iterations": iterations }),
);
}
if let Some(fold) = &node.fold {
let iterations: Vec<Value> = fold
.iterations
.iter()
.map(|it| json!({ "index": it.index, "joined": it.joined }))
.collect();
let mut fold_object = serde_json::Map::new();
fold_object.insert("iterations".to_owned(), json!(iterations));
if let Some(converged) = &fold.converged {
fold_object.insert(
"converged".to_owned(),
json!({
"winner_index": converged.winner_index,
"reason": converged.reason,
}),
);
}
object.insert("fold".to_owned(), Value::Object(fold_object));
}
Value::Object(object)
})
.collect();
let mut object = serde_json::Map::new();
if let Some(hash) = &projection.graph_hash {
object.insert("graph_hash".to_owned(), json!(hash));
}
if let Some(origin) = &projection.forked_from {
object.insert(
"forked_from".to_owned(),
json!({
"run_id": origin.run_id.as_uuid().to_string(),
"through_seq": origin.through_seq.get(),
"from_node": origin.from_node,
"graph_hash": origin.graph_hash,
"acknowledged_writes": origin.acknowledged_writes,
}),
);
}
if let Some(current) = &projection.current_node {
object.insert("current_node".to_owned(), json!(current));
}
object.insert("nodes".to_owned(), Value::Array(nodes));
Value::Object(object)
}
fn write_hazards_json<'a>(hazards: impl Iterator<Item = &'a WriteHazard>) -> Value {
Value::Array(
hazards
.map(|hazard| {
json!({
"seq": hazard.seq,
"tool": hazard.tool,
"input": hazard.input,
"idempotency_key": hazard.idempotency_key,
"recorded_at": rfc3339(hazard.recorded_at),
})
})
.collect(),
)
}
fn fork_preview_json(plan: &ForkPlan, missing: &[u64]) -> Value {
json!({
"dry_run": true,
"origin": plan.origin_run().as_uuid().to_string(),
"from_node": plan.from_node(),
"through_seq": plan.through_seq().get(),
"graph_hash": plan.graph_hash(),
"prefix_event_count": plan.prefix_len(),
"writes": write_hazards_json(plan.hazards().iter()),
"unacknowledged_writes": missing,
"would_proceed": missing.is_empty(),
})
}
fn origin_reconcile_intent(log: &[EventEnvelope], pending: Option<&PendingCall>) -> Value {
let mut intent = crate::json::pending(pending);
if let Some(PendingCall::Tool { seq, .. }) = pending
&& let Some(envelope) = log.iter().find(|envelope| envelope.seq == *seq)
{
intent["recorded_at"] = json!(rfc3339(envelope.recorded_at));
}
intent
}
fn rfc3339(timestamp: OffsetDateTime) -> String {
timestamp.format(&Rfc3339).unwrap_or_default()
}
fn driving(run_id: RunId) -> impl IntoResponse {
(
StatusCode::ACCEPTED,
Json(json!({
"run": run_id.as_uuid().to_string(),
"status": "running",
"outcome": "driving",
})),
)
}
async fn close_servers(servers: Vec<McpServer>) {
for server in servers {
if let Err(error) = server.close().await {
tracing::warn!(%error, "MCP session did not close cleanly");
}
}
}
fn parse_body<T: for<'de> Deserialize<'de>>(body: &Bytes) -> Result<T, ApiError> {
serde_json::from_slice(body)
.map_err(|error| ApiError::BadRequest(format!("request body is not valid JSON: {error}")))
}
fn parse_run_id(text: &str) -> Result<RunId, ApiError> {
Uuid::parse_str(text).map(RunId::from_uuid).map_err(|_| {
ApiError::BadRequest(format!("`{text}` is not a valid run id (expected a UUID)"))
})
}
fn store_error(error: salvor_store::StoreError) -> ApiError {
ApiError::Internal(format!("store: {error}"))
}