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 and classified the run, and hands a graph run's input here UNCHECKED
543/// (see [`refuse_nonconforming_approval`] for why the check belongs on this
544/// side). This resolves the graph document (by the hash the log records) and
545/// its references, vets the input against the gate or tool suspension the run
546/// is parked at, then spawns the engine over it. Returns the same
547/// `202 driving` body the agent-run resume path returns.
548pub 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    // The gate accept edge, on this layer. The engine enforces the same rule
563    // between its `suspend` and `await_resume` (see `salvor_engine::approval`),
564    // but that refusal would surface inside the spawned driver task, long after
565    // this handler has answered 202. Checking here makes it the synchronous 400
566    // the caller can act on, and it happens before anything is spawned, so the
567    // log is untouched and the run stays parked at the gate. The recorded
568    // suspension schema `crate::runs::resume` already checked is the same
569    // schema; what this adds is a real JSON Schema validator and the gate's
570    // node id, neither of which the recorded `Suspended` alone provides.
571    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
585/// Refuses a resume input that does not satisfy the `approval_schema` of the
586/// gate the run is parked at, as `400 approval_schema_violation` naming the
587/// node and every violation.
588///
589/// This is the whole of a graph run's suspension validation:
590/// [`crate::runs::resume`] delegates rather than checking first, so the gate's
591/// precise refusal is never pre-empted by a vaguer one. A run parked at a tool
592/// suspension instead falls back to the recorded-schema check an agent run
593/// gets.
594fn 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        // Not parked at a gate: apply the same recorded-schema check the
601        // agent-run resume path applies, so a graph run parked at a TOOL
602        // suspension is validated exactly as it always was. `crate::runs::resume`
603        // hands graph runs straight here rather than checking twice.
604        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/// Whether a log is a graph run: its first event is `GraphRunStarted`.
631#[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
639// ---------------------------------------------------------------------------
640// Internals: the driver task, resolution, and JSON shaping.
641// ---------------------------------------------------------------------------
642
643/// Spawns the task that drives a graph run to its next resting point over the
644/// engine, closing the built agents' MCP sessions afterward. Marks the run
645/// active before spawning so a concurrent stream cannot miss it, exactly as the
646/// agent-run driver does.
647fn 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, &registry, 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
669/// Builds the run's [`RunCtx`] and drives the engine over it. A resume seeds the
670/// recorded input through [`RunCtx::set_resume_input`] before driving, exactly
671/// as `Runtime::resume` does for an agent run; a fresh start stamps the labels.
672async 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            // The recorded input wins on replay; begin_graph ignores this one.
696            Value::Null
697        }
698        GraphVerb::Recover => Value::Null,
699    };
700    run_graph(&mut ctx, graph, &input, agents, registry).await
701}
702
703/// Builds every agent the graph references (an `agent` node's hash, or a
704/// model-decision `branch`'s), keyed by the hash the document declares.
705///
706/// An unregistered hash is `404 unknown_agent` naming the node; a definition
707/// that will not build is `400`. The built agents' MCP sessions ride back in
708/// the returned `Vec` so the driver can keep them alive for the run and close
709/// them after.
710async 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
743/// Checks every `tool` node's tool is present in the server's registry,
744/// returning the registry (cloned handle) for the driver to resolve through.
745///
746/// `503 tool_registry_unavailable` when no registry is wired at all; `404
747/// unknown_tool` (naming the node) for a tool the wired registry does not hold.
748/// `salvor serve` wires an empty registry, so on a stock server the first
749/// `tool` node a graph declares is a clean `unknown_tool`.
750fn 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
770/// Every agent a graph references, as `(node id, agent hash)` pairs: each
771/// `agent` node, plus each model-decision `branch`'s agent.
772fn 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
787/// The `graph_hash` recorded in a graph run's `GraphRunStarted` head.
788fn 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
795/// Parses a graph document strictly and runs every validation check, mapping any
796/// failure to `400 invalid_graph` with the complete, node/edge-precise error
797/// list. A strict parse failure (an unknown field, a missing one, malformed
798/// JSON) is reported as a single `invalid_graph` error, since the document could
799/// not be typed well enough to run the collect-all checks over it.
800fn 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
822/// Renders one [`GraphError`] as a structured JSON object: a stable `code`, the
823/// human `message`, and the node or edge it names, so a client can pinpoint the
824/// fault without parsing the sentence.
825fn 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
905/// The shape summary as `(key, value)` pairs, for splicing onto a list entry.
906fn 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
915/// The shape summary as a standalone object, for the validate response.
916fn summary_object(summary: &GraphSummary) -> Value {
917    Value::Object(summary_fields(summary).into_iter().collect())
918}
919
920/// Serializes a [`GraphProjection`] for the canvas, honoring absent-vs-null: a
921/// branch case, a map, and the fork origin appear only when recorded, and a
922/// node's state carries its skip reason only when it was skipped.
923fn 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
1004/// Renders a set of [`WriteHazard`]s as the `details.writes` / preview array:
1005/// each `{ seq, tool, input, idempotency_key, recorded_at }`, mirroring the
1006/// reconciliation refusal's intent shape.
1007fn 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
1023/// The dry-run preview: what the fork WOULD do, creating nothing. Carries the
1024/// full hazard list, the would-be prefix summary, and whether the fork would
1025/// proceed under the acknowledgement supplied.
1026fn 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
1040/// The origin's dangling-write intent, for the `origin_needs_reconciliation`
1041/// refusal: the pending call plus when it was recorded, the same evidence a
1042/// resume reconciliation refusal carries.
1043fn 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
1053/// Formats a timestamp as RFC 3339, the wire form the reconciliation intent and
1054/// the store summaries use.
1055fn rfc3339(timestamp: OffsetDateTime) -> String {
1056    timestamp.format(&Rfc3339).unwrap_or_default()
1057}
1058
1059/// The 202 body for a run now driving in the background (identical to the
1060/// agent-run resume path's).
1061fn 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
1072/// Closes every MCP session, logging (not propagating) a teardown hiccup.
1073async 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
1081/// Parses a JSON body into `T`, mapping a decode failure to a `400`.
1082fn 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
1087/// Parses a run id from its UUID string, mapping a bad id to a `400`.
1088fn 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
1094/// Maps a store error to a `500`; the store failing is not the client's fault.
1095fn store_error(error: salvor_store::StoreError) -> ApiError {
1096    ApiError::Internal(format!("store: {error}"))
1097}