use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use salvor_core::{Event, EventEnvelope, PendingCall, RunId, RunStatus, derive_state};
use salvor_engine::{
ForkError, GraphOutcome, ToolResolver, WriteHazard, graph_hash, plan_fork, run_graph,
};
use salvor_graph::{Graph, Node};
use salvor_runtime::{
Agent, ParkReason, RunCtx, RunOutcome, Runtime, RuntimeError, validate_labels,
};
use salvor_server::dispatch::{Disposition, classify};
use salvor_server::{
AgentDefinition, AgentFactory, AppState, BuiltAgent, DefFormat, LlmModelExecutor, ToolRegistry,
};
use salvor_store::{EventStore, SqliteStore};
use salvor_tools::DynTool;
use salvor_tools::mcp::McpServer;
use serde_json::Value;
use tokio::net::TcpListener;
use uuid::Uuid;
use crate::agent_config::{self, AgentConfig};
use crate::checkout;
use crate::cli::{
AbandonArgs, BuildArgs, ForkArgs, GraphRunArgs, GraphValidateArgs, HistoryArgs, ReplayArgs,
ResolveArgs, ResumeArgs, RunArgs, ServeArgs,
};
use crate::dev_server::DevServer;
use crate::render;
use crate::serve_kill;
pub async fn run(store_path: &Path, args: RunArgs) -> Result<u8> {
let config = AgentConfig::load(&args.agent)?;
let input = parse_input(&args.input)?;
let store = open_store(store_path)?;
let (agent, servers) = agent_config::build_agent(&config, &args.agent).await?;
let mut runtime = Runtime::new(store.clone()).with_record_prompts(agent.record_prompts());
if let Some(labels) = agent.labels() {
runtime = runtime.with_labels(labels.clone());
}
let run_id = RunId::new();
let uuid = run_id.as_uuid().to_string();
println!("run {uuid}");
tracing::info!(run_id = %uuid, "starting run");
let outcome = runtime.start_with_id(&agent, run_id, input).await;
close_servers(servers).await;
report_outcome(outcome?, &uuid, &args.agent)
}
pub async fn resume(store_path: &Path, args: ResumeArgs) -> Result<u8> {
let run_id = parse_run_id(&args.run_id)?;
let uuid = run_id.as_uuid().to_string();
let store = open_store(store_path)?;
let log = store.read_log(run_id).await?;
if log.is_empty() {
bail!("no run {uuid} in this store");
}
let state = derive_state(&log);
let disposition = classify(&state);
match disposition {
Disposition::Reconcile(_) => {
let recorded_at = match state.pending_call.as_ref() {
Some(PendingCall::Tool { seq, .. }) => log
.iter()
.find(|envelope| envelope.seq == *seq)
.map(|envelope| envelope.recorded_at),
_ => None,
};
print!(
"{}",
render::reconciliation_report(&uuid, state.pending_call.as_ref(), recorded_at)
);
return Ok(1);
}
Disposition::Completed(output) => {
println!("run {uuid} already completed. Final output:");
println!("{}", render::pretty_json(&output));
return Ok(0);
}
Disposition::Failed(error) => {
println!("run {uuid} already failed: {error}");
return Ok(0);
}
Disposition::Abandoned {
reason,
unresolved_write,
} => {
match reason {
Some(reason) => println!("run {uuid} was abandoned: {reason}"),
None => println!("run {uuid} was abandoned"),
}
if let Some(write) = unresolved_write {
println!(
" the write at seq {} ({}) was left unresolved and is recorded as such; \
its effect stays unknown",
write.seq.get(),
write.tool
);
}
return Ok(0);
}
Disposition::NotStarted => bail!("run {uuid} has no recorded events"),
Disposition::Resume(_) | Disposition::Recover => {}
}
if is_graph_run(&log) {
return resume_graph(store, run_id, &uuid, &log, &args, disposition).await;
}
let agent_path = single_agent(&args.agents)?;
let config = AgentConfig::load(agent_path)?;
let (agent, servers) = agent_config::build_agent(&config, agent_path).await?;
let mut runtime = Runtime::new(store.clone()).with_record_prompts(agent.record_prompts());
if let Some(labels) = agent.labels() {
runtime = runtime.with_labels(labels.clone());
}
let outcome = match disposition {
Disposition::Resume(_) => {
let raw = args.input.as_deref().context(
"this run is parked awaiting input; pass --input <json|@file> to resume it",
)?;
let input = parse_input(raw)?;
tracing::info!(run_id = %uuid, "resuming parked run");
runtime.resume(&agent, run_id, input).await
}
_ => {
if args.input.is_some() {
tracing::warn!(
run_id = %uuid,
"this run crashed mid-step; --input is ignored when recovering"
);
}
tracing::info!(run_id = %uuid, "recovering crashed run");
runtime.recover(&agent, run_id).await
}
};
close_servers(servers).await;
report_outcome(outcome?, &uuid, agent_path)
}
pub async fn fork(store_path: &Path, args: ForkArgs) -> Result<u8> {
let origin_id = parse_run_id(&args.run_id)?;
let origin_uuid = origin_id.as_uuid().to_string();
let store = open_store(store_path)?;
let origin_log = store.read_log(origin_id).await?;
if origin_log.is_empty() {
bail!("no run {origin_uuid} in this store");
}
if matches!(
derive_state(&origin_log).status,
RunStatus::NeedsReconciliation
) {
bail!(
"origin run {origin_uuid} is parked at a dangling write; resolve it \
(salvor resolve {origin_uuid} --output <json>) before forking, so the fork does not \
inherit an unsettled write"
);
}
let plan = plan_fork(&origin_log, &args.from_node).map_err(|error| match error {
ForkError::NotAGraphRun => anyhow::anyhow!(
"run {origin_uuid} is an agent run, not a graph run; only a graph run has node \
boundaries to fork from"
),
ForkError::NodeNeverEntered { node } => anyhow::anyhow!(
"run {origin_uuid} never entered node `{node}`; fork from a node boundary the run reached"
),
})?;
let graph = load_and_validate_graph(&args.graph)?;
let supplied_hash = graph_hash(&graph)?;
if supplied_hash != plan.graph_hash() {
bail!(
"the graph in {} hashes to {supplied_hash}, but run {origin_uuid} forked from {}; a fork \
reuses the SAME document the origin ran (submit a changed graph as a new run instead)",
args.graph.display(),
plan.graph_hash()
);
}
let hazard_seqs = plan.hazard_seqs();
let acknowledged = parse_acknowledge_writes(args.acknowledge_writes.as_deref(), &hazard_seqs)?;
let missing: Vec<u64> = hazard_seqs
.iter()
.copied()
.filter(|seq| !acknowledged.contains(seq))
.collect();
if args.dry_run {
print!("{}", render_fork_preview(&plan, &missing));
return Ok(0);
}
if !missing.is_empty() {
let unacked: Vec<&WriteHazard> = plan
.hazards()
.iter()
.filter(|hazard| missing.contains(&hazard.seq))
.collect();
print!(
"{}",
render_fork_refusal(&origin_uuid, &args.from_node, &unacked)
);
return Ok(1);
}
let (agents, servers) = build_graph_agents(&args.agents).await?;
if let Err(error) = check_graph_resolvable(&graph, &agents) {
close_servers(servers).await;
return Err(error);
}
let tools = AgentTools(&agents);
let child_id = RunId::new();
let child_uuid = child_id.as_uuid().to_string();
let child_prefix = plan.build_child_prefix(child_id, hazard_seqs);
for envelope in &child_prefix {
if let Err(error) = store.append(envelope).await {
close_servers(servers).await;
return Err(error.into());
}
}
println!(
"run {child_uuid} (forked from {origin_uuid} at node `{}`)",
args.from_node
);
tracing::info!(run_id = %child_uuid, origin = %origin_uuid, "forking graph run");
let child_log = store.read_log(child_id).await?;
let mut ctx = RunCtx::new(store, child_id, child_log)?;
let outcome = run_graph(&mut ctx, &graph, &Value::Null, &agents, &tools).await;
close_servers(servers).await;
report_graph_outcome(outcome?, &child_uuid, &args.graph, &args.agents)
}
pub async fn resolve(store_path: &Path, args: ResolveArgs) -> Result<u8> {
let run_id = parse_run_id(&args.run_id)?;
let uuid = run_id.as_uuid().to_string();
let output = parse_input(&args.output)?;
let store = open_store(store_path)?;
if store.read_log(run_id).await?.is_empty() {
bail!("no run {uuid} in this store");
}
let runtime = Runtime::new(store);
match runtime.resolve(run_id, output).await {
Ok(_) => {
print!("{}", render::resolved_report(&uuid));
Ok(0)
}
Err(RuntimeError::NotReconcilable { status, .. }) => {
eprintln!(
"run {uuid} does not need reconciliation (status: {status}); there is no dangling write to resolve"
);
Ok(1)
}
Err(error) => Err(error.into()),
}
}
pub async fn abandon(store_path: &Path, args: AbandonArgs) -> Result<u8> {
let run_id = parse_run_id(&args.run_id)?;
let uuid = run_id.as_uuid().to_string();
let store = open_store(store_path)?;
if store.read_log(run_id).await?.is_empty() {
bail!("no run {uuid} in this store");
}
let runtime = Runtime::new(store.clone());
match runtime.abandon(run_id, args.reason).await {
Ok(_) => {
let log = store.read_log(run_id).await?;
let appended_seq = log.last().map_or(0, |env| env.seq.get());
let unresolved = match log.last().map(|env| &env.event) {
Some(Event::RunAbandoned {
unresolved_write: Some(write),
..
}) => Some((write.seq.get(), write.tool.as_str())),
_ => None,
};
print!(
"{}",
render::abandoned_report(&uuid, appended_seq, unresolved)
);
Ok(0)
}
Err(RuntimeError::AlreadyTerminal { status, .. }) => {
eprintln!(
"run {uuid} is already terminal (status: {status}); there is nothing left to abandon"
);
Ok(1)
}
Err(error) => Err(error.into()),
}
}
pub async fn list(store_path: &Path) -> Result<u8> {
let store = open_store(store_path)?;
let mut summaries = store.list_runs().await?;
if summaries.is_empty() {
println!("no runs in {}", store_path.display());
return Ok(0);
}
summaries.sort_by_key(|summary| summary.first_recorded_at);
let mut rows = Vec::with_capacity(summaries.len());
for summary in summaries {
let log = store.read_log(summary.run_id).await?;
let status = render::status_label(&derive_state(&log).status).to_owned();
rows.push((summary, status));
}
print!("{}", render::list_table(&rows));
Ok(0)
}
pub async fn history(store_path: &Path, args: HistoryArgs) -> Result<u8> {
let run_id = parse_run_id(&args.run_id)?;
let store = open_store(store_path)?;
let log = store.read_log(run_id).await?;
if log.is_empty() {
bail!("no run {} in this store", run_id.as_uuid());
}
if args.json {
println!("{}", serde_json::to_string_pretty(&log)?);
} else {
for envelope in &log {
println!("{}", render::history_line(envelope));
}
}
Ok(0)
}
pub async fn replay(store_path: &Path, args: ReplayArgs) -> Result<u8> {
if !args.dry_run {
eprintln!(
"salvor replay only supports --dry-run in this version: it re-derives state from the log without executing anything. Live replay (re-running from a chosen point) arrives in a later version."
);
return Ok(1);
}
let run_id = parse_run_id(&args.run_id)?;
let store = open_store(store_path)?;
let log = store.read_log(run_id).await?;
if log.is_empty() {
bail!("no run {} in this store", run_id.as_uuid());
}
let state = derive_state(&log);
print!("{}", render::replay_summary(&state));
Ok(0)
}
pub async fn serve(store_path: &Path, args: ServeArgs) -> Result<u8> {
if let Some(target) = &args.kill {
let target = (!target.is_empty()).then_some(target.as_str());
return serve_kill::run(target).await;
}
let bridge_dir = if args.dev {
Some(
checkout::find_repo_root()
.context(
"--dev needs a salvor checkout with bridge/; the installed dashboard is \
embedded and does not hot-reload",
)?
.join("bridge"),
)
} else {
None
};
let store = open_store(store_path)?;
let factory: AgentFactory = Arc::new(|definition: AgentDefinition| {
Box::pin(async move { build_from_definition(definition).await })
});
let mut model_config = salvor_llm::Config::from_env();
if let Ok(base_url) = std::env::var("SALVOR_MODEL_BASE_URL")
&& !base_url.is_empty()
{
model_config = model_config.with_base_url(base_url);
}
let model_client = salvor_llm::Client::new(model_config)
.context("building the model client for the client-driven model step")?;
let tool_registry = if args.demo_tools {
#[cfg(feature = "fixture")]
{
tracing::info!(
"demo tools registered: lookup_invoice (read), issue_refund (write), send_email \
(idempotent) — see salvor_cli::demo_tools"
);
crate::demo_tools::registry()
}
#[cfg(not(feature = "fixture"))]
{
bail!(
"--demo-tools requires the `fixture` feature (this binary was built with \
--no-default-features); rebuild with the default features to use it"
);
}
} else {
ToolRegistry::new()
};
let mut state = AppState::new(store, factory)
.with_model_executor(Arc::new(LlmModelExecutor::new(model_client)))
.with_tool_registry(Arc::new(tool_registry));
if let Ok(raw) = std::env::var("SALVOR_CLIENT_LEASE_TTL_SECS")
&& let Ok(secs) = raw.parse::<u64>()
&& secs > 0
{
state = state.with_client_lease_ttl(std::time::Duration::from_secs(secs));
tracing::info!(
secs,
"client-driven run lease TTL set from SALVOR_CLIENT_LEASE_TTL_SECS"
);
}
if let Some(env_name) = &args.auth_token {
match std::env::var(env_name) {
Ok(token) if !token.is_empty() => {
state = state.with_auth_token(token);
tracing::info!("bearer auth required (token read from ${env_name})");
}
_ => tracing::warn!(
"--auth-token names ${env_name}, but it is unset or empty; serving without auth"
),
}
}
let listener = TcpListener::bind(&args.bind)
.await
.with_context(|| format!("binding {}", args.bind))?;
let addr = listener.local_addr().context("reading the bound address")?;
println!("salvor control plane listening on http://{addr}");
tracing::info!(%addr, "serving the control plane");
let Some(bridge_dir) = bridge_dir else {
salvor_server::serve(listener, state)
.await
.context("serving the control plane")?;
return Ok(0);
};
let dev = DevServer::start(&bridge_dir, addr).await?;
println!(
"dev UI (hot reload): http://localhost:{}/ <- open this one",
dev.port()
);
println!("the API above stays reachable directly, e.g. for curl or an SDK");
tokio::select! {
result = salvor_server::serve(listener, state) => {
result.context("serving the control plane")?;
}
() = shutdown_signal() => {
tracing::info!("shutdown signal received, stopping the dev server");
}
}
dev.shutdown().await;
Ok(0)
}
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut terminate = signal(SignalKind::terminate()).expect("installing a SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = terminate.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
pub async fn build(args: BuildArgs) -> Result<u8> {
let root = checkout::find_repo_root()?;
println!("salvor build: repo root at {}", root.display());
let bridge = root.join("bridge");
checkout::ensure_node_modules(&bridge).await?;
println!("building the dashboard (npm run build)");
checkout::run_shell(&bridge, "npm run build").await?;
println!("building the release binary (cargo build --release -p salvor-cli)");
checkout::run_shell(&root, "cargo build --release -p salvor-cli").await?;
if args.install {
println!("installing salvor onto the PATH (cargo install --path crates/salvor-cli)");
checkout::run_shell(&root, "cargo install --path crates/salvor-cli").await?;
println!(
"salvor installed at {}",
install_dir().join("salvor").display()
);
} else {
println!("built {}", root.join("target/release/salvor").display());
println!("run `salvor build --install` to put it on your PATH");
}
Ok(0)
}
fn install_dir() -> PathBuf {
if let Some(cargo_home) = std::env::var_os("CARGO_HOME") {
PathBuf::from(cargo_home).join("bin")
} else if let Some(home) = std::env::var_os("HOME") {
PathBuf::from(home).join(".cargo").join("bin")
} else {
PathBuf::from("~/.cargo/bin")
}
}
pub fn graph_validate(args: GraphValidateArgs) -> Result<u8> {
let path = args.path.display().to_string();
let text = match std::fs::read_to_string(&args.path) {
Ok(text) => text,
Err(error) => {
eprintln!("cannot read graph file {path}: {error}");
return Ok(1);
}
};
let graph: salvor_graph::Graph = match serde_json::from_str(&text) {
Ok(graph) => graph,
Err(error) => {
eprintln!("{path} is not a valid graph document: {error}");
return Ok(1);
}
};
match salvor_graph::validate(&graph) {
Ok(summary) => {
print!("{}", render::graph_summary(&summary));
Ok(0)
}
Err(errors) => {
eprintln!("{path}: {} validation error(s):", errors.len());
for error in &errors {
eprintln!(" - {error}");
}
Ok(1)
}
}
}
pub fn graph_schema() -> Result<u8> {
let schema = salvor_graph::graph_schema();
println!("{}", serde_json::to_string_pretty(&schema)?);
Ok(0)
}
pub async fn graph_run(store_path: &Path, args: GraphRunArgs) -> Result<u8> {
let graph = load_and_validate_graph(&args.graph)?;
let input = parse_input(&args.input)?;
let labels = parse_label_args(&args.labels)?;
let store = open_store(store_path)?;
let (agents, servers) = build_graph_agents(&args.agents).await?;
if let Err(error) = check_graph_resolvable(&graph, &agents) {
close_servers(servers).await;
return Err(error);
}
let tools = AgentTools(&agents);
let run_id = RunId::new();
let uuid = run_id.as_uuid().to_string();
println!("run {uuid}");
tracing::info!(run_id = %uuid, "starting graph run");
let mut ctx = RunCtx::new(store.clone(), run_id, vec![])?;
if let Some(labels) = labels {
ctx = ctx.with_labels(labels);
}
let outcome = run_graph(&mut ctx, &graph, &input, &agents, &tools).await;
close_servers(servers).await;
report_graph_outcome(outcome?, &uuid, &args.graph, &args.agents)
}
async fn resume_graph(
store: Arc<dyn EventStore>,
run_id: RunId,
uuid: &str,
log: &[EventEnvelope],
args: &ResumeArgs,
disposition: Disposition,
) -> Result<u8> {
let graph_path = args.graph.as_deref().context(
"this is a graph run; pass --graph <graph.json> (its hash must match the recorded run) to \
re-drive it, alongside the --agent files its agent nodes reference",
)?;
let graph = load_and_validate_graph(graph_path)?;
let hash = graph_hash(&graph)?;
let recorded =
recorded_graph_hash(log).context("this graph run's log has no GraphRunStarted event")?;
if hash != recorded {
bail!(
"the graph in {} hashes to {hash}, but run {uuid} recorded {recorded}; resume needs the \
SAME document the run started with (submit the changed graph as a new run instead)",
graph_path.display()
);
}
let (agents, servers) = build_graph_agents(&args.agents).await?;
if let Err(error) = check_graph_resolvable(&graph, &agents) {
close_servers(servers).await;
return Err(error);
}
let tools = AgentTools(&agents);
let mut ctx = RunCtx::new(store, run_id, log.to_vec())?;
match disposition {
Disposition::Resume(_) => {
let raw = args.input.as_deref().context(
"this run is parked awaiting input; pass --input <json|@file> to resume it",
)?;
ctx.set_resume_input(parse_input(raw)?);
tracing::info!(run_id = %uuid, "resuming parked graph run");
}
_ => {
if args.input.is_some() {
tracing::warn!(
run_id = %uuid,
"this graph run crashed mid-step; --input is ignored when recovering"
);
}
tracing::info!(run_id = %uuid, "recovering crashed graph run");
}
}
let outcome = run_graph(&mut ctx, &graph, &Value::Null, &agents, &tools).await;
close_servers(servers).await;
report_graph_outcome(outcome?, uuid, graph_path, &args.agents)
}
struct AgentTools<'a>(&'a HashMap<String, Agent>);
impl ToolResolver for AgentTools<'_> {
fn resolve_tool(&self, name: &str) -> Option<&dyn DynTool> {
self.0.values().find_map(|agent| agent.tools().get(name))
}
}
fn load_and_validate_graph(path: &Path) -> Result<Graph> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading graph document {}", path.display()))?;
let graph: Graph = serde_json::from_str(&text)
.with_context(|| format!("{} is not a valid graph document", path.display()))?;
match salvor_graph::validate(&graph) {
Ok(_) => Ok(graph),
Err(errors) => {
let mut message = format!("{}: {} validation error(s):", path.display(), errors.len());
for error in &errors {
message.push_str(&format!("\n - {error}"));
}
bail!(message)
}
}
}
async fn build_graph_agents(paths: &[PathBuf]) -> Result<(HashMap<String, Agent>, Vec<McpServer>)> {
let mut agents: HashMap<String, Agent> = HashMap::new();
let mut servers: Vec<McpServer> = Vec::new();
for path in paths {
let config = AgentConfig::load(path)?;
let (agent, agent_servers) = agent_config::build_agent(&config, path).await?;
agents.insert(agent.def_hash().to_owned(), agent);
servers.extend(agent_servers);
}
Ok((agents, servers))
}
fn check_graph_resolvable(graph: &Graph, agents: &HashMap<String, Agent>) -> Result<()> {
for node in &graph.nodes {
let referenced = match node {
Node::Agent(agent) => Some((agent.id.as_str(), agent.agent_hash.as_str())),
Node::Branch(branch) => branch
.agent_hash
.as_deref()
.map(|hash| (branch.id.as_str(), hash)),
_ => None,
};
if let Some((node_id, hash)) = referenced
&& !agents.contains_key(hash)
{
let provided: Vec<&str> = agents.keys().map(String::as_str).collect();
let provided = if provided.is_empty() {
"none".to_owned()
} else {
provided.join(", ")
};
bail!(
"node `{node_id}` references agent `{hash}`, which none of the provided --agent \
files supply (provided: {provided})"
);
}
}
for node in &graph.nodes {
if let Node::Tool(tool) = node
&& !agents
.values()
.any(|agent| agent.tools().get(&tool.tool).is_some())
{
bail!(
"tool node `{}` names tool `{}`, which none of the provided agents carry",
tool.id,
tool.tool
);
}
}
Ok(())
}
fn is_graph_run(log: &[EventEnvelope]) -> bool {
matches!(
log.first().map(|envelope| &envelope.event),
Some(Event::GraphRunStarted { .. })
)
}
fn recorded_graph_hash(log: &[EventEnvelope]) -> Option<String> {
log.iter().find_map(|envelope| match &envelope.event {
Event::GraphRunStarted { graph_hash, .. } => Some(graph_hash.clone()),
_ => None,
})
}
fn single_agent(agents: &[PathBuf]) -> Result<&Path> {
match agents {
[one] => Ok(one),
[] => bail!("resuming an agent run needs its definition; pass --agent <file>"),
_ => bail!(
"an agent run resumes under exactly one --agent; pass --graph to resume a graph run \
with multiple agents"
),
}
}
fn parse_label_args(labels: &[String]) -> Result<Option<BTreeMap<String, String>>> {
if labels.is_empty() {
return Ok(None);
}
let mut map = BTreeMap::new();
for label in labels {
let (key, value) = label
.split_once('=')
.with_context(|| format!("label `{label}` must be key=value"))?;
map.insert(key.to_owned(), value.to_owned());
}
validate_labels(&map).map_err(anyhow::Error::msg)?;
Ok(Some(map))
}
fn parse_acknowledge_writes(arg: Option<&str>, hazard_seqs: &[u64]) -> Result<HashSet<u64>> {
match arg.map(str::trim) {
None | Some("") => Ok(HashSet::new()),
Some("all") => Ok(hazard_seqs.iter().copied().collect()),
Some(list) => list
.split(',')
.map(|item| {
let item = item.trim();
item.parse::<u64>().with_context(|| {
format!("`{item}` is not a valid log position; use `4,7` or `all`")
})
})
.collect(),
}
}
fn render_fork_preview(plan: &salvor_engine::ForkPlan, missing: &[u64]) -> String {
let mut out = String::new();
out.push_str(&format!(
"fork of run {} from node `{}` (dry run):\n",
plan.origin_run().as_uuid(),
plan.from_node()
));
out.push_str(&format!(
" prefix: {} event(s), through seq {}\n",
plan.prefix_len(),
plan.through_seq().get()
));
if plan.hazards().is_empty() {
out.push_str(" no writes in the re-walked segment; nothing to acknowledge.\n");
} else {
out.push_str(&format!(
" {} write(s) the re-walked segment would re-execute:\n",
plan.hazards().len()
));
for hazard in plan.hazards() {
out.push_str(&render_hazard_line(hazard));
}
if missing.is_empty() {
out.push_str(" all acknowledged; the fork would proceed.\n");
} else {
out.push_str(&format!(
" would refuse: seq(s) {} still need acknowledgement.\n",
seq_list(missing)
));
}
}
out
}
fn render_fork_refusal(origin: &str, from_node: &str, unacked: &[&WriteHazard]) -> String {
let mut out = String::new();
out.push_str(&format!(
"refused: forking run {origin} from node `{from_node}` would re-execute {} recorded \
write(s) the segment re-walks:\n",
unacked.len()
));
for hazard in unacked {
out.push_str(&render_hazard_line(hazard));
}
let seqs: Vec<u64> = unacked.iter().map(|hazard| hazard.seq).collect();
out.push_str(&format!(
"acknowledge that they may re-fire, then fork:\n salvor fork {origin} --from-node \
{from_node} --graph <graph.json> --agent <file>... --acknowledge-writes {}\n",
seq_list(&seqs)
));
out
}
fn render_hazard_line(hazard: &WriteHazard) -> String {
format!(
" - seq {} `{}` input {}\n",
hazard.seq,
hazard.tool,
render::pretty_json(&hazard.input).trim_end()
)
}
fn seq_list(seqs: &[u64]) -> String {
seqs.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(",")
}
fn report_graph_outcome(
outcome: GraphOutcome,
uuid: &str,
graph_path: &Path,
agents: &[PathBuf],
) -> Result<u8> {
match outcome {
GraphOutcome::Completed { output } => {
println!("{}", render::pretty_json(&output));
Ok(0)
}
GraphOutcome::Parked { node, reason } => {
println!("graph run {uuid} parked at node `{node}`.");
match &reason {
ParkReason::Suspended { reason, .. } => println!(" reason: {reason}"),
ParkReason::BudgetExceeded { budget, observed } => {
println!(" budget crossed: {budget:?} (observed {observed})");
}
}
let agent_flags: String = agents
.iter()
.map(|path| format!(" --agent {}", path.display()))
.collect();
println!(
"resume it with:\n salvor resume {uuid} --graph {}{agent_flags} --input <json>",
graph_path.display()
);
Ok(0)
}
}
}
async fn build_from_definition(definition: AgentDefinition) -> Result<BuiltAgent, String> {
let text = String::from_utf8(definition.body)
.map_err(|_| "agent definition is not valid UTF-8".to_owned())?;
let config = match definition.format {
DefFormat::Toml => AgentConfig::from_toml_str(&text),
DefFormat::Json => AgentConfig::from_json_str(&text),
}
.map_err(|error| format!("{error:#}"))?;
let base = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let pseudo_path = base.join("agent-definition");
let (agent, servers) = agent_config::build_agent(&config, &pseudo_path)
.await
.map_err(|error| format!("{error:#}"))?;
Ok(BuiltAgent { agent, servers })
}
fn report_outcome(outcome: RunOutcome, uuid: &str, agent_path: &Path) -> Result<u8> {
match outcome {
RunOutcome::Completed { output, .. } => {
println!("{}", render::pretty_json(&output));
Ok(0)
}
RunOutcome::Parked { reason, .. } => {
print!("{}", render::parked_report(uuid, &reason, agent_path));
Ok(0)
}
}
}
async fn close_servers(servers: Vec<salvor_tools::mcp::McpServer>) {
for server in servers {
if let Err(error) = server.close().await {
tracing::warn!(%error, "MCP server did not shut down cleanly");
}
}
}
fn open_store(path: &Path) -> Result<Arc<dyn EventStore>> {
let store =
SqliteStore::open(path).with_context(|| format!("opening store at {}", path.display()))?;
Ok(Arc::new(store))
}
pub fn parse_input(raw: &str) -> Result<Value> {
let text = if let Some(path) = raw.strip_prefix('@') {
std::fs::read_to_string(path).with_context(|| format!("reading input file {path}"))?
} else {
raw.to_owned()
};
serde_json::from_str(&text).context("parsing --input as JSON (wrap a bare string in quotes)")
}
fn parse_run_id(text: &str) -> Result<RunId> {
let uuid = Uuid::parse_str(text)
.with_context(|| format!("`{text}` is not a valid run id (expected a UUID)"))?;
Ok(RunId::from_uuid(uuid))
}