Skip to main content

salvor_server/
graph.rs

1//! The graph control plane: submit and inspect graph documents, start graph
2//! runs, and project a graph run's per-node progress.
3//!
4//! # A graph run is an ordinary run with a richer log
5//!
6//! This module adds no new durability story. A graph run's log opens with
7//! `GraphRunStarted` instead of `RunStarted`, and its nodes narrate the walk
8//! with `NodeEntered`/`NodeExited`/`BranchTaken`/…, but every model call, tool
9//! call, suspension, and completion inside it is recorded through the exact
10//! same [`RunCtx`](salvor_runtime::RunCtx) machinery an agent run uses. So the
11//! pure-read endpoints ([`GET /v1/runs/{id}`](crate::runs::get),
12//! [`/replay`](crate::runs::replay), [`/events`](crate::sse::stream)) and even
13//! [`POST /v1/runs/{id}/resume`](crate::runs::resume) work on a graph run with
14//! their existing code: they fold or drive the log, and a graph run is just a
15//! log. The one place resume needs help is re-driving: the log carries only the
16//! graph's *hash*, so resume looks the document back up here and drives it with
17//! the engine rather than the built-in loop. See [`drive_resume`].
18//!
19//! # Strict at submit, honest at resolution
20//!
21//! `POST /v1/graphs` validates a document all at once (collect-all,
22//! node/edge-precise) and refuses the whole thing with `400 invalid_graph` on
23//! any error — the strict-at-submit posture a control document earns. Starting
24//! a run then resolves what the document references against the server's live
25//! inventory, synchronously, before the run is spawned: every `agent` node's
26//! hash must be a registered agent (built through the same
27//! [`AgentFactory`](crate::AgentFactory) an agent run uses), and every `tool`
28//! node's tool must be present in the server's [`ToolRegistry`]. A missing
29//! reference is a synchronous `404` naming the node, not a background failure,
30//! so an operator learns at submit that a run cannot proceed rather than
31//! watching it stall.
32//!
33//! # Tool resolution: the existing registry, nothing new
34//!
35//! A graph `tool` node resolves through the server's [`ToolRegistry`] — the
36//! SAME seam a client-driven tool step dispatches through. No new
37//! tool-registration surface exists for graphs. `salvor serve` wires that
38//! registry EMPTY, so on a stock server every `tool` node is a precise
39//! `unknown_tool` refusal until a host registers the tool it names; a test (or
40//! a future `aarg serve`) injects a registry holding its tools.
41
42use 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
69/// A graph `tool` node resolves through the server's whole tool inventory: the
70/// [`ToolRegistry`] the client-driven tool step already dispatches through.
71/// Implementing the engine's resolver on it directly is what lets the engine
72/// stay ignorant of where the server keeps its tools.
73impl 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/// The body of `POST /v1/graph-runs`.
80#[derive(Debug, Deserialize)]
81struct StartRunRequest {
82    /// The hash of a previously submitted graph document.
83    graph_hash: String,
84    /// The run input. Defaults to JSON null when omitted.
85    #[serde(default)]
86    input: Value,
87    /// Optional correlation tags, recorded once on `GraphRunStarted`, checked
88    /// against the same bounds an agent run's labels are.
89    #[serde(default)]
90    labels: Option<BTreeMap<String, String>>,
91}
92
93/// The body of `POST /v1/runs/{id}/fork`.
94#[derive(Debug, Default, Deserialize)]
95struct ForkRequest {
96    /// The node boundary to restart the fork from: the fork's log carries the
97    /// origin's events below this node's `NodeEntered`, then re-walks from it.
98    from_node: String,
99    /// The origin log positions of the `Effect::Write` intents in the re-walked
100    /// segment the operator acknowledges. Must cover the full hazard set for the
101    /// fork to proceed; the covered seqs are recorded permanently into the
102    /// child's `ForkOrigin.acknowledged_writes`. Empty (the default) when the
103    /// fork boundary sits before any write.
104    #[serde(default)]
105    acknowledge_writes: Vec<u64>,
106    /// When true, return what the fork WOULD do (the hazard list and the
107    /// would-be prefix summary) and create no run.
108    #[serde(default)]
109    dry_run: bool,
110}
111
112/// Which verb a graph driver task runs. Mirrors the built-in loop's start /
113/// resume / recover, but over the engine rather than the agent loop.
114enum GraphVerb {
115    Start {
116        input: Value,
117        labels: Option<BTreeMap<String, String>>,
118    },
119    Resume(Value),
120    Recover,
121}
122
123// ---------------------------------------------------------------------------
124// Graph documents: submit, list, get, validate.
125// ---------------------------------------------------------------------------
126
127/// `POST /v1/graphs`: submit (and strictly validate) a graph document.
128///
129/// The body is the document JSON. On success the document is stored
130/// content-addressed by its reproducible [`graph_hash`], and the response is
131/// `{ "graph": "<hash>", "created": <bool> }`, where `created` is `false` when
132/// the identical document was already stored (idempotent re-submit). Any
133/// validation failure is `400 invalid_graph` carrying the complete error list.
134pub 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
147/// `GET /v1/graphs`: one entry per stored graph, each with its hash and a
148/// shape summary (node/edge counts, entry and terminal node ids), following the
149/// enriched-runs precedent.
150pub 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            // A stored graph passed validation at submit, so this summarizes
158            // rather than re-validates; the fields are omitted (never faked)
159            // in the impossible event a stored document does not summarize.
160            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
172/// `GET /v1/graphs/{hash}`: the stored document, under its hash. `404
173/// unknown_graph` when nothing is stored under the hash.
174pub 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
186/// `POST /v1/graphs/validate`: validate a document without storing it.
187///
188/// This is submit's dry run, the graph counterpart of `/replay`: it always
189/// answers the question rather than treating an invalid document as a bad
190/// request. On a valid document it returns `{ "valid": true, "graph": "<hash>",
191/// "summary": {...} }` (the hash the document WOULD get, plus the shape summary
192/// the CLI prints); on an invalid one it returns `{ "valid": false, "errors":
193/// [...] }` with the same node/edge-precise list `POST /v1/graphs` would refuse
194/// with. Nothing is ever stored.
195pub 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        // parse_and_validate only ever yields Ok or InvalidGraph.
210        Err(_) => Json(json!({ "valid": false, "errors": [] })),
211    }
212}
213
214// ---------------------------------------------------------------------------
215// Graph runs.
216// ---------------------------------------------------------------------------
217
218/// `POST /v1/graph-runs`: start a run of a stored graph, and return its id at
219/// once (the same fire-and-return shape `POST /v1/runs` uses).
220///
221/// Resolution is synchronous and up front: `404 unknown_graph` for an unknown
222/// hash, `400 bad_request` for out-of-bounds labels, `404 unknown_agent` /
223/// `404 unknown_tool` (naming the node) for a reference the server cannot
224/// supply. Only once everything resolves is the run spawned.
225pub 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    // Resolve every referenced tool and agent synchronously; a missing one is a
242    // 404 naming the node, before any run head is written.
243    let registry = require_tools(&state, &graph)?;
244    let (agents, servers) = build_agents(&state, &graph).await?;
245
246    let run_id = RunId::new();
247    // A minted id is fresh by construction, but guard defensively the same way
248    // `POST /v1/runs` does before writing the run head.
249    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
276/// `GET /v1/runs/{id}/graph`: the graph run's per-node projection, for the
277/// canvas. `404 unknown_run` when the id has no history; `409 not_a_graph_run`
278/// when the run is an ordinary agent run (its log has no `GraphRunStarted`
279/// head), mirroring the existing 409 error shape.
280pub 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
301/// `POST /v1/runs/{id}/fork`: fork a graph run from a node boundary into a NEW
302/// run, refusing (or recording an acknowledgement for) the writes the re-walked
303/// segment would re-fire.
304///
305/// The origin is never touched: this reads its log, plans the fork purely
306/// ([`salvor_engine::plan_fork`]), and — on success — writes the child's prefix
307/// under a fresh id and drives it onward from the fork node, exactly as a
308/// recovered graph run continues. The refusals, each typed and precise:
309///
310/// - `404 unknown_run` — no such origin.
311/// - `409 not_a_graph_run` — the origin is an ordinary agent run.
312/// - `409 invalid_fork_node` — the origin never entered the named node.
313/// - `404 unknown_graph` — the origin's graph is no longer in the registry
314///   (graphs do not survive a restart); resubmit the identical document, then
315///   fork.
316/// - `409 origin_needs_reconciliation` — the origin is parked at a dangling
317///   write; resolve it first.
318/// - `409 write_replay_hazard` — the re-walked segment holds `Effect::Write`
319///   intents not covered by `acknowledge_writes`; `details.writes` lists exactly
320///   the ones still needing acknowledgement.
321///
322/// `dry_run: true` returns the preview (the hazard list and the would-be prefix
323/// summary) and creates nothing; the structural refusals above still apply, so a
324/// dry run reports a fork that could never proceed rather than pretending it
325/// could.
326pub 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    // An origin parked at a dangling write must be resolved first: forking past
347    // an unsettled write would carry that ambiguity into the child.
348    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    // Plan the fork purely: find the boundary, the prefix, and the hazard set.
361    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    // A fork reuses the origin's graph unchanged, so the document must still be
373    // in the registry. It is in-memory, so a restart drops it: refuse honestly
374    // and tell the operator to resubmit the identical document.
375    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    // Which hazard writes are still unacknowledged?
384    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    // dry_run: preview, create nothing.
393    if request.dry_run {
394        return Ok(Json(fork_preview_json(&plan, &missing)).into_response());
395    }
396
397    // Refuse-then-record: any unacknowledged hazard refuses, listing exactly the
398    // writes still needing acknowledgement (all of them on a first, bare fork; a
399    // partial acknowledgement narrows the list to what is missing).
400    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    // Everything the child references must resolve, exactly as a fresh graph run
425    // does, before any envelope is written.
426    let registry = require_tools(&state, &graph)?;
427    let (agents, servers) = build_agents(&state, &graph).await?;
428
429    // Mint the child and write its prefix: the origin's events below the fork
430    // node, rewritten under the child id, seq-0 carrying the fork origin with the
431    // acknowledged writes. The child exists, standalone, the instant we return.
432    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    // Continue executing from the fork node, exactly like a recovered graph run:
454    // the engine replays the prefix and drives the fork node onward live.
455    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            // The recorded fork origin, the same shape the graph projection's
470            // `forked_from` carries (the `ForkOrigin` fields), so the two agree.
471            "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
483/// `GET /v1/runs/{id}/forks`: the forks of a run, as a DERIVED index.
484///
485/// The origin is immutable and never points forward at its children; this
486/// answer is a server-side scan of every run's `forked_from`, not a fact the
487/// origin recorded. The response is labeled `"derived": true` to say so. `404
488/// unknown_run` when the id has no history.
489pub 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    // Scan every run for a GraphRunStarted head whose fork origin names this run.
507    // An unreadable individual log is skipped, never allowed to fail the whole
508    // listing (the same posture GET /v1/runs takes).
509    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
540/// Drives a parked or crashed GRAPH run further, for
541/// [`crate::runs::resume`]'s graph branch. The resume handler has already read
542/// the log, classified the run, and validated the input against the recorded
543/// suspension schema; this resolves the graph document (by the hash the log
544/// records) and its references, then spawns the engine over it. Returns the
545/// same `202 driving` body the agent-run resume path returns.
546pub 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/// Whether a log is a graph run: its first event is `GraphRunStarted`.
572#[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
580// ---------------------------------------------------------------------------
581// Internals: the driver task, resolution, and JSON shaping.
582// ---------------------------------------------------------------------------
583
584/// Spawns the task that drives a graph run to its next resting point over the
585/// engine, closing the built agents' MCP sessions afterward. Marks the run
586/// active before spawning so a concurrent stream cannot miss it, exactly as the
587/// agent-run driver does.
588fn 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, &registry, 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
610/// Builds the run's [`RunCtx`] and drives the engine over it. A resume seeds the
611/// recorded input through [`RunCtx::set_resume_input`] before driving, exactly
612/// as `Runtime::resume` does for an agent run; a fresh start stamps the labels.
613async 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            // The recorded input wins on replay; begin_graph ignores this one.
637            Value::Null
638        }
639        GraphVerb::Recover => Value::Null,
640    };
641    run_graph(&mut ctx, graph, &input, agents, registry).await
642}
643
644/// Builds every agent the graph references (an `agent` node's hash, or a
645/// model-decision `branch`'s), keyed by the hash the document declares.
646///
647/// An unregistered hash is `404 unknown_agent` naming the node; a definition
648/// that will not build is `400`. The built agents' MCP sessions ride back in
649/// the returned `Vec` so the driver can keep them alive for the run and close
650/// them after.
651async 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
684/// Checks every `tool` node's tool is present in the server's registry,
685/// returning the registry (cloned handle) for the driver to resolve through.
686///
687/// `503 tool_registry_unavailable` when no registry is wired at all; `404
688/// unknown_tool` (naming the node) for a tool the wired registry does not hold.
689/// `salvor serve` wires an empty registry, so on a stock server the first
690/// `tool` node a graph declares is a clean `unknown_tool`.
691fn 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
711/// Every agent a graph references, as `(node id, agent hash)` pairs: each
712/// `agent` node, plus each model-decision `branch`'s agent.
713fn 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
728/// The `graph_hash` recorded in a graph run's `GraphRunStarted` head.
729fn 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
736/// Parses a graph document strictly and runs every validation check, mapping any
737/// failure to `400 invalid_graph` with the complete, node/edge-precise error
738/// list. A strict parse failure (an unknown field, a missing one, malformed
739/// JSON) is reported as a single `invalid_graph` error, since the document could
740/// not be typed well enough to run the collect-all checks over it.
741fn 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
763/// Renders one [`GraphError`] as a structured JSON object: a stable `code`, the
764/// human `message`, and the node or edge it names, so a client can pinpoint the
765/// fault without parsing the sentence.
766fn 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
846/// The shape summary as `(key, value)` pairs, for splicing onto a list entry.
847fn 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
856/// The shape summary as a standalone object, for the validate response.
857fn summary_object(summary: &GraphSummary) -> Value {
858    Value::Object(summary_fields(summary).into_iter().collect())
859}
860
861/// Serializes a [`GraphProjection`] for the canvas, honoring absent-vs-null: a
862/// branch case, a map, and the fork origin appear only when recorded, and a
863/// node's state carries its skip reason only when it was skipped.
864fn 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
945/// Renders a set of [`WriteHazard`]s as the `details.writes` / preview array:
946/// each `{ seq, tool, input, idempotency_key, recorded_at }`, mirroring the
947/// reconciliation refusal's intent shape.
948fn 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
964/// The dry-run preview: what the fork WOULD do, creating nothing. Carries the
965/// full hazard list, the would-be prefix summary, and whether the fork would
966/// proceed under the acknowledgement supplied.
967fn 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
981/// The origin's dangling-write intent, for the `origin_needs_reconciliation`
982/// refusal: the pending call plus when it was recorded, the same evidence a
983/// resume reconciliation refusal carries.
984fn 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
994/// Formats a timestamp as RFC 3339, the wire form the reconciliation intent and
995/// the store summaries use.
996fn rfc3339(timestamp: OffsetDateTime) -> String {
997    timestamp.format(&Rfc3339).unwrap_or_default()
998}
999
1000/// The 202 body for a run now driving in the background (identical to the
1001/// agent-run resume path's).
1002fn 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
1013/// Closes every MCP session, logging (not propagating) a teardown hiccup.
1014async 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
1022/// Parses a JSON body into `T`, mapping a decode failure to a `400`.
1023fn 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
1028/// Parses a run id from its UUID string, mapping a bad id to a `400`.
1029fn 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
1035/// Maps a store error to a `500`; the store failing is not the client's fault.
1036fn store_error(error: salvor_store::StoreError) -> ApiError {
1037    ApiError::Internal(format!("store: {error}"))
1038}