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(
547 state: AppState,
548 run_id: RunId,
549 log: &[EventEnvelope],
550 input: Option<Value>,
551) -> Result<Response, ApiError> {
552 let hash = recorded_graph_hash(log).ok_or_else(|| {
553 ApiError::Internal("graph run log has no GraphRunStarted event".to_owned())
554 })?;
555 let graph = state.graph(&hash).ok_or_else(|| {
556 ApiError::UnknownGraph(format!(
557 "the graph {hash} this run executes is not stored on this server; submit it, then resume"
558 ))
559 })?;
560 let registry = require_tools(&state, &graph)?;
561 let (agents, servers) = build_agents(&state, &graph).await?;
562
563 let verb = match input {
564 Some(input) => GraphVerb::Resume(input),
565 None => GraphVerb::Recover,
566 };
567 spawn_graph_drive(state, run_id, graph, agents, servers, registry, verb);
568 Ok(driving(run_id).into_response())
569}
570
571#[must_use]
573pub fn is_graph_run(log: &[EventEnvelope]) -> bool {
574 matches!(
575 log.first().map(|envelope| &envelope.event),
576 Some(Event::GraphRunStarted { .. })
577 )
578}
579
580fn spawn_graph_drive(
589 state: AppState,
590 run_id: RunId,
591 graph: Graph,
592 agents: HashMap<String, Agent>,
593 servers: Vec<McpServer>,
594 registry: Arc<ToolRegistry>,
595 verb: GraphVerb,
596) {
597 state.begin_run(run_id);
598 let task_state = state.clone();
599 let handle = tokio::spawn(async move {
600 let result = drive_graph(&task_state, run_id, &graph, &agents, ®istry, verb).await;
601 close_servers(servers).await;
602 if let Err(error) = result {
603 tracing::error!(run_id = %run_id.as_uuid(), %error, "graph run drive ended with an error");
604 }
605 task_state.end_run(run_id);
606 });
607 state.set_handle(run_id, handle);
608}
609
610async fn drive_graph(
614 state: &AppState,
615 run_id: RunId,
616 graph: &Graph,
617 agents: &HashMap<String, Agent>,
618 registry: &ToolRegistry,
619 verb: GraphVerb,
620) -> Result<GraphOutcome, salvor_engine::EngineError> {
621 let log = state
622 .store()
623 .read_log(run_id)
624 .await
625 .map_err(salvor_runtime::RuntimeError::Store)?;
626 let mut ctx: RunCtx = state.run_ctx(run_id, log)?;
627 let input = match verb {
628 GraphVerb::Start { input, labels } => {
629 if let Some(labels) = labels {
630 ctx = ctx.with_labels(labels);
631 }
632 input
633 }
634 GraphVerb::Resume(input) => {
635 ctx.set_resume_input(input);
636 Value::Null
638 }
639 GraphVerb::Recover => Value::Null,
640 };
641 run_graph(&mut ctx, graph, &input, agents, registry).await
642}
643
644async fn build_agents(
652 state: &AppState,
653 graph: &Graph,
654) -> Result<(HashMap<String, Agent>, Vec<McpServer>), ApiError> {
655 let mut agents: HashMap<String, Agent> = HashMap::new();
656 let mut servers: Vec<McpServer> = Vec::new();
657 for (node_id, hash) in referenced_agents(graph) {
658 if agents.contains_key(hash) {
659 continue;
660 }
661 let registered = state.agent(hash).ok_or_else(|| {
662 ApiError::UnknownAgent(format!(
663 "agent node `{node_id}` references agent `{hash}`, which is not registered on this \
664 server; register its definition, then start the run"
665 ))
666 })?;
667 match state.build_agent(registered.definition).await {
668 Ok(BuiltAgent { agent, servers: s }) => {
669 agents.insert(hash.to_owned(), agent);
670 servers.extend(s);
671 }
672 Err(message) => {
673 close_servers(servers).await;
674 return Err(ApiError::BadRequest(format!(
675 "agent node `{node_id}` references agent `{hash}`, which failed to build: \
676 {message}"
677 )));
678 }
679 }
680 }
681 Ok((agents, servers))
682}
683
684fn require_tools(state: &AppState, graph: &Graph) -> Result<Arc<ToolRegistry>, ApiError> {
692 let registry = state.tool_registry().ok_or_else(|| {
693 ApiError::ToolRegistryUnavailable(
694 "this server has no tool registry wired, so it cannot run a graph with `tool` nodes"
695 .to_owned(),
696 )
697 })?;
698 for node in &graph.nodes {
699 if let Node::Tool(tool) = node
700 && registry.get(&tool.tool).is_none()
701 {
702 return Err(ApiError::UnknownTool(format!(
703 "tool node `{}` names tool `{}`, which is not registered on this server",
704 tool.id, tool.tool
705 )));
706 }
707 }
708 Ok(registry)
709}
710
711fn referenced_agents(graph: &Graph) -> Vec<(&str, &str)> {
714 graph
715 .nodes
716 .iter()
717 .filter_map(|node| match node {
718 Node::Agent(agent) => Some((agent.id.as_str(), agent.agent_hash.as_str())),
719 Node::Branch(branch) => branch
720 .agent_hash
721 .as_deref()
722 .map(|hash| (branch.id.as_str(), hash)),
723 _ => None,
724 })
725 .collect()
726}
727
728fn recorded_graph_hash(log: &[EventEnvelope]) -> Option<String> {
730 log.iter().find_map(|envelope| match &envelope.event {
731 Event::GraphRunStarted { graph_hash, .. } => Some(graph_hash.clone()),
732 _ => None,
733 })
734}
735
736fn parse_and_validate(body: &Bytes) -> Result<Graph, ApiError> {
742 let graph: Graph = match serde_json::from_slice(body) {
743 Ok(graph) => graph,
744 Err(error) => {
745 return Err(ApiError::InvalidGraph {
746 message: "the graph document is not well formed".to_owned(),
747 errors: json!([{ "code": "malformed_document", "message": error.to_string() }]),
748 });
749 }
750 };
751 match salvor_graph::validate(&graph) {
752 Ok(_) => Ok(graph),
753 Err(errors) => Err(ApiError::InvalidGraph {
754 message: format!(
755 "the graph document has {} validation error(s)",
756 errors.len()
757 ),
758 errors: Value::Array(errors.iter().map(graph_error_json).collect()),
759 }),
760 }
761}
762
763fn graph_error_json(error: &GraphError) -> Value {
767 let message = error.to_string();
768 match error {
769 GraphError::UnsupportedSchemaVersion { found, supported } => json!({
770 "code": "unsupported_schema_version", "message": message,
771 "found": found, "supported": supported,
772 }),
773 GraphError::DuplicateNodeId { id } => json!({
774 "code": "duplicate_node_id", "message": message, "node": id,
775 }),
776 GraphError::DanglingEdge {
777 from,
778 to,
779 missing,
780 suggestion,
781 } => json!({
782 "code": "dangling_edge", "message": message,
783 "edge": { "from": from, "to": to }, "missing": missing, "suggestion": suggestion,
784 }),
785 GraphError::DanglingMapBody {
786 id,
787 missing,
788 suggestion,
789 } => json!({
790 "code": "dangling_map_body", "message": message,
791 "node": id, "missing": missing, "suggestion": suggestion,
792 }),
793 GraphError::DanglingFoldBody {
794 id,
795 missing,
796 suggestion,
797 } => json!({
798 "code": "dangling_fold_body", "message": message,
799 "node": id, "missing": missing, "suggestion": suggestion,
800 }),
801 GraphError::MalformedAgentHash { id, hash } => json!({
802 "code": "malformed_agent_hash", "message": message, "node": id, "hash": hash,
803 }),
804 GraphError::NonPositiveConcurrency { id, found } => json!({
805 "code": "non_positive_concurrency", "message": message, "node": id, "found": found,
806 }),
807 GraphError::NonPositiveMaxIterations { id, found } => json!({
808 "code": "non_positive_max_iterations", "message": message, "node": id, "found": found,
809 }),
810 GraphError::ApprovalSchemaNotObject { id } => json!({
811 "code": "approval_schema_not_object", "message": message, "node": id,
812 }),
813 GraphError::Cycle { path } => json!({
814 "code": "cycle", "message": message, "path": path,
815 }),
816 GraphError::EdgeTypeMismatch { from, to } => json!({
817 "code": "edge_type_mismatch", "message": message, "edge": { "from": from, "to": to },
818 }),
819 GraphError::InvalidBranchExpression { node, case, error } => json!({
820 "code": "invalid_branch_expression", "message": message,
821 "node": node, "case": case, "error": error,
822 }),
823 GraphError::ModelDecisionWithoutAgent { node, case } => json!({
824 "code": "model_decision_without_agent", "message": message, "node": node, "case": case,
825 }),
826 GraphError::InvalidFoldStopExpression { node, error } => json!({
827 "code": "invalid_fold_stop_expression", "message": message, "node": node, "error": error,
828 }),
829 GraphError::InvalidFoldJoinReference {
830 node,
831 reference,
832 error,
833 } => json!({
834 "code": "invalid_fold_join_reference", "message": message,
835 "node": node, "reference": reference, "error": error,
836 }),
837 GraphError::NodeNameTooLong { id, len, max } => json!({
838 "code": "node_name_too_long", "message": message, "node": id, "len": len, "max": max,
839 }),
840 GraphError::BlankNodeName { id } => json!({
841 "code": "blank_node_name", "message": message, "node": id,
842 }),
843 }
844}
845
846fn summary_fields(summary: &GraphSummary) -> Vec<(String, Value)> {
848 vec![
849 ("node_count".to_owned(), json!(summary.node_count)),
850 ("edge_count".to_owned(), json!(summary.edge_count)),
851 ("entry_nodes".to_owned(), json!(summary.entry_nodes)),
852 ("terminal_nodes".to_owned(), json!(summary.terminal_nodes)),
853 ]
854}
855
856fn summary_object(summary: &GraphSummary) -> Value {
858 Value::Object(summary_fields(summary).into_iter().collect())
859}
860
861fn projection_json(projection: &GraphProjection) -> Value {
865 let nodes: Vec<Value> = projection
866 .nodes
867 .iter()
868 .map(|node| {
869 let mut object = serde_json::Map::new();
870 object.insert("node".to_owned(), json!(node.node));
871 match &node.state {
872 NodeState::Entered => {
873 object.insert("state".to_owned(), json!("entered"));
874 }
875 NodeState::Exited => {
876 object.insert("state".to_owned(), json!("exited"));
877 }
878 NodeState::Skipped { reason } => {
879 object.insert("state".to_owned(), json!("skipped"));
880 object.insert("reason".to_owned(), json!(reason));
881 }
882 }
883 if let Some(case) = &node.branch_case {
884 object.insert("branch_case".to_owned(), json!(case));
885 }
886 if let Some(map) = &node.map {
887 let iterations: Vec<Value> = map
888 .iterations
889 .iter()
890 .map(|it| {
891 json!({ "index": it.index, "child_run": it.child_run, "joined": it.joined })
892 })
893 .collect();
894 object.insert(
895 "map".to_owned(),
896 json!({ "items": map.items, "iterations": iterations }),
897 );
898 }
899 if let Some(fold) = &node.fold {
900 let iterations: Vec<Value> = fold
901 .iterations
902 .iter()
903 .map(|it| json!({ "index": it.index, "joined": it.joined }))
904 .collect();
905 let mut fold_object = serde_json::Map::new();
906 fold_object.insert("iterations".to_owned(), json!(iterations));
907 if let Some(converged) = &fold.converged {
908 fold_object.insert(
909 "converged".to_owned(),
910 json!({
911 "winner_index": converged.winner_index,
912 "reason": converged.reason,
913 }),
914 );
915 }
916 object.insert("fold".to_owned(), Value::Object(fold_object));
917 }
918 Value::Object(object)
919 })
920 .collect();
921
922 let mut object = serde_json::Map::new();
923 if let Some(hash) = &projection.graph_hash {
924 object.insert("graph_hash".to_owned(), json!(hash));
925 }
926 if let Some(origin) = &projection.forked_from {
927 object.insert(
928 "forked_from".to_owned(),
929 json!({
930 "run_id": origin.run_id.as_uuid().to_string(),
931 "through_seq": origin.through_seq.get(),
932 "from_node": origin.from_node,
933 "graph_hash": origin.graph_hash,
934 "acknowledged_writes": origin.acknowledged_writes,
935 }),
936 );
937 }
938 if let Some(current) = &projection.current_node {
939 object.insert("current_node".to_owned(), json!(current));
940 }
941 object.insert("nodes".to_owned(), Value::Array(nodes));
942 Value::Object(object)
943}
944
945fn write_hazards_json<'a>(hazards: impl Iterator<Item = &'a WriteHazard>) -> Value {
949 Value::Array(
950 hazards
951 .map(|hazard| {
952 json!({
953 "seq": hazard.seq,
954 "tool": hazard.tool,
955 "input": hazard.input,
956 "idempotency_key": hazard.idempotency_key,
957 "recorded_at": rfc3339(hazard.recorded_at),
958 })
959 })
960 .collect(),
961 )
962}
963
964fn fork_preview_json(plan: &ForkPlan, missing: &[u64]) -> Value {
968 json!({
969 "dry_run": true,
970 "origin": plan.origin_run().as_uuid().to_string(),
971 "from_node": plan.from_node(),
972 "through_seq": plan.through_seq().get(),
973 "graph_hash": plan.graph_hash(),
974 "prefix_event_count": plan.prefix_len(),
975 "writes": write_hazards_json(plan.hazards().iter()),
976 "unacknowledged_writes": missing,
977 "would_proceed": missing.is_empty(),
978 })
979}
980
981fn origin_reconcile_intent(log: &[EventEnvelope], pending: Option<&PendingCall>) -> Value {
985 let mut intent = crate::json::pending(pending);
986 if let Some(PendingCall::Tool { seq, .. }) = pending
987 && let Some(envelope) = log.iter().find(|envelope| envelope.seq == *seq)
988 {
989 intent["recorded_at"] = json!(rfc3339(envelope.recorded_at));
990 }
991 intent
992}
993
994fn rfc3339(timestamp: OffsetDateTime) -> String {
997 timestamp.format(&Rfc3339).unwrap_or_default()
998}
999
1000fn driving(run_id: RunId) -> impl IntoResponse {
1003 (
1004 StatusCode::ACCEPTED,
1005 Json(json!({
1006 "run": run_id.as_uuid().to_string(),
1007 "status": "running",
1008 "outcome": "driving",
1009 })),
1010 )
1011}
1012
1013async fn close_servers(servers: Vec<McpServer>) {
1015 for server in servers {
1016 if let Err(error) = server.close().await {
1017 tracing::warn!(%error, "MCP session did not close cleanly");
1018 }
1019 }
1020}
1021
1022fn parse_body<T: for<'de> Deserialize<'de>>(body: &Bytes) -> Result<T, ApiError> {
1024 serde_json::from_slice(body)
1025 .map_err(|error| ApiError::BadRequest(format!("request body is not valid JSON: {error}")))
1026}
1027
1028fn parse_run_id(text: &str) -> Result<RunId, ApiError> {
1030 Uuid::parse_str(text).map(RunId::from_uuid).map_err(|_| {
1031 ApiError::BadRequest(format!("`{text}` is not a valid run id (expected a UUID)"))
1032 })
1033}
1034
1035fn store_error(error: salvor_store::StoreError) -> ApiError {
1037 ApiError::Internal(format!("store: {error}"))
1038}