salvor_engine/lib.rs
1//! The Salvor graph engine: drives a frozen graph document through its nodes,
2//! recording the walk into one durable run log.
3//!
4//! # Where this crate sits, and why it is its own crate
5//!
6//! The engine is deliberately **not** part of `salvor-runtime` (that would drag
7//! the graph document format into the built-in agent loop) and **not** part of
8//! `salvor-graph` (that crate is a pure, IO-free leaf). It sits above both and
9//! composes only their public surfaces: the graph document from `salvor-graph`,
10//! and the durability substrate ([`RunCtx`](salvor_runtime::RunCtx),
11//! [`drive_loop`](salvor_runtime::drive_loop)) from `salvor-runtime`. It reaches
12//! into nothing private. That is a deliberate proof of the runtime's API
13//! guardrail: everything the engine needs, an outside crate could also do.
14//!
15//! # What it drives
16//!
17//! [`run_graph`] opens a run's log with `GraphRunStarted`, walks the nodes in
18//! deterministic topological order (see [`walk`]), and drives each one:
19//!
20//! - an **agent** node runs the built-in agent loop
21//! ([`drive_loop`](salvor_runtime::drive_loop)) inside the same log, framed by
22//! `NodeEntered` / `NodeExited`. A node that declares an `output_schema` runs
23//! the structured form of that loop
24//! ([`drive_loop_structured`](salvor_runtime::drive_loop_structured)) instead,
25//! so the node's output is the validated object the schema describes rather
26//! than the reply text, and downstream expressions can read its fields. The
27//! resolved agent may declare a schema of its own, in which case the node's
28//! declaration wins and the agent's is the fallback (see
29//! [`Agent::output_schema`](salvor_runtime::Agent::output_schema));
30//! - a **tool** node records one tool call through the same write-ahead
31//! intent/completion machinery the built-in loop uses, honoring the tool's
32//! effect class. A tool that asks to park the run parks it: a suspension
33//! through `Suspended` / `Resumed`, a sleep through `SleepStarted` /
34//! `SleepCompleted`. Either way the node stays entered with no `NodeExited`,
35//! so a later drive re-enters it and continues from the recorded park. The
36//! sleep request rides inside the call's own completion, so the call settles
37//! (and releases any idempotency claim) before the timer starts;
38//! - a **gate** node parks the run through the exact `Suspended` / `Resumed`
39//! machinery the built-in loop uses for a tool suspension: entering it records
40//! `NodeEntered`, then `suspend` records the gate's `approval_schema` as the
41//! suspension schema and the drive returns [`GraphOutcome::Parked`]. A later
42//! drive over the log (carrying the resume input the existing resume machinery
43//! appended) passes that input through the gate as its output and continues.
44//! A gate needs no event kind of its own. A resume input is ENFORCED against
45//! the gate's `approval_schema` at the accept edge, between the `suspend` and
46//! the `await_resume` that would record it, so a non-conforming approval is a
47//! typed refusal that appends nothing and leaves the run parked; a recorded
48//! `Resumed` is never re-judged on replay (see [`approval`]);
49//! - a **branch** node routes on its input: an expression branch evaluates its
50//! cases in author order and the first true case wins; a model-decision branch
51//! drives the node's agent and maps the reply to a case name. Either way the
52//! chosen case is recorded as `BranchTaken`, the walk follows the like-named
53//! edge, and every node reachable only through a non-taken case is recorded
54//! `NodeSkipped`;
55//! - a **map** node fans out over a list. Its `over` reference resolves against
56//! the routed value to a JSON array (a non-array is a typed
57//! [`EngineError::MapOverNotAList`] refused before `NodeEntered`); the engine
58//! records `NodeEntered`, then `MapFannedOut` with the resolved item list, then
59//! walks the list IN INDEX ORDER, and for each element records
60//! `MapIterationStarted` (carrying the derived child-run id), runs the body's
61//! work inline, and records `MapIterationJoined`. The joined output is the
62//! per-element outputs as a list in index order. Iterations run
63//! **inline and sequentially** in the parent's own log: the `concurrency` cap is
64//! accepted (the validator requires it be at least 1) but not honored: a
65//! deliberate v0.4 choice that costs only wall-clock and changes no event shape,
66//! so the whole fan-out is proven by the same single-log replay machinery
67//! already proven for linear and branching graphs. Concurrent child runs are
68//! not yet supported. A
69//! `subgraph` body, or a body node that is not an `agent` or `tool`, is a typed
70//! [`EngineError::UnsupportedMapBody`] refused before `NodeEntered`;
71//! - a **fold** node runs its body up to `max_iterations` times, inline and
72//! sequentially in the same log. Pass 0's input is the fold's routed value;
73//! every later pass's input is the previous pass's output, which IS the
74//! accumulated value: there is no separate accumulator state and no merge rule,
75//! because the document has no vocabulary for one. A value that is an MCP
76//! result envelope (an object with a `content` ARRAY and a
77//! `structuredContent` key) contributes that PAYLOAD as the accumulated
78//! value, not the object around it: an MCP tool answers with a
79//! `{content, structuredContent}` envelope, and the payload is the value the
80//! loop is folding, so the next pass's input, the `stop_when` predicate, a
81//! `best_by` reference, and the join's own output all read the bare payload
82//! and a fold expression never reaches through a transport detail. This holds
83//! for the value ENTERING the fold as much as for one a pass produced, so a
84//! fold fed by a `tool` node over an edge folds the same shape at pass 0 that
85//! it folds at pass 3. Any other value (an agent body's structured object, a
86//! native tool's flat struct, an object that merely carries a field called
87//! `structuredContent` as data, a string, a list) is carried verbatim. The
88//! unwrap is DERIVATION, not
89//! recording: `ToolCallCompleted` still holds the whole envelope, and the
90//! payload is a pure function of it (see [`unwrap_pass_output`]). Nothing
91//! outside a fold is touched: an ordinary edge routes a node's recorded output
92//! verbatim, so a branch expression reading `structuredContent.` still reads
93//! what it always read. The engine
94//! records `NodeEntered`, then per pass `FoldIterationStarted`, the body's
95//! work inline,
96//! and `FoldIterationJoined`, and stops when the `stop_when` predicate holds
97//! over the pass just joined or when the bound is reached (there is no third
98//! cause: nothing stops a fold for "failing to improve"). Reaching the bound
99//! means what `on_bound` says it means: absent or `join` joins the passes
100//! anyway, and `fail` is a typed [`EngineError::FoldBoundExceeded`] returned
101//! from exactly where `FoldConverged` would have been recorded, so the passes
102//! and their joins stay in the log and no convergence and no `NodeExited`
103//! land. Otherwise the `join` rule
104//! picks the value the node produces: `last` takes the final pass, `all` takes
105//! every pass's value as a list in pass order, and `best_by` is an argmax over
106//! ALL passes of the reference's value, ordered by the expression language's
107//! own comparison ([`salvor_graph::expr::compare`]) so the argmax and the
108//! predicate beside it can never order values differently, keeping the earliest
109//! pass on a tie. A `best_by` with no comparable candidate in any pass is a
110//! typed [`EngineError::FoldNoComparableCandidate`] refused before
111//! `FoldConverged`. The chosen winner and the stop reason are recorded on
112//! `FoldConverged`, then `NodeExited`. A `subgraph` body, or a body node that
113//! is not an `agent` or `tool`, is a typed
114//! [`EngineError::UnsupportedFoldBody`] refused before `NodeEntered`;
115//! - a **delay** node parks the run on a durable timer, the timer counterpart
116//! of the gate: `NodeEntered`, then `sleep_for` (a recorded clock reading
117//! followed by `SleepStarted { wake_at }`), then a wait. Before the deadline
118//! the drive returns [`GraphOutcome::Parked`] with
119//! [`ParkReason::Sleeping`](salvor_runtime::ParkReason::Sleeping) and no
120//! `NodeExited`, so a drive that arrives early records nothing and a later
121//! one re-enters the same node and continues from the recorded sleep. At or
122//! past the deadline `SleepCompleted` lands, `NodeExited` closes the node,
123//! and the walk continues with the node's input passed through UNCHANGED: a
124//! delay moves a run in time, never in value, so its output is its input
125//! verbatim. It needs no event kind of its own; the sleep pair is the whole
126//! vocabulary. The wait is a DURATION in the document and the instant is
127//! derived from the recorded reading, which is what keeps the same document
128//! runnable more than once (see [`salvor_graph::DelayNode`]).
129//!
130//! A node that is a map's or a fold's body is executed ONLY as that owner's
131//! per-item or per-pass worker; it is never walked independently, so its own
132//! events (a tool call, an agent loop) are recorded inline between the owner's
133//! iteration markers and its node id is never framed with a `NodeEntered` of its
134//! own. That keeps node ids unambiguous in the one log and is why forking INTO a
135//! map iteration or a fold pass is refused: neither is a node boundary (see
136//! [`plan_fork`]).
137//!
138//! After the last node the engine records the single terminal `RunCompleted`.
139//! It records no terminal for a refusal: refuse-before-record is what keeps the
140//! log free of events past one. Whether a refused run is DEAD or merely stuck is
141//! the driver's call, and [`EngineError::is_permanent`] is how the engine tells
142//! it apart; [`record_permanent_refusal`] is the append the drivers make when
143//! the answer is dead. There is no ambient clock or randomness in any decision: everything the
144//! engine feeds forward (the walk order, each node's input, the branch route, a
145//! map's resolved item list and its per-iteration child ids, a fold's stop
146//! decision, its winner and its recorded reason, an idempotent tool's
147//! idempotency key) is a pure function of the document or of values the `RunCtx`
148//! recorded, so a second drive over the recorded log replays with no live calls
149//! and produces a byte-identical log. A map iteration's child-run id is
150//! `sha256:` over the parent run id, the node id, and the index (see
151//! [`map_child_run_id`]): pure recorded data, so replay reconstructs the
152//! identical id without storing anything extra. The idempotency
153//! key is derived from the call's position in the graph (graph hash, node id,
154//! call index) rather than from drawn randomness, which is what lets a FORK of a
155//! run re-walk a segment and present the same key its origin recorded. See
156//! [`fork_safe_idempotency_key`] and the `salvor-server` fork endpoint.
157//!
158//! # Data flow
159//!
160//! Each node's output flows to its successors along the edges, and a node's
161//! input is the recorded output of the live inbound edge that reaches it (the
162//! graph input for an entry node with no inbound edge). A branch passes its
163//! routed value through unchanged to the taken case's edge; the decision only
164//! selects the route, never the data. A tool node's `input` references are still
165//! not resolved yet; the upstream output is the downstream input
166//! verbatim. When more than one live inbound edge reaches a node, the one whose
167//! source id is smallest wins, so the merge is a pure function of the document.
168//!
169//! # Resolving agents and tools
170//!
171//! A node names its agent by hash and its tool by name; the engine turns those
172//! into executables through the [`AgentResolver`] and [`ToolResolver`] traits
173//! the caller supplies. Tests inject maps; the server wires its own
174//! registries in separately. Keeping resolution behind a trait is what lets the engine stay
175//! ignorant of where agents and tools actually come from.
176
177#![warn(missing_docs)]
178
179pub mod approval;
180mod error;
181pub mod fork;
182mod walk;
183
184use std::cmp::Ordering;
185use std::collections::{HashMap, HashSet};
186
187use salvor_core::{Effect, RunId};
188use salvor_graph::expr::{Expr, Reference};
189use salvor_graph::{
190 AgentNode, BranchCondition, BranchNode, DelayNode, Edge, FoldBody, FoldJoin, FoldNode,
191 GateNode, Graph, MapBody, MapNode, Node, OnBound,
192};
193use salvor_runtime::{
194 Agent, LoopOutcome, ParkReason, Resumption, RunCtx, ToolCallResult, Waking, drive_loop,
195 drive_loop_structured, hash_value, slept_output,
196};
197use salvor_tools::DynTool;
198use serde_json::{Value, json};
199
200pub use approval::{ApprovalViolation, approval_violations, parked_gate};
201pub use error::EngineError;
202pub use fork::{ForkError, ForkPlan, WriteHazard, plan_fork};
203
204/// Resolves an `agent` node's declared hash to the [`Agent`] that executes it.
205///
206/// A small trait, not a fixed type, so a test can inject a map while the server
207/// injects its agent registry. A [`HashMap<String, Agent>`](std::collections::HashMap)
208/// implements it out of the box.
209pub trait AgentResolver {
210 /// The agent registered under `agent_hash`, or `None` if none is.
211 fn resolve_agent(&self, agent_hash: &str) -> Option<&Agent>;
212}
213
214/// Resolves a `tool` node's declared name to the [`DynTool`] that executes it.
215///
216/// The tool counterpart of [`AgentResolver`]. A
217/// [`HashMap<String, Box<dyn DynTool>>`](std::collections::HashMap) implements
218/// it out of the box.
219pub trait ToolResolver {
220 /// The tool registered under `name`, or `None` if none is.
221 fn resolve_tool(&self, name: &str) -> Option<&dyn DynTool>;
222}
223
224impl AgentResolver for HashMap<String, Agent> {
225 fn resolve_agent(&self, agent_hash: &str) -> Option<&Agent> {
226 self.get(agent_hash)
227 }
228}
229
230impl ToolResolver for HashMap<String, Box<dyn DynTool>> {
231 fn resolve_tool(&self, name: &str) -> Option<&dyn DynTool> {
232 self.get(name).map(AsRef::as_ref)
233 }
234}
235
236/// How a graph drive ended.
237#[derive(Debug)]
238pub enum GraphOutcome {
239 /// The graph ran to completion; this is the final output the terminal
240 /// `RunCompleted` recorded.
241 Completed {
242 /// The graph run's final output (the last node's output).
243 output: Value,
244 },
245 /// A node parked the run durably (an agent's budget crossing or a tool
246 /// suspension). The run survives restarts; resume it through the runtime's
247 /// resume path, then drive the graph again to continue.
248 Parked {
249 /// The node that parked.
250 node: String,
251 /// Why it parked.
252 reason: ParkReason,
253 },
254}
255
256/// Computes a graph document's content hash: `sha256:` over its canonical JSON,
257/// the exact string recorded in `GraphRunStarted`.
258///
259/// Reuses `salvor-runtime`'s canonical hashing (the same story behind
260/// `agent_def_hash` and `request_hash`), so a graph run's `graph_hash` is
261/// reproducible and matches whatever a control plane computes for the same
262/// document.
263///
264/// # Errors
265///
266/// [`EngineError::GraphEncode`] if the document cannot be serialized (it always
267/// can; the edge is kept honest rather than panicking).
268pub fn graph_hash(graph: &Graph) -> Result<String, EngineError> {
269 let value = serde_json::to_value(graph).map_err(EngineError::GraphEncode)?;
270 Ok(hash_value(&value))
271}
272
273/// Drives `graph` to completion (or a park) over `ctx`, recording the walk into
274/// the run's log.
275///
276/// The log opens with `GraphRunStarted { graph_hash }`, each node contributes
277/// `NodeEntered` … its own events … `NodeExited`, and the run closes with one
278/// `RunCompleted`. See the crate docs for the node handling and determinism
279/// guarantees. Fresh, recovering, or replaying is entirely the `ctx`'s
280/// business: the engine issues the same sequence of `RunCtx` calls either way,
281/// which is what makes a second drive over the recorded log a byte-identical,
282/// zero-live-call replay.
283///
284/// # Errors
285///
286/// [`EngineError::MapOverNotAList`] when a map node's `over` reference does not
287/// resolve to a list, and [`EngineError::UnsupportedMapBody`] for a `subgraph` or
288/// non-`agent`/`tool` body (both before the map's `NodeEntered` is recorded);
289/// [`EngineError::UnsupportedFoldBody`] for a fold node's `subgraph` or
290/// non-`agent`/`tool` body (before the fold's `NodeEntered`), and
291/// [`EngineError::FoldNoComparableCandidate`] when a `best_by` join finds no
292/// comparable value in any pass, and [`EngineError::FoldBoundExceeded`] when a
293/// fold declaring `on_bound: fail` reaches its bound with `stop_when` still
294/// unsatisfied (both after the passes ran, before `FoldConverged`);
295/// [`EngineError::NoBranchCaseMatched`] when an expression branch
296/// matches no case (also before its `NodeEntered`);
297/// [`EngineError::BranchDecisionUnmatched`] when a model-decision branch's agent
298/// names no case (after its `NodeEntered`, since the model had to run);
299/// [`EngineError::UnknownAgent`] / [`EngineError::UnknownTool`] when a resolver
300/// cannot supply a node's executable; [`EngineError::MalformedGraph`] when the
301/// topology is not a DAG (or, unreachable in practice, a branch condition the
302/// validator accepted fails to parse here, or a `delay` node's wait is zero or
303/// out of range, refused before that node's `NodeEntered`); [`EngineError::ToolFailed`] when a
304/// tool call fails; [`EngineError::Runtime`] for any replay divergence,
305/// reconciliation refusal, provider, or store error.
306pub async fn run_graph(
307 ctx: &mut RunCtx,
308 graph: &Graph,
309 input: &Value,
310 agents: &impl AgentResolver,
311 tools: &impl ToolResolver,
312) -> Result<GraphOutcome, EngineError> {
313 let hash = graph_hash(graph)?;
314 // The recorded input always wins on replay; `begin_graph` returns it.
315 let graph_input = ctx.begin_graph(&hash, input).await?;
316
317 // Topology and routing state, all keyed on ids that borrow the document.
318 let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
319 let mut inbound: HashMap<&str, Vec<&Edge>> = HashMap::new();
320 for edge in &graph.edges {
321 inbound.entry(edge.to.as_str()).or_default().push(edge);
322 }
323 // Branch conditions are parsed ONCE here (the validator already guarantees
324 // they parse; a failure now is a MalformedGraph unreachable in practice).
325 let branches = parse_branches(graph)?;
326 // Every fold's `stop_when` (and its `best_by` reference) is parsed ONCE
327 // here, on the same terms the branch conditions are.
328 let folds = parse_folds(graph)?;
329 // The ids of every node used as a `map` or `fold` body: they are the
330 // per-item workers of their map and the per-pass workers of their fold, and
331 // are executed ONLY inside that owner's loop, never walked independently, so
332 // their events stay unambiguous inside the one log.
333 let body_targets: HashSet<&str> = graph
334 .nodes
335 .iter()
336 .filter_map(|node| match node {
337 Node::Map(map) => match &map.body {
338 MapBody::Node(target) => Some(target.as_str()),
339 MapBody::Subgraph(_) => None,
340 },
341 Node::Fold(fold) => match &fold.body {
342 FoldBody::Node(target) => Some(target.as_str()),
343 FoldBody::Subgraph(_) => None,
344 },
345 _ => None,
346 })
347 .collect();
348
349 // What each executed node produced, which nodes were skipped, and which case
350 // each branch fired: the pure state the routing reads. `last_output` threads
351 // the terminal output, seeded with the graph input so an empty graph still
352 // completes with it (matching a linear graph with no nodes at all).
353 let mut outputs: HashMap<&str, Value> = HashMap::new();
354 let mut skipped: HashSet<&str> = HashSet::new();
355 let mut branch_case: HashMap<&str, String> = HashMap::new();
356 let mut last_output = graph_input.clone();
357
358 for node in walk::walk_order(graph)? {
359 let id = node.id();
360 // A node used as a map or fold body is not walked independently: it runs
361 // only as its owner's per-item or per-pass worker, inline in the loop
362 // below. Nothing is recorded for it here: it is body-owned, not
363 // "skipped".
364 if body_targets.contains(id) {
365 continue;
366 }
367 // A node with no live inbound edge was routed past: record the skip (its
368 // sole marker) and move on. Predecessors are visited first in topological
369 // order, so their skip/branch state is already known here.
370 let Some(node_input) = select_input(
371 id,
372 &inbound,
373 &by_id,
374 &branch_case,
375 &skipped,
376 &outputs,
377 &graph_input,
378 ) else {
379 ctx.node_skipped(id, SKIP_REASON).await?;
380 skipped.insert(id);
381 continue;
382 };
383
384 match node {
385 Node::Agent(agent_node) => {
386 let agent = agents
387 .resolve_agent(&agent_node.agent_hash)
388 .ok_or_else(|| EngineError::UnknownAgent {
389 node: agent_node.id.clone(),
390 agent_hash: agent_node.agent_hash.clone(),
391 })?;
392 ctx.node_entered(id).await?;
393 // The agent loop runs inside this same log via the runtime's
394 // begin/drive_loop split: no second run head, and it returns the
395 // output without recording a terminal (the engine owns that).
396 match drive_agent_node(ctx, agent_node, agent, &node_input).await? {
397 LoopOutcome::Completed(output) => {
398 ctx.node_exited(id).await?;
399 last_output = output.clone();
400 outputs.insert(id, output);
401 }
402 LoopOutcome::Parked(reason) => {
403 return Ok(GraphOutcome::Parked {
404 node: agent_node.id.clone(),
405 reason,
406 });
407 }
408 }
409 }
410 Node::Tool(tool_node) => {
411 let tool = tools.resolve_tool(&tool_node.tool).ok_or_else(|| {
412 EngineError::UnknownTool {
413 node: tool_node.id.clone(),
414 tool: tool_node.tool.clone(),
415 }
416 })?;
417 ctx.node_entered(id).await?;
418 // An idempotent tool's key is a PURE function of WHERE the call
419 // sits in the graph (the graph hash, the node id, the call index
420 // within the node), not of drawn randomness. That is what makes it
421 // fork-safe: a fork re-walks the segment from its fork node and
422 // re-executes the idempotent calls in it live, and this derivation
423 // hands each of them the IDENTICAL key its origin recorded, so the
424 // provider collapses the duplicate. It also leaves `Effect::Write`
425 // as the sole class a fork must have acknowledged. Read and write
426 // tools carry no key (see the built-in loop).
427 let idempotency_key = match tool.effect() {
428 Effect::Idempotent => Some(fork_safe_idempotency_key(&hash, id, 0)),
429 Effect::Read | Effect::Write => None,
430 };
431 match ctx
432 .tool_call(tool, &node_input, idempotency_key.as_deref())
433 .await?
434 {
435 ToolCallResult::Output(output) => {
436 ctx.node_exited(id).await?;
437 last_output = output.clone();
438 outputs.insert(id, output);
439 }
440 ToolCallResult::Failed(failure) => {
441 return Err(EngineError::ToolFailed {
442 node: tool_node.id.clone(),
443 message: failure.message,
444 });
445 }
446 ToolCallResult::Suspended(suspension) => {
447 // Whatever the tool said it waits on is recorded and
448 // reported. A node parked on a webhook is not a gate
449 // and must not read as one.
450 ctx.suspend_with_kind(
451 &suspension.reason,
452 &suspension.input_schema,
453 suspension.kind,
454 )
455 .await?;
456 match ctx.await_resume().await? {
457 Resumption::Parked => {
458 return Ok(GraphOutcome::Parked {
459 node: tool_node.id.clone(),
460 reason: ParkReason::Suspended {
461 reason: suspension.reason,
462 input_schema: suspension.input_schema,
463 kind: suspension.kind,
464 },
465 });
466 }
467 Resumption::Resumed(resume_input) => {
468 // The recorded resume input is the tool's answer.
469 ctx.node_exited(id).await?;
470 last_output = resume_input.clone();
471 outputs.insert(id, resume_input);
472 }
473 }
474 }
475 // The timer park, the same shape as the suspension above:
476 // the node stays entered with no `NodeExited`, so a later
477 // drive re-enters it and continues from the recorded sleep.
478 // The call settled before the sleep started (its completion
479 // carried the request), so a node asleep for a week holds
480 // no idempotency claim.
481 ToolCallResult::Sleeping(sleep) => {
482 ctx.sleep_until(sleep.wake_at).await?;
483 match ctx.await_wake().await? {
484 Waking::Asleep { wake_at } => {
485 return Ok(GraphOutcome::Parked {
486 node: tool_node.id.clone(),
487 reason: ParkReason::Sleeping { wake_at },
488 });
489 }
490 Waking::Woken => {
491 // The tool named a deadline instead of a value,
492 // so the node's output is derived from the wake
493 // instant its completion recorded: pure, and
494 // identical on every replay.
495 let output = slept_output(sleep.wake_at);
496 ctx.node_exited(id).await?;
497 last_output = output.clone();
498 outputs.insert(id, output);
499 }
500 }
501 }
502 }
503 }
504 // A gate parks through the exact suspension machinery a tool uses:
505 // NodeEntered, then `suspend` recording the gate's approval schema,
506 // then a park. A later drive over the resumed log passes the resume
507 // input through as the gate's output. No gate-specific event kind.
508 Node::Gate(gate) => {
509 ctx.node_entered(id).await?;
510 let reason = gate_reason(gate);
511 ctx.suspend(&reason, &gate.approval_schema).await?;
512 // THE ACCEPT EDGE. Right here, and nowhere later, is where a
513 // resume input may be judged: the gate's `Suspended` is on
514 // disk, and the next line can append a `Resumed`. Refusing
515 // before that append is what keeps the refusal free: nothing
516 // lands in the log and the run stays parked at this gate,
517 // waiting for an approval that conforms.
518 //
519 // The guard is `is_replaying()`. When history remains, the next
520 // event is a RECORDED `Resumed`, and a recorded `Resumed` is
521 // never re-judged: replay trusts what was written. That is
522 // load-bearing rather than an optimization. If replay
523 // re-validated, a stricter validator (or a new `jsonschema`
524 // release) would turn logs that replayed yesterday into
525 // refusals today, and a durable log that stops replaying is not
526 // durable. So: check what has not been written, trust what has.
527 if !ctx.is_replaying()
528 && let Some(input) = ctx.staged_resume_input()
529 {
530 let violations = approval_violations(input, &gate.approval_schema);
531 if !violations.is_empty() {
532 return Err(EngineError::ApprovalSchemaViolation {
533 node: gate.id.clone(),
534 violations,
535 });
536 }
537 }
538 match ctx.await_resume().await? {
539 Resumption::Parked => {
540 return Ok(GraphOutcome::Parked {
541 node: gate.id.clone(),
542 reason: ParkReason::Suspended {
543 reason,
544 input_schema: gate.approval_schema.clone(),
545 // A gate is the human park by definition, so
546 // it names no discriminator.
547 kind: None,
548 },
549 });
550 }
551 Resumption::Resumed(resume_input) => {
552 ctx.node_exited(id).await?;
553 last_output = resume_input.clone();
554 outputs.insert(id, resume_input);
555 }
556 }
557 }
558 // A delay parks on a timer the way a gate parks on a person: enter
559 // the node, park, and leave no `NodeExited` behind, so a later
560 // drive re-enters it and continues from the recorded sleep. No
561 // delay-specific event kind: `SleepStarted` / `SleepCompleted` is
562 // the whole vocabulary, exactly as `Suspended` / `Resumed` is the
563 // gate's.
564 Node::Delay(delay) => {
565 // Refuse-before-record: the declared wait becomes a duration
566 // here, ahead of `NodeEntered`, so a document the validator
567 // would have refused leaves nothing in the log.
568 let duration = delay_duration(delay)?;
569 ctx.node_entered(id).await?;
570 // `sleep_for` is `now()` then `sleep_until`: the clock reading
571 // lands in the log as a `NowObserved` and the wake instant is
572 // derived from it, so the instant is a pure function of
573 // recorded data and every later drive derives the identical
574 // one. A wake instant baked into the document could not make
575 // that claim on a second run, which is why the field is a
576 // duration.
577 ctx.sleep_for(duration).await?;
578 match ctx.await_wake().await? {
579 Waking::Asleep { wake_at } => {
580 return Ok(GraphOutcome::Parked {
581 node: delay.id.clone(),
582 reason: ParkReason::Sleeping { wake_at },
583 });
584 }
585 // A delay transforms nothing, so its output is its input
586 // verbatim, the same pass-through a branch performs: the
587 // node moves the run in time, never in value.
588 Waking::Woken => {
589 ctx.node_exited(id).await?;
590 last_output = node_input.clone();
591 outputs.insert(id, node_input);
592 }
593 }
594 }
595 Node::Branch(branch) => {
596 // A branch is a pure router: whichever case fires, the routed
597 // value passes through unchanged to the taken edge.
598 let cases = branches.get(id).expect("every branch node is parsed");
599 let chosen: String = match &branch.agent_hash {
600 // Expression branch: choose purely, so a no-match refuses
601 // before NodeEntered and nothing lands past the refusal.
602 None => {
603 let case = choose_expression_case(id, cases, &node_input)?;
604 ctx.node_entered(id).await?;
605 case.to_owned()
606 }
607 // Model-decision branch: the agent must run first, so its
608 // NodeEntered and model events precede the mapping (and the
609 // BranchDecisionUnmatched refusal, if the reply names no case).
610 Some(agent_hash) => {
611 let agent = agents.resolve_agent(agent_hash).ok_or_else(|| {
612 EngineError::UnknownAgent {
613 node: branch.id.clone(),
614 agent_hash: agent_hash.clone(),
615 }
616 })?;
617 ctx.node_entered(id).await?;
618 let reply = match drive_loop(ctx, agent, &node_input).await? {
619 LoopOutcome::Completed(output) => output,
620 LoopOutcome::Parked(reason) => {
621 return Ok(GraphOutcome::Parked {
622 node: branch.id.clone(),
623 reason,
624 });
625 }
626 };
627 match_decision(branch, &reply)?.to_owned()
628 }
629 };
630 ctx.branch_taken(id, &chosen).await?;
631 ctx.node_exited(id).await?;
632 branch_case.insert(id, chosen);
633 last_output = node_input.clone();
634 outputs.insert(id, node_input);
635 }
636 Node::Map(map_node) => {
637 match drive_map(ctx, map_node, &node_input, &by_id, agents, tools, &hash).await? {
638 MapOutcome::Joined(output) => {
639 last_output = output.clone();
640 outputs.insert(id, output);
641 }
642 MapOutcome::Parked { node, reason } => {
643 return Ok(GraphOutcome::Parked { node, reason });
644 }
645 }
646 }
647 // A fold's parsed plan carries the node itself, so the drive takes
648 // one argument for both.
649 Node::Fold(_) => {
650 let plan = folds.get(id).expect("every fold node is parsed");
651 match drive_fold(ctx, plan, &node_input, &by_id, agents, tools, &hash).await? {
652 FoldOutcome::Converged(output) => {
653 last_output = output.clone();
654 outputs.insert(id, output);
655 }
656 FoldOutcome::Parked { node, reason } => {
657 return Ok(GraphOutcome::Parked { node, reason });
658 }
659 }
660 }
661 }
662 }
663
664 ctx.complete_run(&last_output).await?;
665 Ok(GraphOutcome::Completed {
666 output: last_output,
667 })
668}
669
670/// Records the terminal `RunFailed` a PERMANENT [`run_graph`] refusal deserves,
671/// so a dead run stops masquerading as a running one.
672///
673/// [`run_graph`] itself never writes a terminal for a refusal, and that is
674/// deliberate: refuse-before-record is what keeps the log free of events past a
675/// refusal, and the engine cannot know whether its caller intends to re-drive.
676/// The DRIVER does know, and it is the driver that owns the run's disposition.
677/// So the drivers (the CLI's `graph run`, `resume`, and `graph fork` paths, and
678/// the server's `drive_graph` task) call this with the error they just received
679/// and THE SAME `ctx` that produced it. The same one matters: the append claims
680/// the position that `ctx`'s cursor stands on, which is the position after
681/// everything the refused drive replayed or wrote. A fresh `RunCtx` over the
682/// same log stands at position zero and would diverge rather than append, which
683/// is the machinery refusing to write a terminal onto a run it has not read.
684///
685/// Only a permanent error is recorded: one that
686/// [`EngineError::is_permanent`] calls a pure function of the frozen document
687/// and the recorded log, so re-driving reproduces it forever. A transient error
688/// is left exactly as it was, and the run stays recoverable. Returns whether a
689/// terminal was recorded, so a caller can say so in its own voice.
690///
691/// # Ordering, and the kill between the refusal and this append
692///
693/// The append goes through [`RunCtx::fail_run`](salvor_runtime::RunCtx::fail_run),
694/// which is the same persist discipline every other event uses: the cursor
695/// claims the next sequence and the envelope is durable before this returns.
696/// That is also the "only when the log holds no terminal" guard, and it needs no
697/// second read of the store. A log whose recorded next event is the identical
698/// `RunFailed` (a driver that already did this) REPLAYS it and appends nothing;
699/// a log holding a different terminal is a divergence the caller surfaces
700/// rather than overwrites.
701///
702/// A `kill -9` landing between the refusal and this append therefore leaves a
703/// log with no terminal at all, ending at whatever the refusal was recorded
704/// past (for a fold, its last `FoldIterationJoined`). That log is not corrupt
705/// and not stuck: the next drive replays it, re-derives the SAME permanent
706/// refusal from the same recorded values, and this appends the `RunFailed`
707/// then. Nothing re-executes, because everything before the refusal is history.
708/// The window is a delay in the status an operator reads, never a divergence.
709///
710/// # Errors
711///
712/// [`RuntimeError`](salvor_runtime::RuntimeError) when the append does not
713/// persist, or when the log already holds a different terminal. Callers report
714/// the ORIGINAL engine refusal in that case: it is the real news, and losing
715/// the terminal only means the run reads as recoverable when it is not.
716pub async fn record_permanent_refusal(
717 ctx: &mut RunCtx,
718 error: &EngineError,
719) -> Result<bool, salvor_runtime::RuntimeError> {
720 if !error.is_permanent() {
721 return Ok(false);
722 }
723 ctx.fail_run(&error.to_string()).await?;
724 Ok(true)
725}
726
727/// The wait a `delay` node declares, as a duration the runtime can sleep for.
728///
729/// Two refusals, both pure functions of the frozen document and both raised
730/// BEFORE the node's `NodeEntered`, so a document that trips either leaves
731/// nothing in the log. A zero wait is the one
732/// [`salvor_graph::validate`] already reports as `NonPositiveDelay`; this
733/// re-checks it defensively for the same reason [`drive_fold`] re-checks a
734/// fold's bound, because a validator is a submit-time gate and the engine can
735/// be handed a document by some other route. A wait past `i64::MAX` seconds
736/// cannot be a [`time::Duration`] at all; it is astronomically out of range
737/// rather than merely long, and the alternative to naming it is a panic in a
738/// conversion.
739fn delay_duration(delay: &DelayNode) -> Result<time::Duration, EngineError> {
740 if delay.seconds < 1 {
741 return Err(EngineError::MalformedGraph {
742 detail: format!(
743 "delay node `{}`: seconds must be at least 1, found {}",
744 delay.id, delay.seconds
745 ),
746 });
747 }
748 let seconds = i64::try_from(delay.seconds).map_err(|_| EngineError::MalformedGraph {
749 detail: format!(
750 "delay node `{}`: a wait of {} seconds is outside the representable range",
751 delay.id, delay.seconds
752 ),
753 })?;
754 Ok(time::Duration::seconds(seconds))
755}
756
757/// How driving one map node's fan-out ended.
758enum MapOutcome {
759 /// Every iteration joined; the map's output is the per-index outputs as a
760 /// JSON array in index order.
761 Joined(Value),
762 /// An iteration parked the run (an `agent` body's budget crossing or a `tool`
763 /// body's suspension), propagated up as a graph park at the map node.
764 Parked {
765 /// The map node that parked.
766 node: String,
767 /// Why it parked.
768 reason: ParkReason,
769 },
770}
771
772/// Drives one map node's fan-out inline and sequentially into the parent log.
773///
774/// Refuses an unsupported body form or a non-list `over` **before** recording the
775/// map's `NodeEntered`, so nothing lands in the log past such a refusal. Then
776/// records `NodeEntered`, `MapFannedOut` with the resolved item list, and for each
777/// element in INDEX ORDER records `MapIterationStarted` (with the derived
778/// child-run id), runs the body's work inline, and records `MapIterationJoined`.
779/// The `concurrency` cap is accepted but not honored: iterations run
780/// one after another, which is why the whole fan-out is a plain single-log replay.
781async fn drive_map(
782 ctx: &mut RunCtx,
783 map_node: &MapNode,
784 routed: &Value,
785 by_id: &HashMap<&str, &Node>,
786 agents: &impl AgentResolver,
787 tools: &impl ToolResolver,
788 graph_hash: &str,
789) -> Result<MapOutcome, EngineError> {
790 let node_id = map_node.id.as_str();
791
792 // Resolve the body up front so an unsupported body form refuses BEFORE the
793 // map's NodeEntered is recorded.
794 let body: &Node = match &map_node.body {
795 MapBody::Node(target) => {
796 let body_node =
797 by_id
798 .get(target.as_str())
799 .copied()
800 .ok_or_else(|| EngineError::MalformedGraph {
801 detail: format!("map node `{node_id}`: body names unknown node `{target}`"),
802 })?;
803 match body_node {
804 Node::Agent(_) | Node::Tool(_) => body_node,
805 other => {
806 return Err(EngineError::UnsupportedMapBody {
807 node: node_id.to_owned(),
808 detail: format!(
809 "a `{}` body node cannot be a per-item worker; only `agent` and `tool` bodies run",
810 other.kind_name()
811 ),
812 });
813 }
814 }
815 }
816 MapBody::Subgraph(_) => {
817 return Err(EngineError::UnsupportedMapBody {
818 node: node_id.to_owned(),
819 detail: "an embedded `subgraph` body is not executed yet".to_owned(),
820 });
821 }
822 };
823
824 // Resolve `over` against the routed value; a non-array (including a missing
825 // path) is a typed refusal BEFORE NodeEntered.
826 let items = resolve_over(node_id, &map_node.over, routed)?;
827
828 ctx.node_entered(node_id).await?;
829 ctx.map_fanned_out(node_id, &Value::Array(items.clone()))
830 .await?;
831
832 let mut joined: Vec<Value> = Vec::with_capacity(items.len());
833 for (position, item) in items.iter().enumerate() {
834 let index = position as u64;
835 let child_run = map_child_run_id(ctx.run_id(), node_id, index);
836 ctx.map_iteration_started(node_id, index, &child_run)
837 .await?;
838 let call = BodyCall {
839 owner: BodyOwner::Map,
840 graph_hash,
841 node_id,
842 index,
843 };
844 match run_body(ctx, body, item, agents, tools, call).await? {
845 IterationOutcome::Output(output) => joined.push(output),
846 IterationOutcome::Parked(reason) => {
847 return Ok(MapOutcome::Parked {
848 node: node_id.to_owned(),
849 reason,
850 });
851 }
852 }
853 // Joins are recorded strictly in index order, never completion order.
854 ctx.map_iteration_joined(node_id, index).await?;
855 }
856 ctx.node_exited(node_id).await?;
857 Ok(MapOutcome::Joined(Value::Array(joined)))
858}
859
860/// How one map iteration's or fold pass's body work ended.
861enum IterationOutcome {
862 /// The body produced this output for the iteration or pass.
863 Output(Value),
864 /// The body parked (an agent budget crossing or a tool suspension).
865 Parked(ParkReason),
866}
867
868/// Which node kind owns an inline body run. Carried only so the unsupported-body
869/// error the shared runner returns names the right kind; the two owners run the
870/// body identically.
871#[derive(Clone, Copy)]
872enum BodyOwner {
873 Map,
874 Fold,
875}
876
877/// Where one map iteration's or fold pass's tool call sits, for deriving its
878/// fork-safe idempotency key: the graph hash, the owning node's id, and the
879/// iteration or pass index.
880struct BodyCall<'a> {
881 /// Which node kind owns this run.
882 owner: BodyOwner,
883 /// The graph document hash.
884 graph_hash: &'a str,
885 /// The owning map or fold node id (the "node" this call belongs to).
886 node_id: &'a str,
887 /// The zero-based iteration or pass index (the call index within the node).
888 index: u64,
889}
890
891impl BodyCall<'_> {
892 /// The refusal for a body node kind that cannot be a worker, named for the
893 /// owner. Both callers validate the body kind before the loop, so this is
894 /// only ever built on a path the caller already proved unreachable.
895 fn unsupported_body(&self, detail: String) -> EngineError {
896 match self.owner {
897 BodyOwner::Map => EngineError::UnsupportedMapBody {
898 node: self.node_id.to_owned(),
899 detail,
900 },
901 BodyOwner::Fold => EngineError::UnsupportedFoldBody {
902 node: self.node_id.to_owned(),
903 detail,
904 },
905 }
906 }
907}
908
909/// Drives one `agent` node's loop, wherever it sits: walked as a node of its
910/// own, or run inline as a map's or fold's per-item worker.
911///
912/// A declared `output_schema` is the whole difference between the two loops.
913/// With one, the runtime offers the model its answer tool and validates the
914/// answer against the schema, so this node's output is that object and a
915/// downstream expression can read a field of it; without one, the output is the
916/// reply text as before.
917///
918/// Two places can declare it, and the rule is **node wins, else the agent's
919/// own**. The node's `output_schema` is the graph author's statement about what
920/// this position in this document needs, made with the whole document in view;
921/// the agent's is the agent author's statement about what the agent always
922/// produces, made without knowing which graph would call it. The more specific
923/// declaration is the more informed one, so it takes the node's when there is
924/// one and falls back to the agent's when there is not. A node that declares
925/// nothing and an agent that declares nothing stay on the plain text loop, as
926/// every graph did before either declaration existed.
927async fn drive_agent_node(
928 ctx: &mut RunCtx,
929 node: &AgentNode,
930 agent: &Agent,
931 input: &Value,
932) -> Result<LoopOutcome, EngineError> {
933 let schema = node
934 .output_schema
935 .as_ref()
936 .or_else(|| agent.output_schema());
937 let outcome = match schema {
938 Some(schema) => drive_loop_structured(ctx, agent, input, schema).await?,
939 None => drive_loop(ctx, agent, input).await?,
940 };
941 Ok(outcome)
942}
943
944/// Runs one map iteration's or fold pass's body work inline: the referenced
945/// `agent` or `tool` node's work with `item` as its input, recorded in the parent
946/// log WITHOUT a `NodeEntered` frame of its own (the owner's markers bracket it
947/// instead). The body kind was already validated as `agent` or `tool` by
948/// [`drive_map`] or [`drive_fold`].
949async fn run_body(
950 ctx: &mut RunCtx,
951 body: &Node,
952 item: &Value,
953 agents: &impl AgentResolver,
954 tools: &impl ToolResolver,
955 call: BodyCall<'_>,
956) -> Result<IterationOutcome, EngineError> {
957 match body {
958 Node::Agent(agent_node) => {
959 let agent = agents
960 .resolve_agent(&agent_node.agent_hash)
961 .ok_or_else(|| EngineError::UnknownAgent {
962 node: agent_node.id.clone(),
963 agent_hash: agent_node.agent_hash.clone(),
964 })?;
965 match drive_agent_node(ctx, agent_node, agent, item).await? {
966 LoopOutcome::Completed(output) => Ok(IterationOutcome::Output(output)),
967 LoopOutcome::Parked(reason) => Ok(IterationOutcome::Parked(reason)),
968 }
969 }
970 Node::Tool(tool_node) => {
971 let tool =
972 tools
973 .resolve_tool(&tool_node.tool)
974 .ok_or_else(|| EngineError::UnknownTool {
975 node: tool_node.id.clone(),
976 tool: tool_node.tool.clone(),
977 })?;
978 // Each iteration or pass is a distinct call of the OWNING node, so
979 // its idempotent key is derived from that node's id and the index:
980 // the "several calls within one node" case
981 // `fork_safe_idempotency_key`'s call-index parameter exists for. A
982 // fork re-walking the loop presents each call's identical key;
983 // Read/Write carry none.
984 let idempotency_key = match tool.effect() {
985 Effect::Idempotent => Some(fork_safe_idempotency_key(
986 call.graph_hash,
987 call.node_id,
988 call.index,
989 )),
990 Effect::Read | Effect::Write => None,
991 };
992 match ctx
993 .tool_call(tool, item, idempotency_key.as_deref())
994 .await?
995 {
996 ToolCallResult::Output(output) => Ok(IterationOutcome::Output(output)),
997 ToolCallResult::Failed(failure) => Err(EngineError::ToolFailed {
998 node: tool_node.id.clone(),
999 message: failure.message,
1000 }),
1001 ToolCallResult::Suspended(suspension) => {
1002 ctx.suspend_with_kind(
1003 &suspension.reason,
1004 &suspension.input_schema,
1005 suspension.kind,
1006 )
1007 .await?;
1008 match ctx.await_resume().await? {
1009 Resumption::Parked => Ok(IterationOutcome::Parked(ParkReason::Suspended {
1010 reason: suspension.reason,
1011 input_schema: suspension.input_schema,
1012 kind: suspension.kind,
1013 })),
1014 Resumption::Resumed(resume_input) => {
1015 Ok(IterationOutcome::Output(resume_input))
1016 }
1017 }
1018 }
1019 // The timer park. No join is recorded for a parked iteration or
1020 // pass, so a later drive re-enters this same one and continues
1021 // from the recorded sleep, exactly as a suspension does.
1022 ToolCallResult::Sleeping(sleep) => {
1023 ctx.sleep_until(sleep.wake_at).await?;
1024 match ctx.await_wake().await? {
1025 Waking::Asleep { wake_at } => {
1026 Ok(IterationOutcome::Parked(ParkReason::Sleeping { wake_at }))
1027 }
1028 Waking::Woken => Ok(IterationOutcome::Output(slept_output(sleep.wake_at))),
1029 }
1030 }
1031 }
1032 }
1033 // The caller validated the body kind as agent or tool before the loop.
1034 other => Err(call.unsupported_body(format!(
1035 "a `{}` body node cannot be a worker",
1036 other.kind_name()
1037 ))),
1038 }
1039}
1040
1041/// How driving one fold node's loop ended.
1042enum FoldOutcome {
1043 /// The loop stopped and the `join` rule chose the value the node produces.
1044 Converged(Value),
1045 /// A pass parked the run (an `agent` body's budget crossing or a `tool`
1046 /// body's suspension), propagated up as a graph park at the fold node. No
1047 /// join is recorded for that pass, so a later drive re-drives the SAME pass.
1048 Parked {
1049 /// The fold node that parked.
1050 node: String,
1051 /// Why it parked.
1052 reason: ParkReason,
1053 },
1054}
1055
1056/// Drives one fold node's bounded loop inline and sequentially into the parent
1057/// log.
1058///
1059/// Refuses an unsupported body form **before** recording the fold's
1060/// `NodeEntered`, so nothing lands in the log past such a refusal. Then records
1061/// `NodeEntered` and, for each pass in index order, `FoldIterationStarted`, the
1062/// body's work inline, and `FoldIterationJoined`. Pass 0's input is the fold's
1063/// routed value; every later pass's input is the previous pass's output, which
1064/// IS the accumulated value (the document has no vocabulary for a separate
1065/// accumulator or a merge rule). Both go through the same unwrap out of an MCP
1066/// result envelope (see [`unwrap_pass_output`]), so the body sees ONE shape
1067/// across the whole loop whether the fold was fed over an edge or by itself.
1068/// The loop stops
1069/// when `stop_when` holds over the
1070/// pass just joined, or when `max_iterations` passes have run. A bound reached
1071/// by a fold declaring `on_bound: fail` is a typed
1072/// [`EngineError::FoldBoundExceeded`] returned in place of the convergence;
1073/// otherwise the `join`
1074/// rule picks the winner, `FoldConverged` records it with the stop reason, and
1075/// `NodeExited` closes the node.
1076async fn drive_fold(
1077 ctx: &mut RunCtx,
1078 plan: &FoldPlan<'_>,
1079 routed: &Value,
1080 by_id: &HashMap<&str, &Node>,
1081 agents: &impl AgentResolver,
1082 tools: &impl ToolResolver,
1083 graph_hash: &str,
1084) -> Result<FoldOutcome, EngineError> {
1085 let fold = plan.node;
1086 let node_id = fold.id.as_str();
1087
1088 // Resolve the body up front so an unsupported body form refuses BEFORE the
1089 // fold's NodeEntered is recorded.
1090 let body: &Node = match &fold.body {
1091 FoldBody::Node(target) => {
1092 let body_node =
1093 by_id
1094 .get(target.as_str())
1095 .copied()
1096 .ok_or_else(|| EngineError::MalformedGraph {
1097 detail: format!(
1098 "fold node `{node_id}`: body names unknown node `{target}`"
1099 ),
1100 })?;
1101 match body_node {
1102 Node::Agent(_) | Node::Tool(_) => body_node,
1103 other => {
1104 return Err(EngineError::UnsupportedFoldBody {
1105 node: node_id.to_owned(),
1106 detail: format!(
1107 "a `{}` body node cannot be a per-pass worker; only `agent` and `tool` bodies run",
1108 other.kind_name()
1109 ),
1110 });
1111 }
1112 }
1113 }
1114 FoldBody::Subgraph(_) => {
1115 return Err(EngineError::UnsupportedFoldBody {
1116 node: node_id.to_owned(),
1117 detail: "an embedded `subgraph` body is not executed yet".to_owned(),
1118 });
1119 }
1120 };
1121
1122 // The validator requires a bound of at least 1; the engine re-checks it
1123 // defensively, before NodeEntered, because a zero bound would leave the
1124 // join with no pass to choose.
1125 if fold.max_iterations < 1 {
1126 return Err(EngineError::MalformedGraph {
1127 detail: format!(
1128 "fold node `{node_id}`: max_iterations must be at least 1, found {}",
1129 fold.max_iterations
1130 ),
1131 });
1132 }
1133
1134 ctx.node_entered(node_id).await?;
1135
1136 // Every pass's output, in pass order. The latest entry is the accumulated
1137 // value the next pass folds over and the predicate reads; the whole list is
1138 // what the join rule chooses from. Not pre-sized to the bound: the bound is
1139 // author-supplied and may be enormous, and each pass does real work, so
1140 // growing costs nothing next to reserving for passes that may never run.
1141 let mut passes: Vec<Value> = Vec::new();
1142 // THE ENTRY VALUE. The fold's routed value goes through the SAME unwrap its
1143 // pass outputs do. A fold reached over an inbound edge from a `tool` node is
1144 // handed that node's recorded output, which for a tool reached over MCP is
1145 // the whole result envelope; without this, pass 0 would fold the envelope
1146 // while every later pass folds a bare payload, so one body tool would see two
1147 // shapes in a single run and pass 0 would read every field at a path that is
1148 // not there. Derived once, here, because this is the single point pass 0's
1149 // input is chosen, and a fold that IS the entry node folds its graph input
1150 // exactly as before (a plain object is not an envelope and passes through
1151 // verbatim). Nothing outside a fold changes: an ordinary edge still routes a
1152 // node's recorded output verbatim, which is what branch expressions reading
1153 // `structuredContent.` depend on.
1154 let entry = unwrap_pass_output(routed.clone());
1155 let mut stopped_by_predicate = false;
1156 for position in 0..fold.max_iterations {
1157 let index = u64::from(position);
1158 ctx.fold_iteration_started(node_id, index).await?;
1159 let call = BodyCall {
1160 owner: BodyOwner::Fold,
1161 graph_hash,
1162 node_id,
1163 index,
1164 };
1165 // Pass 0 folds over the unwrapped entry value; every later pass folds
1166 // over the pass before it, which was unwrapped as it was pushed. Bound to
1167 // its own statement so the borrow of `passes` ends before the outcome is
1168 // pushed onto it.
1169 let outcome = {
1170 let input = passes.last().unwrap_or(&entry);
1171 run_body(ctx, body, input, agents, tools, call).await?
1172 };
1173 match outcome {
1174 // THE UNWRAP POINT for a pass output; the entry value above is the
1175 // only other one, and it calls the same function. The accumulated
1176 // value the fold carries is the pass output's `structuredContent`
1177 // payload when the output is an MCP result envelope, and the output
1178 // verbatim otherwise (see `unwrap_pass_output`). Applying it here,
1179 // where the output is pushed onto `passes`, is what makes every
1180 // consumer agree: the next pass's input, the `stop_when` predicate,
1181 // the `best_by` argmax, and the join's own output all read this
1182 // vector and therefore all read the bare payload. Nothing about the
1183 // RECORDING changes: `ToolCallCompleted` already holds the full
1184 // envelope and still does. This is derivation from recorded data,
1185 // pure and total, so a replay re-derives the identical value.
1186 IterationOutcome::Output(output) => passes.push(unwrap_pass_output(output)),
1187 // No join is recorded for a parked pass, so the next drive re-enters
1188 // this same pass and re-runs its body.
1189 IterationOutcome::Parked(reason) => {
1190 return Ok(FoldOutcome::Parked {
1191 node: node_id.to_owned(),
1192 reason,
1193 });
1194 }
1195 }
1196 ctx.fold_iteration_joined(node_id, index).await?;
1197 // The predicate reads the pass that just joined. It is a pure function
1198 // of recorded output, so replay re-decides identically.
1199 let joined = passes.last().expect("the pass that just joined");
1200 if plan.stop_when.eval(joined) {
1201 stopped_by_predicate = true;
1202 break;
1203 }
1204 }
1205
1206 // THE BOUND VERDICT, before the join. A fold that declares `on_bound: fail`
1207 // treats `stop_when` as a REQUIREMENT, not an early exit, so reaching the
1208 // bound without it holding means the loop converged on nothing and there is
1209 // no value to join. The join is not consulted at all: a `best_by` argmax
1210 // over passes that all fell short would answer a question this fold did not
1211 // ask, so this check sits ahead of it and wins.
1212 //
1213 // THE RECORDING POINT, chosen deliberately. The passes and their joins are
1214 // already durably in the log and stay there: that work really happened, a
1215 // replay must reproduce it, and an operator reading the run needs to see
1216 // what the loop actually tried. What is refused is the CONVERGENCE. So this
1217 // returns from exactly the line `fold_converged` would have occupied: no
1218 // `FoldConverged`, no `NodeExited`, no terminal from the engine, which is
1219 // the same before-the-record discipline `FoldNoComparableCandidate` already
1220 // follows one arm below. An absent `on_bound`, or `OnBound::Join`, never
1221 // reaches this branch and behaves exactly as it did before the field
1222 // existed.
1223 if !stopped_by_predicate && fold.on_bound == Some(OnBound::Fail) {
1224 return Err(EngineError::FoldBoundExceeded {
1225 node: node_id.to_owned(),
1226 bound: fold.max_iterations,
1227 });
1228 }
1229
1230 // The bound is at least 1 and every pass either pushed its output or
1231 // returned, so there is always a last pass here.
1232 let last = passes.len() - 1;
1233 let last_index = last as u64;
1234 let (winner_index, output) = match &fold.join {
1235 FoldJoin::Last => (last_index, passes[last].clone()),
1236 // Every pass contributes to an `all` join's output, so no single pass is
1237 // the winner; the recorded winner_index reads as the pass the loop
1238 // stopped at, which is what bounds the list.
1239 FoldJoin::All => (last_index, Value::Array(passes.clone())),
1240 FoldJoin::BestBy(reference) => {
1241 let parsed = plan
1242 .best_by
1243 .as_ref()
1244 .expect("a best_by join parses its reference at load");
1245 let index = best_by_index(&passes, parsed).ok_or_else(|| {
1246 EngineError::FoldNoComparableCandidate {
1247 node: node_id.to_owned(),
1248 reference: reference.clone(),
1249 }
1250 })?;
1251 (index as u64, passes[index].clone())
1252 }
1253 };
1254
1255 ctx.fold_converged(
1256 node_id,
1257 winner_index,
1258 &stop_reason(fold, stopped_by_predicate, passes.len()),
1259 )
1260 .await?;
1261 ctx.node_exited(node_id).await?;
1262 Ok(FoldOutcome::Converged(output))
1263}
1264
1265/// The value a fold actually folds, given a recorded one: the
1266/// `structuredContent` payload when the value is an MCP result envelope, and the
1267/// value verbatim otherwise. Applied to a pass's output and to the fold's own
1268/// entry value, so a fold folds bare payloads whether they entered over an edge
1269/// or were produced by a pass.
1270///
1271/// A fold pass whose body is a `tool` node produces the RECORDED TOOL RESULT.
1272/// For a tool reached over MCP that result is an envelope,
1273/// `{content, structuredContent?, isError?}`: the human-readable rendering
1274/// beside the machine-readable payload. The payload is the value the loop is
1275/// actually folding, so carrying the envelope forward would make every fold
1276/// expression reach through a transport detail (`structuredContent.score`
1277/// rather than `score`), and would make the same predicate wrong against a
1278/// native tool, which returns its flat struct with no envelope at all. Unwrap
1279/// once, here, and the document says the same thing whichever tool answers.
1280///
1281/// # What counts as an envelope
1282///
1283/// An object with BOTH a `content` array and a `structuredContent` key, which
1284/// is the recorded shape and the whole recorded shape. `salvor-tools` hands the
1285/// runtime `serde_json::to_value(&rmcp::model::CallToolResult)`, and that type
1286/// serializes `content: Vec<ContentBlock>` unconditionally (no
1287/// `skip_serializing_if`, so an empty result still writes `"content": []`)
1288/// while `structuredContent`, `isError`, and `_meta` are each written only when
1289/// present. So every MCP result carries a `content` ARRAY, and only a
1290/// structured one carries the payload key beside it.
1291///
1292/// Keying on the payload key ALONE would be wrong, and that is the point of the
1293/// pair: a bare tool or agent output is arbitrary author-shaped JSON, and an
1294/// object that happens to carry a field called `structuredContent` as data is a
1295/// legitimate accumulated value, not a transport wrapper. Such a value has no
1296/// `content` array beside it and passes through whole.
1297///
1298/// Everything else is returned untouched, which is what makes this safe for
1299/// every other body: an `agent` body with an `output_schema` produces a bare
1300/// object, a native tool produces a flat struct, a graph input is whatever the
1301/// operator submitted, and a non-object value (a string, a number, a list) has
1302/// no keys at all.
1303///
1304/// This is a pure, total function of a recorded value, which is what lets it
1305/// sit outside the recording entirely: `ToolCallCompleted` still holds the full
1306/// envelope, and this is re-derived from it identically on every replay. The
1307/// document validator's fold-reference check reads the same bare payload, so
1308/// what it checks at submit is what the engine folds at run time.
1309fn unwrap_pass_output(output: Value) -> Value {
1310 match output {
1311 // The guard is the envelope test; the `remove` below cannot then miss.
1312 Value::Object(mut fields)
1313 if fields.get("content").is_some_and(Value::is_array)
1314 && fields.contains_key("structuredContent") =>
1315 {
1316 fields
1317 .remove("structuredContent")
1318 .expect("the guard proved the key is present")
1319 }
1320 other => other,
1321 }
1322}
1323
1324/// The index of the pass a `best_by` join wins with: the argmax over every
1325/// pass of the value `reference` names, ordered by the expression language's OWN
1326/// comparison ([`salvor_graph::expr::compare`]), so an argmax and the
1327/// `stop_when` predicate beside it can never order values differently.
1328///
1329/// A pass whose reference is missing, or names a value that does not order
1330/// (anything but a number or a string), cannot win: `compare` answers both
1331/// questions, so no type list is re-derived here. Ties keep the EARLIEST pass,
1332/// because only a strictly greater candidate displaces the incumbent. `None`
1333/// when no pass has a comparable value at all, which the caller refuses with
1334/// before recording a convergence.
1335fn best_by_index(passes: &[Value], reference: &Reference) -> Option<usize> {
1336 let mut best: Option<(usize, &Value)> = None;
1337 for (index, pass) in passes.iter().enumerate() {
1338 let Some(candidate) = reference
1339 .resolve(pass)
1340 .filter(|value| salvor_graph::expr::compare(value, value).is_some())
1341 else {
1342 continue;
1343 };
1344 let wins = match best {
1345 None => true,
1346 Some((_, incumbent)) => {
1347 salvor_graph::expr::compare(candidate, incumbent) == Some(Ordering::Greater)
1348 }
1349 };
1350 if wins {
1351 best = Some((index, candidate));
1352 }
1353 }
1354 best.map(|(index, _)| index)
1355}
1356
1357/// The human-readable reason recorded on `FoldConverged`: which of the two stop
1358/// causes ended the loop, naming the predicate that fired or the bound that was
1359/// reached. A pure function of the document and the pass count, so it reproduces
1360/// byte for byte on replay (the cursor matches the recorded reason). There is no
1361/// third cause: no "failed to improve" rule stops a fold.
1362///
1363/// # Why both read verdict first, expression last
1364///
1365/// This string is rendered after a display prefix that is not this function's to
1366/// change (`fold <node> converged on [<i>]: `), and readers truncate. So the
1367/// VERDICT leads and the author's `stop_when` expression trails: truncation can
1368/// then only ever eat the expression, never the word that says which way the
1369/// loop went. The bound reason in particular has to survive standing beside the
1370/// word "converged", so it says plainly that the join was taken at the bound and
1371/// that the predicate never held, rather than opening with the bound and hiding
1372/// the negation past the cut.
1373///
1374/// The `on_bound: fail` case never reaches here at all: it returns
1375/// [`EngineError::FoldBoundExceeded`] from where `FoldConverged` would have been
1376/// recorded, and that error text is verdict-first already.
1377fn stop_reason(fold: &FoldNode, stopped_by_predicate: bool, passes: usize) -> String {
1378 if stopped_by_predicate {
1379 format!(
1380 "stop_when held after pass {}: `{}`",
1381 passes - 1,
1382 fold.stop_when
1383 )
1384 } else {
1385 format!(
1386 "joined at the max_iterations bound of {}; stop_when never held: `{}`",
1387 fold.max_iterations, fold.stop_when
1388 )
1389 }
1390}
1391
1392/// The derived id of the child run executing one map iteration: `sha256:` over the
1393/// parent run id, the map node id, and the zero-based index.
1394///
1395/// A pure function of recorded data (the parent run id the `RunCtx` carries, the
1396/// document's node id, and the index), so a replay of the parent reconstructs the
1397/// identical id without storing anything extra, and it is stable across processes
1398/// and languages. Reuses `salvor-runtime`'s canonical hashing, the same story
1399/// behind [`graph_hash`] and [`fork_safe_idempotency_key`]. Iterations run
1400/// inline in the parent log today, so no separate log exists under this id
1401/// yet; it is recorded on `MapIterationStarted` as the durable, forward-compatible
1402/// identity a future concurrent-child-run would key each iteration's own log
1403/// on.
1404fn map_child_run_id(parent_run: RunId, node_id: &str, index: u64) -> String {
1405 hash_value(&json!({
1406 "parent_run": parent_run,
1407 "node": node_id,
1408 "index": index,
1409 }))
1410}
1411
1412/// Resolves a map node's `over` reference against the routed value to the list of
1413/// items to fan out over.
1414///
1415/// The reference uses the same path grammar and missing-path semantics the branch
1416/// expressions use (see [`salvor_graph::expr::parse_reference`]). A reference that
1417/// fails to parse is a [`EngineError::MalformedGraph`] that does not arise for a
1418/// validated document; one that resolves to anything but a JSON array, including
1419/// a missing path, is a typed [`EngineError::MapOverNotAList`].
1420fn resolve_over(node_id: &str, over: &str, routed: &Value) -> Result<Vec<Value>, EngineError> {
1421 let reference =
1422 salvor_graph::expr::parse_reference(over).map_err(|error| EngineError::MalformedGraph {
1423 detail: format!(
1424 "map node `{node_id}`: `over` reference `{over}` is unparseable: {error}"
1425 ),
1426 })?;
1427 match reference.resolve(routed) {
1428 Some(Value::Array(items)) => Ok(items.clone()),
1429 _ => Err(EngineError::MapOverNotAList {
1430 node: node_id.to_owned(),
1431 over: over.to_owned(),
1432 }),
1433 }
1434}
1435
1436/// The idempotency key a graph `tool` node's [`Effect::Idempotent`] call
1437/// presents: a pure function of the call's POSITION in the graph (the graph
1438/// hash, the node id, and the call index within the node), never of drawn
1439/// randomness.
1440///
1441/// This is what makes an idempotent tool fork-safe. A fork re-walks the segment
1442/// from its fork node, re-executing the idempotent calls in it live; deriving
1443/// the key from position means each re-executed call presents the IDENTICAL key
1444/// its origin recorded, so the provider collapses the duplicate. Drawing the key
1445/// from [`RunCtx::random`](salvor_runtime::RunCtx::random) instead would mint a
1446/// fresh key in the fork, and the provider would see a second, distinct call.
1447/// With this derivation, [`Effect::Write`] is the only effect class a fork must
1448/// have acknowledged, because a [`Effect::Read`] re-executes freely and a
1449/// [`Effect::Idempotent`] retry collapses.
1450///
1451/// `call_index` is the zero-based position of the call within the node. A `tool`
1452/// node makes exactly one call, so it is always `0` today; the parameter is
1453/// carried so a future node kind issuing several calls keeps their keys distinct.
1454///
1455/// Reuses `salvor-runtime`'s canonical hashing (the same behind `graph_hash`
1456/// itself), so the key is reproducible and stable across processes and
1457/// languages. Existing recorded logs are not disturbed: a key is plain data in
1458/// the log, and replay correlates on the RECORDED request, never on a
1459/// re-derivation, so a log whose idempotent call recorded a random-drawn key
1460/// still replays byte for byte under this build.
1461fn fork_safe_idempotency_key(graph_hash: &str, node_id: &str, call_index: u64) -> String {
1462 hash_value(&serde_json::json!({
1463 "graph_hash": graph_hash,
1464 "node": node_id,
1465 "call": call_index,
1466 }))
1467}
1468
1469/// The reason recorded for every [`salvor_core::Event::NodeSkipped`]: a constant,
1470/// so it is trivially a pure function of the run and reproduces byte for byte on
1471/// replay (the cursor matches the recorded reason).
1472const SKIP_REASON: &str = "no live inbound edge: an upstream branch routed to another case";
1473
1474/// Every branch node's cases, parsed once at load: the branch node id maps to
1475/// its cases as `(case name, optional parsed expression)` pairs, where the
1476/// expression is `None` for a `model_decision` case. All ids and names borrow
1477/// the graph document.
1478type ParsedBranches<'a> = HashMap<&'a str, Vec<(&'a str, Option<Expr>)>>;
1479
1480/// Parses every branch node's case conditions once, up front. An `expression`
1481/// case parses to an [`Expr`]; a `model_decision` case has no expression, so it
1482/// stores `None`. The validator already guarantees each expression parses, so a
1483/// failure here is a [`EngineError::MalformedGraph`] that does not arise for a
1484/// validated document.
1485fn parse_branches(graph: &Graph) -> Result<ParsedBranches<'_>, EngineError> {
1486 let mut parsed = HashMap::new();
1487 for node in &graph.nodes {
1488 let Node::Branch(branch) = node else {
1489 continue;
1490 };
1491 let mut cases = Vec::with_capacity(branch.cases.len());
1492 for case in &branch.cases {
1493 let expr = match &case.when {
1494 BranchCondition::Expression(source) => {
1495 Some(salvor_graph::expr::parse(source).map_err(|error| {
1496 EngineError::MalformedGraph {
1497 detail: format!(
1498 "branch node `{}`: case `{}` has an unparseable condition: {error}",
1499 branch.id, case.name
1500 ),
1501 }
1502 })?)
1503 }
1504 BranchCondition::ModelDecision => None,
1505 };
1506 cases.push((case.name.as_str(), expr));
1507 }
1508 parsed.insert(branch.id.as_str(), cases);
1509 }
1510 Ok(parsed)
1511}
1512
1513/// One fold node's parsed plan: the node itself plus its expression fields,
1514/// parsed once at load rather than once per pass.
1515struct FoldPlan<'a> {
1516 /// The document's fold node, borrowed for the drive.
1517 node: &'a FoldNode,
1518 /// The `stop_when` predicate, evaluated against each pass's output.
1519 stop_when: Expr,
1520 /// The `best_by` join's reference, `None` for the `last` and `all` joins,
1521 /// which carry no reference.
1522 best_by: Option<Reference>,
1523}
1524
1525/// Every fold node's plan, keyed by node id (both borrow the graph document).
1526type ParsedFolds<'a> = HashMap<&'a str, FoldPlan<'a>>;
1527
1528/// Parses every fold node's expression fields once, up front, exactly as
1529/// [`parse_branches`] does for the branch conditions: the `stop_when` predicate
1530/// and, for a `best_by` join, its reference. The validator already guarantees
1531/// both parse, so a failure here is a [`EngineError::MalformedGraph`] that does
1532/// not arise for a validated document.
1533fn parse_folds(graph: &Graph) -> Result<ParsedFolds<'_>, EngineError> {
1534 let mut parsed = HashMap::new();
1535 for node in &graph.nodes {
1536 let Node::Fold(fold) = node else {
1537 continue;
1538 };
1539 let stop_when = salvor_graph::expr::parse(&fold.stop_when).map_err(|error| {
1540 EngineError::MalformedGraph {
1541 detail: format!(
1542 "fold node `{}`: `stop_when` is unparseable: {error}",
1543 fold.id
1544 ),
1545 }
1546 })?;
1547 let best_by = match &fold.join {
1548 FoldJoin::BestBy(reference) => Some(
1549 salvor_graph::expr::parse_reference(reference).map_err(|error| {
1550 EngineError::MalformedGraph {
1551 detail: format!(
1552 "fold node `{}`: the `best_by` reference `{reference}` is unparseable: {error}",
1553 fold.id
1554 ),
1555 }
1556 })?,
1557 ),
1558 FoldJoin::Last | FoldJoin::All => None,
1559 };
1560 parsed.insert(
1561 fold.id.as_str(),
1562 FoldPlan {
1563 node: fold,
1564 stop_when,
1565 best_by,
1566 },
1567 );
1568 }
1569 Ok(parsed)
1570}
1571
1572/// The input a node receives: the recorded output of its live inbound edge, or
1573/// the graph input for an entry node (no inbound edge). Returns `None` when no
1574/// inbound edge is live, which means the node was routed past and must be
1575/// skipped.
1576///
1577/// An inbound edge is live when its source ran (was not skipped) and, if the
1578/// source is a branch, the edge realizes the case that fired. Among several live
1579/// inbound edges the smallest source id wins, so a merge is a pure function of
1580/// the document.
1581fn select_input(
1582 id: &str,
1583 inbound: &HashMap<&str, Vec<&Edge>>,
1584 by_id: &HashMap<&str, &Node>,
1585 branch_case: &HashMap<&str, String>,
1586 skipped: &HashSet<&str>,
1587 outputs: &HashMap<&str, Value>,
1588 graph_input: &Value,
1589) -> Option<Value> {
1590 let edges = inbound.get(id).map(Vec::as_slice).unwrap_or_default();
1591 if edges.is_empty() {
1592 return Some(graph_input.clone());
1593 }
1594 let mut chosen: Option<&Edge> = None;
1595 for edge in edges {
1596 if !is_live_inbound(edge, by_id, branch_case, skipped) {
1597 continue;
1598 }
1599 chosen = match chosen {
1600 Some(best) if best.from <= edge.from => Some(best),
1601 _ => Some(edge),
1602 };
1603 }
1604 chosen.map(|edge| {
1605 outputs
1606 .get(edge.from.as_str())
1607 .cloned()
1608 .unwrap_or(Value::Null)
1609 })
1610}
1611
1612/// Whether an inbound edge carries a live value into its destination: the source
1613/// ran, and if the source is a branch the edge's label names the fired case.
1614fn is_live_inbound(
1615 edge: &Edge,
1616 by_id: &HashMap<&str, &Node>,
1617 branch_case: &HashMap<&str, String>,
1618 skipped: &HashSet<&str>,
1619) -> bool {
1620 if skipped.contains(edge.from.as_str()) {
1621 return false;
1622 }
1623 match by_id.get(edge.from.as_str()) {
1624 // A branch only lets the edge realizing its fired case through.
1625 Some(Node::Branch(_)) => {
1626 branch_case.get(edge.from.as_str()).map(String::as_str) == edge.label.as_deref()
1627 }
1628 // Every non-branch source feeds all of its outbound edges.
1629 _ => true,
1630 }
1631}
1632
1633/// The human-readable suspension reason a gate parks under: its prompt when it
1634/// has one, else a phrase derived from the node id. A pure function of the
1635/// document, so it reproduces on replay.
1636fn gate_reason(gate: &GateNode) -> String {
1637 gate.prompt
1638 .clone()
1639 .unwrap_or_else(|| format!("approval required at gate `{}`", gate.id))
1640}
1641
1642/// Picks the first expression case whose condition is true, in author order.
1643/// Returns [`EngineError::NoBranchCaseMatched`] when none fires. A
1644/// `model_decision` case reaching here means an expression branch (no
1645/// `agent_hash`) carried one, which the validator rejects, so it is a
1646/// [`EngineError::MalformedGraph`] unreachable for a validated document.
1647fn choose_expression_case<'a>(
1648 node_id: &str,
1649 cases: &'a [(&'a str, Option<Expr>)],
1650 value: &Value,
1651) -> Result<&'a str, EngineError> {
1652 for (name, expr) in cases {
1653 match expr {
1654 Some(expr) if expr.eval(value) => return Ok(name),
1655 Some(_) => {}
1656 None => {
1657 return Err(EngineError::MalformedGraph {
1658 detail: format!(
1659 "branch node `{node_id}`: an expression branch must not carry a model-decision case"
1660 ),
1661 });
1662 }
1663 }
1664 }
1665 Err(EngineError::NoBranchCaseMatched {
1666 node: node_id.to_owned(),
1667 })
1668}
1669
1670/// Maps a decision agent's reply to a case name: the reply's final text,
1671/// trimmed, must exactly equal one of the branch's case names. Anything else is
1672/// [`EngineError::BranchDecisionUnmatched`], listing the case names.
1673fn match_decision<'a>(branch: &'a BranchNode, reply: &Value) -> Result<&'a str, EngineError> {
1674 let reply_text = reply
1675 .as_str()
1676 .map_or_else(|| reply.to_string(), |text| text.trim().to_owned());
1677 for case in &branch.cases {
1678 if case.name == reply_text {
1679 return Ok(case.name.as_str());
1680 }
1681 }
1682 Err(EngineError::BranchDecisionUnmatched {
1683 node: branch.id.clone(),
1684 reply: reply_text,
1685 cases: branch.cases.iter().map(|case| case.name.clone()).collect(),
1686 })
1687}