1use std::collections::HashMap;
43
44use axum::Json;
45use axum::body::Bytes;
46use axum::extract::{Path, State};
47use axum::http::StatusCode;
48use axum::response::{IntoResponse, Response};
49use salvor_core::{Event, EventEnvelope, PendingCall, RunId, RunStatus, derive_state};
50use salvor_engine::{
51 ForkError, ForkPlan, GraphOutcome, WriteHazard, graph_hash, plan_fork, run_graph,
52};
53use salvor_graph::{Graph, GraphError, GraphSummary, Node};
54use salvor_replay::{GraphProjection, NodeState, derive_graph_projection};
55use salvor_runtime::{Agent, RunCtx, validate_labels};
56use salvor_tools::mcp::McpServer;
57use serde::Deserialize;
58use serde_json::{Value, json};
59use std::collections::{BTreeMap, HashSet};
60use std::sync::Arc;
61use time::OffsetDateTime;
62use time::format_description::well_known::Rfc3339;
63use uuid::Uuid;
64
65use crate::error::ApiError;
66use crate::state::{AppState, BuiltAgent};
67use crate::tool_registry::ToolRegistry;
68
69impl salvor_engine::ToolResolver for ToolRegistry {
74 fn resolve_tool(&self, name: &str) -> Option<&dyn salvor_tools::DynTool> {
75 self.resolve(name)
76 }
77}
78
79#[derive(Debug, Deserialize)]
81struct StartRunRequest {
82 graph_hash: String,
84 #[serde(default)]
86 input: Value,
87 #[serde(default)]
90 labels: Option<BTreeMap<String, String>>,
91}
92
93#[derive(Debug, Default, Deserialize)]
95struct ForkRequest {
96 from_node: String,
99 #[serde(default)]
105 acknowledge_writes: Vec<u64>,
106 #[serde(default)]
109 dry_run: bool,
110}
111
112enum GraphVerb {
115 Start {
116 input: Value,
117 labels: Option<BTreeMap<String, String>>,
118 },
119 Resume(Value),
120 Recover,
121}
122
123pub async fn submit(
135 State(state): State<AppState>,
136 body: Bytes,
137) -> Result<impl IntoResponse, ApiError> {
138 let graph = parse_and_validate(&body)?;
139 let hash = graph_hash(&graph).map_err(|error| ApiError::Internal(error.to_string()))?;
140 let created = state.store_graph(hash.clone(), graph);
141 Ok((
142 StatusCode::CREATED,
143 Json(json!({ "graph": hash, "created": created })),
144 ))
145}
146
147pub async fn list(State(state): State<AppState>) -> impl IntoResponse {
151 let graphs: Vec<Value> = state
152 .graph_hashes()
153 .into_iter()
154 .filter_map(|hash| state.graph(&hash).map(|graph| (hash, graph)))
155 .map(|(hash, graph)| {
156 let mut entry = json!({ "graph": hash });
157 if let Ok(summary) = salvor_graph::validate(&graph) {
161 let object = entry.as_object_mut().expect("entry is a JSON object");
162 for (key, value) in summary_fields(&summary) {
163 object.insert(key, value);
164 }
165 }
166 entry
167 })
168 .collect();
169 Json(json!({ "graphs": graphs }))
170}
171
172pub async fn get(
175 State(state): State<AppState>,
176 Path(hash): Path<String>,
177) -> Result<impl IntoResponse, ApiError> {
178 let graph = state
179 .graph(&hash)
180 .ok_or_else(|| ApiError::UnknownGraph(format!("no graph stored under `{hash}`")))?;
181 let document = serde_json::to_value(&graph)
182 .map_err(|error| ApiError::Internal(format!("re-encoding stored graph: {error}")))?;
183 Ok(Json(json!({ "graph": hash, "document": document })))
184}
185
186pub async fn validate_only(body: Bytes) -> impl IntoResponse {
196 match parse_and_validate(&body) {
197 Ok(graph) => {
198 let hash = graph_hash(&graph).unwrap_or_default();
199 let summary = salvor_graph::validate(&graph).expect("just validated");
200 let mut object = serde_json::Map::new();
201 object.insert("valid".to_owned(), json!(true));
202 object.insert("graph".to_owned(), json!(hash));
203 object.insert("summary".to_owned(), json!(summary_object(&summary)));
204 Json(Value::Object(object))
205 }
206 Err(ApiError::InvalidGraph { errors, .. }) => {
207 Json(json!({ "valid": false, "errors": errors }))
208 }
209 Err(_) => Json(json!({ "valid": false, "errors": [] })),
211 }
212}
213
214pub async fn start_run(
226 State(state): State<AppState>,
227 body: Bytes,
228) -> Result<impl IntoResponse, ApiError> {
229 let request: StartRunRequest = parse_body(&body)?;
230 if let Some(labels) = &request.labels {
231 validate_labels(labels).map_err(ApiError::BadRequest)?;
232 }
233
234 let graph = state.graph(&request.graph_hash).ok_or_else(|| {
235 ApiError::UnknownGraph(format!(
236 "no graph stored under `{}`; submit it to POST /v1/graphs first",
237 request.graph_hash
238 ))
239 })?;
240
241 let registry = require_tools(&state, &graph)?;
244 let (agents, servers) = build_agents(&state, &graph).await?;
245
246 let run_id = RunId::new();
247 let log = state.store().read_log(run_id).await.map_err(store_error)?;
250 if !log.is_empty() {
251 close_servers(servers).await;
252 return Err(ApiError::RunExists(format!(
253 "run {} already has recorded history",
254 run_id.as_uuid()
255 )));
256 }
257
258 spawn_graph_drive(
259 state,
260 run_id,
261 graph,
262 agents,
263 servers,
264 registry,
265 GraphVerb::Start {
266 input: request.input,
267 labels: request.labels,
268 },
269 );
270 Ok((
271 StatusCode::CREATED,
272 Json(json!({ "run": run_id.as_uuid().to_string(), "status": "running" })),
273 ))
274}
275
276pub async fn projection(
281 State(state): State<AppState>,
282 Path(run_id_text): Path<String>,
283) -> Result<impl IntoResponse, ApiError> {
284 let run_id = parse_run_id(&run_id_text)?;
285 let log = state.store().read_log(run_id).await.map_err(store_error)?;
286 if log.is_empty() {
287 return Err(ApiError::UnknownRun(format!(
288 "no run {} in this store",
289 run_id.as_uuid()
290 )));
291 }
292 if !is_graph_run(&log) {
293 return Err(ApiError::NotAGraphRun(format!(
294 "run {} is an agent run, not a graph run; it has no graph projection",
295 run_id.as_uuid()
296 )));
297 }
298 Ok(Json(projection_json(&derive_graph_projection(&log))))
299}
300
301pub async fn fork(
327 State(state): State<AppState>,
328 Path(run_id_text): Path<String>,
329 body: Bytes,
330) -> Result<Response, ApiError> {
331 let origin_id = parse_run_id(&run_id_text)?;
332 let request: ForkRequest = parse_body(&body)?;
333
334 let origin_log = state
335 .store()
336 .read_log(origin_id)
337 .await
338 .map_err(store_error)?;
339 if origin_log.is_empty() {
340 return Err(ApiError::UnknownRun(format!(
341 "no run {} in this store",
342 origin_id.as_uuid()
343 )));
344 }
345
346 let derived = derive_state(&origin_log);
349 if matches!(derived.status, RunStatus::NeedsReconciliation) {
350 return Err(ApiError::OriginNeedsReconciliation {
351 message: format!(
352 "origin run {id} is parked at a dangling write; resolve it (POST /v1/runs/{id}/resolve) \
353 before forking, so the fork does not inherit an unsettled write",
354 id = origin_id.as_uuid()
355 ),
356 intent: origin_reconcile_intent(&origin_log, derived.pending_call.as_ref()),
357 });
358 }
359
360 let plan = plan_fork(&origin_log, &request.from_node).map_err(|error| match error {
362 ForkError::NotAGraphRun => ApiError::NotAGraphRun(format!(
363 "run {} is an agent run, not a graph run; only a graph run has node boundaries to fork from",
364 origin_id.as_uuid()
365 )),
366 ForkError::NodeNeverEntered { node } => ApiError::InvalidForkNode(format!(
367 "run {} never entered node `{node}`; fork from a node boundary the run reached",
368 origin_id.as_uuid()
369 )),
370 })?;
371
372 let graph = state.graph(plan.graph_hash()).ok_or_else(|| {
376 ApiError::UnknownGraph(format!(
377 "the graph {} this run executes is not stored on this server (graphs do not survive a \
378 restart); resubmit the identical document to POST /v1/graphs, then fork",
379 plan.graph_hash()
380 ))
381 })?;
382
383 let hazard_seqs = plan.hazard_seqs();
385 let acknowledged: HashSet<u64> = request.acknowledge_writes.iter().copied().collect();
386 let missing: Vec<u64> = hazard_seqs
387 .iter()
388 .copied()
389 .filter(|seq| !acknowledged.contains(seq))
390 .collect();
391
392 if request.dry_run {
394 return Ok(Json(fork_preview_json(&plan, &missing)).into_response());
395 }
396
397 if !missing.is_empty() {
401 let unacked: Vec<&WriteHazard> = plan
402 .hazards()
403 .iter()
404 .filter(|hazard| missing.contains(&hazard.seq))
405 .collect();
406 return Err(ApiError::WriteReplayHazard {
407 message: format!(
408 "forking run {} from node `{}` would re-execute {} recorded write(s) the segment \
409 re-walks; acknowledge them (acknowledge_writes: [{}]) to record that you accept \
410 they may re-fire, then fork",
411 origin_id.as_uuid(),
412 request.from_node,
413 unacked.len(),
414 missing
415 .iter()
416 .map(u64::to_string)
417 .collect::<Vec<_>>()
418 .join(", "),
419 ),
420 writes: write_hazards_json(unacked.into_iter()),
421 });
422 }
423
424 let registry = require_tools(&state, &graph)?;
427 let (agents, servers) = build_agents(&state, &graph).await?;
428
429 let child_id = RunId::new();
433 let existing = state
434 .store()
435 .read_log(child_id)
436 .await
437 .map_err(store_error)?;
438 if !existing.is_empty() {
439 close_servers(servers).await;
440 return Err(ApiError::RunExists(format!(
441 "run {} already has recorded history",
442 child_id.as_uuid()
443 )));
444 }
445 let child_prefix = plan.build_child_prefix(child_id, hazard_seqs.clone());
446 for envelope in &child_prefix {
447 if let Err(error) = state.store().append(envelope).await {
448 close_servers(servers).await;
449 return Err(store_error(error));
450 }
451 }
452
453 spawn_graph_drive(
456 state,
457 child_id,
458 graph,
459 agents,
460 servers,
461 registry,
462 GraphVerb::Recover,
463 );
464 Ok((
465 StatusCode::CREATED,
466 Json(json!({
467 "run": child_id.as_uuid().to_string(),
468 "status": "running",
469 "forked_from": {
472 "run_id": origin_id.as_uuid().to_string(),
473 "through_seq": plan.through_seq().get(),
474 "from_node": request.from_node,
475 "graph_hash": plan.graph_hash(),
476 "acknowledged_writes": hazard_seqs,
477 },
478 })),
479 )
480 .into_response())
481}
482
483pub async fn forks(
490 State(state): State<AppState>,
491 Path(run_id_text): Path<String>,
492) -> Result<impl IntoResponse, ApiError> {
493 let origin_id = parse_run_id(&run_id_text)?;
494 let origin_log = state
495 .store()
496 .read_log(origin_id)
497 .await
498 .map_err(store_error)?;
499 if origin_log.is_empty() {
500 return Err(ApiError::UnknownRun(format!(
501 "no run {} in this store",
502 origin_id.as_uuid()
503 )));
504 }
505
506 let summaries = state.store().list_runs().await.map_err(store_error)?;
510 let mut forks = Vec::new();
511 for summary in summaries {
512 if summary.run_id == origin_id {
513 continue;
514 }
515 let Ok(log) = state.store().read_log(summary.run_id).await else {
516 continue;
517 };
518 if let Some(Event::GraphRunStarted {
519 forked_from: Some(origin),
520 ..
521 }) = log.first().map(|envelope| &envelope.event)
522 && origin.run_id == origin_id
523 {
524 forks.push(json!({
525 "run": summary.run_id.as_uuid().to_string(),
526 "from_node": origin.from_node,
527 "through_seq": origin.through_seq.get(),
528 "acknowledged_writes": origin.acknowledged_writes,
529 }));
530 }
531 }
532
533 Ok(Json(json!({
534 "run": origin_id.as_uuid().to_string(),
535 "derived": true,
536 "forks": forks,
537 })))
538}
539
540pub async fn drive_resume(
549 state: AppState,
550 run_id: RunId,
551 log: &[EventEnvelope],
552 input: Option<Value>,
553) -> Result<Response, ApiError> {
554 let hash = recorded_graph_hash(log).ok_or_else(|| {
555 ApiError::Internal("graph run log has no GraphRunStarted event".to_owned())
556 })?;
557 let graph = state.graph(&hash).ok_or_else(|| {
558 ApiError::UnknownGraph(format!(
559 "the graph {hash} this run executes is not stored on this server; submit it, then resume"
560 ))
561 })?;
562 if let Some(input) = input.as_ref() {
572 refuse_nonconforming_approval(log, &graph, input)?;
573 }
574 let registry = require_tools(&state, &graph)?;
575 let (agents, servers) = build_agents(&state, &graph).await?;
576
577 let verb = match input {
578 Some(input) => GraphVerb::Resume(input),
579 None => GraphVerb::Recover,
580 };
581 spawn_graph_drive(state, run_id, graph, agents, servers, registry, verb);
582 Ok(driving(run_id).into_response())
583}
584
585fn refuse_nonconforming_approval(
595 log: &[EventEnvelope],
596 graph: &Graph,
597 input: &Value,
598) -> Result<(), ApiError> {
599 let Some(gate) = salvor_engine::parked_gate(log, graph) else {
600 if let RunStatus::Suspended { input_schema, .. } = &derive_state(log).status {
605 salvor_runtime::validate_against_schema(input, input_schema)
606 .map_err(ApiError::BadRequest)?;
607 }
608 return Ok(());
609 };
610 let violations = salvor_engine::approval_violations(input, &gate.approval_schema);
611 if violations.is_empty() {
612 return Ok(());
613 }
614 Err(ApiError::ApprovalSchemaViolation {
615 message: format!(
616 "the approval does not satisfy gate `{}`'s approval_schema; the run is still parked \
617 at that gate, so a conforming approval resumes it",
618 gate.id
619 ),
620 node: gate.id.clone(),
621 violations: Value::Array(
622 violations
623 .iter()
624 .map(|violation| json!({ "path": violation.path, "message": violation.message }))
625 .collect(),
626 ),
627 })
628}
629
630#[must_use]
632pub fn is_graph_run(log: &[EventEnvelope]) -> bool {
633 matches!(
634 log.first().map(|envelope| &envelope.event),
635 Some(Event::GraphRunStarted { .. })
636 )
637}
638
639fn spawn_graph_drive(
648 state: AppState,
649 run_id: RunId,
650 graph: Graph,
651 agents: HashMap<String, Agent>,
652 servers: Vec<McpServer>,
653 registry: Arc<ToolRegistry>,
654 verb: GraphVerb,
655) {
656 state.begin_run(run_id);
657 let task_state = state.clone();
658 let handle = tokio::spawn(async move {
659 let result = drive_graph(&task_state, run_id, &graph, &agents, ®istry, verb).await;
660 close_servers(servers).await;
661 if let Err(error) = result {
662 tracing::error!(run_id = %run_id.as_uuid(), %error, "graph run drive ended with an error");
663 }
664 task_state.end_run(run_id);
665 });
666 state.set_handle(run_id, handle);
667}
668
669async fn drive_graph(
673 state: &AppState,
674 run_id: RunId,
675 graph: &Graph,
676 agents: &HashMap<String, Agent>,
677 registry: &ToolRegistry,
678 verb: GraphVerb,
679) -> Result<GraphOutcome, salvor_engine::EngineError> {
680 let log = state
681 .store()
682 .read_log(run_id)
683 .await
684 .map_err(salvor_runtime::RuntimeError::Store)?;
685 let mut ctx: RunCtx = state.run_ctx(run_id, log)?;
686 let input = match verb {
687 GraphVerb::Start { input, labels } => {
688 if let Some(labels) = labels {
689 ctx = ctx.with_labels(labels);
690 }
691 input
692 }
693 GraphVerb::Resume(input) => {
694 ctx.set_resume_input(input);
695 Value::Null
697 }
698 GraphVerb::Recover => Value::Null,
699 };
700 run_graph(&mut ctx, graph, &input, agents, registry).await
701}
702
703async fn build_agents(
711 state: &AppState,
712 graph: &Graph,
713) -> Result<(HashMap<String, Agent>, Vec<McpServer>), ApiError> {
714 let mut agents: HashMap<String, Agent> = HashMap::new();
715 let mut servers: Vec<McpServer> = Vec::new();
716 for (node_id, hash) in referenced_agents(graph) {
717 if agents.contains_key(hash) {
718 continue;
719 }
720 let registered = state.agent(hash).ok_or_else(|| {
721 ApiError::UnknownAgent(format!(
722 "agent node `{node_id}` references agent `{hash}`, which is not registered on this \
723 server; register its definition, then start the run"
724 ))
725 })?;
726 match state.build_agent(registered.definition).await {
727 Ok(BuiltAgent { agent, servers: s }) => {
728 agents.insert(hash.to_owned(), agent);
729 servers.extend(s);
730 }
731 Err(message) => {
732 close_servers(servers).await;
733 return Err(ApiError::BadRequest(format!(
734 "agent node `{node_id}` references agent `{hash}`, which failed to build: \
735 {message}"
736 )));
737 }
738 }
739 }
740 Ok((agents, servers))
741}
742
743fn require_tools(state: &AppState, graph: &Graph) -> Result<Arc<ToolRegistry>, ApiError> {
751 let registry = state.tool_registry().ok_or_else(|| {
752 ApiError::ToolRegistryUnavailable(
753 "this server has no tool registry wired, so it cannot run a graph with `tool` nodes"
754 .to_owned(),
755 )
756 })?;
757 for node in &graph.nodes {
758 if let Node::Tool(tool) = node
759 && registry.get(&tool.tool).is_none()
760 {
761 return Err(ApiError::UnknownTool(format!(
762 "tool node `{}` names tool `{}`, which is not registered on this server",
763 tool.id, tool.tool
764 )));
765 }
766 }
767 Ok(registry)
768}
769
770fn referenced_agents(graph: &Graph) -> Vec<(&str, &str)> {
773 graph
774 .nodes
775 .iter()
776 .filter_map(|node| match node {
777 Node::Agent(agent) => Some((agent.id.as_str(), agent.agent_hash.as_str())),
778 Node::Branch(branch) => branch
779 .agent_hash
780 .as_deref()
781 .map(|hash| (branch.id.as_str(), hash)),
782 _ => None,
783 })
784 .collect()
785}
786
787fn recorded_graph_hash(log: &[EventEnvelope]) -> Option<String> {
789 log.iter().find_map(|envelope| match &envelope.event {
790 Event::GraphRunStarted { graph_hash, .. } => Some(graph_hash.clone()),
791 _ => None,
792 })
793}
794
795fn parse_and_validate(body: &Bytes) -> Result<Graph, ApiError> {
801 let graph: Graph = match serde_json::from_slice(body) {
802 Ok(graph) => graph,
803 Err(error) => {
804 return Err(ApiError::InvalidGraph {
805 message: "the graph document is not well formed".to_owned(),
806 errors: json!([{ "code": "malformed_document", "message": error.to_string() }]),
807 });
808 }
809 };
810 match salvor_graph::validate(&graph) {
811 Ok(_) => Ok(graph),
812 Err(errors) => Err(ApiError::InvalidGraph {
813 message: format!(
814 "the graph document has {} validation error(s)",
815 errors.len()
816 ),
817 errors: Value::Array(errors.iter().map(graph_error_json).collect()),
818 }),
819 }
820}
821
822fn graph_error_json(error: &GraphError) -> Value {
826 let message = error.to_string();
827 match error {
828 GraphError::UnsupportedSchemaVersion { found, supported } => json!({
829 "code": "unsupported_schema_version", "message": message,
830 "found": found, "supported": supported,
831 }),
832 GraphError::DuplicateNodeId { id } => json!({
833 "code": "duplicate_node_id", "message": message, "node": id,
834 }),
835 GraphError::DanglingEdge {
836 from,
837 to,
838 missing,
839 suggestion,
840 } => json!({
841 "code": "dangling_edge", "message": message,
842 "edge": { "from": from, "to": to }, "missing": missing, "suggestion": suggestion,
843 }),
844 GraphError::DanglingMapBody {
845 id,
846 missing,
847 suggestion,
848 } => json!({
849 "code": "dangling_map_body", "message": message,
850 "node": id, "missing": missing, "suggestion": suggestion,
851 }),
852 GraphError::DanglingFoldBody {
853 id,
854 missing,
855 suggestion,
856 } => json!({
857 "code": "dangling_fold_body", "message": message,
858 "node": id, "missing": missing, "suggestion": suggestion,
859 }),
860 GraphError::MalformedAgentHash { id, hash } => json!({
861 "code": "malformed_agent_hash", "message": message, "node": id, "hash": hash,
862 }),
863 GraphError::NonPositiveConcurrency { id, found } => json!({
864 "code": "non_positive_concurrency", "message": message, "node": id, "found": found,
865 }),
866 GraphError::NonPositiveMaxIterations { id, found } => json!({
867 "code": "non_positive_max_iterations", "message": message, "node": id, "found": found,
868 }),
869 GraphError::ApprovalSchemaNotObject { id } => json!({
870 "code": "approval_schema_not_object", "message": message, "node": id,
871 }),
872 GraphError::Cycle { path } => json!({
873 "code": "cycle", "message": message, "path": path,
874 }),
875 GraphError::EdgeTypeMismatch { from, to } => json!({
876 "code": "edge_type_mismatch", "message": message, "edge": { "from": from, "to": to },
877 }),
878 GraphError::InvalidBranchExpression { node, case, error } => json!({
879 "code": "invalid_branch_expression", "message": message,
880 "node": node, "case": case, "error": error,
881 }),
882 GraphError::ModelDecisionWithoutAgent { node, case } => json!({
883 "code": "model_decision_without_agent", "message": message, "node": node, "case": case,
884 }),
885 GraphError::InvalidFoldStopExpression { node, error } => json!({
886 "code": "invalid_fold_stop_expression", "message": message, "node": node, "error": error,
887 }),
888 GraphError::InvalidFoldJoinReference {
889 node,
890 reference,
891 error,
892 } => json!({
893 "code": "invalid_fold_join_reference", "message": message,
894 "node": node, "reference": reference, "error": error,
895 }),
896 GraphError::NodeNameTooLong { id, len, max } => json!({
897 "code": "node_name_too_long", "message": message, "node": id, "len": len, "max": max,
898 }),
899 GraphError::BlankNodeName { id } => json!({
900 "code": "blank_node_name", "message": message, "node": id,
901 }),
902 }
903}
904
905fn summary_fields(summary: &GraphSummary) -> Vec<(String, Value)> {
907 vec![
908 ("node_count".to_owned(), json!(summary.node_count)),
909 ("edge_count".to_owned(), json!(summary.edge_count)),
910 ("entry_nodes".to_owned(), json!(summary.entry_nodes)),
911 ("terminal_nodes".to_owned(), json!(summary.terminal_nodes)),
912 ]
913}
914
915fn summary_object(summary: &GraphSummary) -> Value {
917 Value::Object(summary_fields(summary).into_iter().collect())
918}
919
920fn projection_json(projection: &GraphProjection) -> Value {
924 let nodes: Vec<Value> = projection
925 .nodes
926 .iter()
927 .map(|node| {
928 let mut object = serde_json::Map::new();
929 object.insert("node".to_owned(), json!(node.node));
930 match &node.state {
931 NodeState::Entered => {
932 object.insert("state".to_owned(), json!("entered"));
933 }
934 NodeState::Exited => {
935 object.insert("state".to_owned(), json!("exited"));
936 }
937 NodeState::Skipped { reason } => {
938 object.insert("state".to_owned(), json!("skipped"));
939 object.insert("reason".to_owned(), json!(reason));
940 }
941 }
942 if let Some(case) = &node.branch_case {
943 object.insert("branch_case".to_owned(), json!(case));
944 }
945 if let Some(map) = &node.map {
946 let iterations: Vec<Value> = map
947 .iterations
948 .iter()
949 .map(|it| {
950 json!({ "index": it.index, "child_run": it.child_run, "joined": it.joined })
951 })
952 .collect();
953 object.insert(
954 "map".to_owned(),
955 json!({ "items": map.items, "iterations": iterations }),
956 );
957 }
958 if let Some(fold) = &node.fold {
959 let iterations: Vec<Value> = fold
960 .iterations
961 .iter()
962 .map(|it| json!({ "index": it.index, "joined": it.joined }))
963 .collect();
964 let mut fold_object = serde_json::Map::new();
965 fold_object.insert("iterations".to_owned(), json!(iterations));
966 if let Some(converged) = &fold.converged {
967 fold_object.insert(
968 "converged".to_owned(),
969 json!({
970 "winner_index": converged.winner_index,
971 "reason": converged.reason,
972 }),
973 );
974 }
975 object.insert("fold".to_owned(), Value::Object(fold_object));
976 }
977 Value::Object(object)
978 })
979 .collect();
980
981 let mut object = serde_json::Map::new();
982 if let Some(hash) = &projection.graph_hash {
983 object.insert("graph_hash".to_owned(), json!(hash));
984 }
985 if let Some(origin) = &projection.forked_from {
986 object.insert(
987 "forked_from".to_owned(),
988 json!({
989 "run_id": origin.run_id.as_uuid().to_string(),
990 "through_seq": origin.through_seq.get(),
991 "from_node": origin.from_node,
992 "graph_hash": origin.graph_hash,
993 "acknowledged_writes": origin.acknowledged_writes,
994 }),
995 );
996 }
997 if let Some(current) = &projection.current_node {
998 object.insert("current_node".to_owned(), json!(current));
999 }
1000 object.insert("nodes".to_owned(), Value::Array(nodes));
1001 Value::Object(object)
1002}
1003
1004fn write_hazards_json<'a>(hazards: impl Iterator<Item = &'a WriteHazard>) -> Value {
1008 Value::Array(
1009 hazards
1010 .map(|hazard| {
1011 json!({
1012 "seq": hazard.seq,
1013 "tool": hazard.tool,
1014 "input": hazard.input,
1015 "idempotency_key": hazard.idempotency_key,
1016 "recorded_at": rfc3339(hazard.recorded_at),
1017 })
1018 })
1019 .collect(),
1020 )
1021}
1022
1023fn fork_preview_json(plan: &ForkPlan, missing: &[u64]) -> Value {
1027 json!({
1028 "dry_run": true,
1029 "origin": plan.origin_run().as_uuid().to_string(),
1030 "from_node": plan.from_node(),
1031 "through_seq": plan.through_seq().get(),
1032 "graph_hash": plan.graph_hash(),
1033 "prefix_event_count": plan.prefix_len(),
1034 "writes": write_hazards_json(plan.hazards().iter()),
1035 "unacknowledged_writes": missing,
1036 "would_proceed": missing.is_empty(),
1037 })
1038}
1039
1040fn origin_reconcile_intent(log: &[EventEnvelope], pending: Option<&PendingCall>) -> Value {
1044 let mut intent = crate::json::pending(pending);
1045 if let Some(PendingCall::Tool { seq, .. }) = pending
1046 && let Some(envelope) = log.iter().find(|envelope| envelope.seq == *seq)
1047 {
1048 intent["recorded_at"] = json!(rfc3339(envelope.recorded_at));
1049 }
1050 intent
1051}
1052
1053fn rfc3339(timestamp: OffsetDateTime) -> String {
1056 timestamp.format(&Rfc3339).unwrap_or_default()
1057}
1058
1059fn driving(run_id: RunId) -> impl IntoResponse {
1062 (
1063 StatusCode::ACCEPTED,
1064 Json(json!({
1065 "run": run_id.as_uuid().to_string(),
1066 "status": "running",
1067 "outcome": "driving",
1068 })),
1069 )
1070}
1071
1072async fn close_servers(servers: Vec<McpServer>) {
1074 for server in servers {
1075 if let Err(error) = server.close().await {
1076 tracing::warn!(%error, "MCP session did not close cleanly");
1077 }
1078 }
1079}
1080
1081fn parse_body<T: for<'de> Deserialize<'de>>(body: &Bytes) -> Result<T, ApiError> {
1083 serde_json::from_slice(body)
1084 .map_err(|error| ApiError::BadRequest(format!("request body is not valid JSON: {error}")))
1085}
1086
1087fn parse_run_id(text: &str) -> Result<RunId, ApiError> {
1089 Uuid::parse_str(text).map(RunId::from_uuid).map_err(|_| {
1090 ApiError::BadRequest(format!("`{text}` is not a valid run id (expected a UUID)"))
1091 })
1092}
1093
1094fn store_error(error: salvor_store::StoreError) -> ApiError {
1096 ApiError::Internal(format!("store: {error}"))
1097}