salvor-engine 0.5.2

The Salvor graph engine: drives a validated graph document through its nodes over the public RunCtx durability substrate
Documentation

The Salvor graph engine: drives a frozen graph document through its nodes, recording the walk into one durable run log.

Where this crate sits, and why it is its own crate

The engine is deliberately not part of salvor-runtime (that would drag the graph document format into the built-in agent loop) and not part of salvor-graph (that crate is a pure, IO-free leaf). It sits above both and composes only their public surfaces: the graph document from salvor-graph, and the durability substrate (RunCtx, drive_loop) from salvor-runtime. It reaches into nothing private. That is a deliberate proof of the runtime's API guardrail: everything the engine needs, an outside crate could also do.

What it drives

[run_graph] opens a run's log with GraphRunStarted, walks the nodes in deterministic topological order (see [walk]), and drives each one:

  • an agent node runs the built-in agent loop (drive_loop) inside the same log, framed by NodeEntered / NodeExited;
  • a tool node records one tool call through the same write-ahead intent/completion machinery the built-in loop uses, honoring the tool's effect class;
  • a gate node parks the run through the exact Suspended / Resumed machinery the built-in loop uses for a tool suspension: entering it records NodeEntered, then suspend records the gate's approval_schema as the suspension schema and the drive returns [GraphOutcome::Parked]. A later drive over the log (carrying the resume input the existing resume machinery appended) passes that input through the gate as its output and continues. A gate needs no event kind of its own;
  • a branch node routes on its input: an expression branch evaluates its cases in author order and the first true case wins; a model-decision branch drives the node's agent and maps the reply to a case name. Either way the chosen case is recorded as BranchTaken, the walk follows the like-named edge, and every node reachable only through a non-taken case is recorded NodeSkipped;
  • a map node fans out over a list. Its over reference resolves against the routed value to a JSON array (a non-array is a typed [EngineError::MapOverNotAList] refused before NodeEntered); the engine records NodeEntered, then MapFannedOut with the resolved item list, then walks the list IN INDEX ORDER, and for each element records MapIterationStarted (carrying the derived child-run id), runs the body's work inline, and records MapIterationJoined. The joined output is the per-element outputs as a list in index order. Iterations run inline and sequentially in the parent's own log: the concurrency cap is accepted (the validator requires it be at least 1) but not honored — a deliberate v0.4 choice that costs only wall-clock and changes no event shape, so the whole fan-out is proven by the same single-log replay machinery already proven for linear and branching graphs. Concurrent child runs are not yet supported. A subgraph body, or a body node that is not an agent or tool, is a typed [EngineError::UnsupportedMapBody] refused before NodeEntered.

A node that is a map's body is executed ONLY as that map's per-item worker; it is never walked independently, so its own events (a tool call, an agent loop) are recorded inline between the map's iteration markers and its node id is never framed with a NodeEntered of its own. That keeps node ids unambiguous in the one log and is why forking INTO a map iteration is refused: an iteration is not a node boundary (see [plan_fork]).

After the last node the engine records the single terminal RunCompleted. There is no ambient clock or randomness in any decision: everything the engine feeds forward — the walk order, each node's input, the branch route, a map's resolved item list and its per-iteration child ids, an idempotent tool's idempotency key — is a pure function of the document or of values the RunCtx recorded, so a second drive over the recorded log replays with no live calls and produces a byte-identical log. A map iteration's child-run id is sha256: over the parent run id, the node id, and the index (see [map_child_run_id]) — pure recorded data, so replay reconstructs the identical id without storing anything extra. The idempotency key is derived from the call's position in the graph (graph hash, node id, call index) rather than from drawn randomness, which is what lets a FORK of a run re-walk a segment and present the same key its origin recorded — see [fork_safe_idempotency_key] and the salvor-server fork endpoint.

Data flow

Each node's output flows to its successors along the edges, and a node's input is the recorded output of the live inbound edge that reaches it (the graph input for an entry node with no inbound edge). A branch passes its routed value through unchanged to the taken case's edge; the decision only selects the route, never the data. A tool node's input references are still not resolved yet; the upstream output is the downstream input verbatim. When more than one live inbound edge reaches a node, the one whose source id is smallest wins, so the merge is a pure function of the document.

Resolving agents and tools

A node names its agent by hash and its tool by name; the engine turns those into executables through the [AgentResolver] and [ToolResolver] traits the caller supplies. Tests inject maps; the server wires its own registries in separately. Keeping resolution behind a trait is what lets the engine stay ignorant of where agents and tools actually come from.