use salvor_core::{BudgetKind, EventEnvelope, PendingCall, RunState, RunStatus};
use salvor_runtime::{ParkReason, event_detail, event_kind};
use salvor_store::RunSummary;
use serde_json::Value;
use std::path::Path;
use time::OffsetDateTime;
use crate::serve_kill::RunningServer;
#[must_use]
pub fn history_line(envelope: &EventEnvelope) -> String {
format!(
"{:>4} {} {:<19} {}",
envelope.seq.get(),
format_ts(envelope.recorded_at),
event_kind(&envelope.event),
event_detail(&envelope.event),
)
}
#[must_use]
pub fn parked_report(run_uuid: &str, reason: &ParkReason, agent_path: &Path) -> String {
let agent = agent_path.display();
match reason {
ParkReason::Suspended {
reason,
input_schema,
} => format!(
"Run {run_uuid} parked: suspended.\n \
reason: {reason}\n \
the resume input must satisfy this schema:\n{}\n\
Resume once you have the input:\n \
salvor resume {run_uuid} --agent {agent} --input @resume.json\n",
indent(&pretty_json(input_schema), 4),
),
ParkReason::BudgetExceeded { budget, observed } => {
let kind = budget_kind(budget.kind);
let extend_key = extend_key(budget.kind);
format!(
"Run {run_uuid} parked: budget exceeded ({kind}).\n \
limit: {}\n \
observed: {}\n\
Raise the limit and resume:\n \
salvor resume {run_uuid} --agent {agent} --input '{{\"extend\": {{\"{extend_key}\": <more>}}}}'\n",
fmt_num(budget.limit),
fmt_num(*observed),
)
}
}
}
#[must_use]
pub fn reconciliation_report(
run_uuid: &str,
pending: Option<&PendingCall>,
recorded_at: Option<OffsetDateTime>,
) -> String {
let mut out = format!(
"Run {run_uuid} needs reconciliation and cannot be resumed automatically.\n\
A write tool call was recorded but never completed, so it may or may not have taken effect.\n"
);
if let Some(PendingCall::Tool {
seq,
tool,
input,
effect,
idempotency_key,
}) = pending
{
let key = idempotency_key.as_deref().unwrap_or("<none>");
let when = recorded_at.map_or_else(|| "<unknown>".to_owned(), format_ts);
out.push_str(&format!(
"\nThe recorded intent:\n \
seq: {seq}\n \
recorded at: {when}\n \
tool: {tool}\n \
effect: {effect:?}\n \
idempotency key: {key}\n \
input:\n{}\n",
indent(&pretty_json(input), 4),
));
}
out.push_str(&format!(
"\nBecause the intent was durably recorded before the tool ran, the write may have\n\
reached its target, partially applied, or never run at all. Salvor will not guess.\n\
\n\
There are two honest outcomes. Both begin by verifying externally whether the write\n\
took effect, and both end by recording the completion so replay never re-runs it:\n \
1. The write took effect. Record what the tool returned:\n \
salvor resolve {run_uuid} --output '<json the tool returned>'\n \
2. The write did not take effect and still needs to happen. Perform it yourself\n \
first, then record its result the same way. There is no automatic retry for a write.\n"
));
out
}
#[must_use]
pub fn resolved_report(run_uuid: &str) -> String {
format!(
"Run {run_uuid} resolved: recorded the missing write completion by hand.\n\
The run no longer needs reconciliation. Continue it with:\n \
salvor resume {run_uuid} --agent <agent.toml>\n"
)
}
#[must_use]
pub fn abandoned_report(
run_uuid: &str,
appended_seq: u64,
unresolved: Option<(u64, &str)>,
) -> String {
let mut out = format!(
"Run {run_uuid}: appended RunAbandoned at seq {appended_seq}. Status now abandoned.\n\
Nothing was edited or re-run; the run is retired.\n"
);
if let Some((seq, tool)) = unresolved {
out.push_str(&format!(
"The write at seq {seq} ({tool}) stays unresolved and is recorded as such; \
its effect remains unknown.\n"
));
}
out
}
#[must_use]
pub fn list_table(rows: &[(RunSummary, String)]) -> String {
let mut out = format!(
"{:<36} {:<20} {:>6} {:<20} {:<20}\n",
"RUN ID", "STATUS", "EVENTS", "STARTED", "LAST ACTIVITY"
);
for (summary, status) in rows {
out.push_str(&format!(
"{:<36} {:<20} {:>6} {:<20} {:<20}\n",
summary.run_id.as_uuid(),
status,
summary.event_count,
format_ts(summary.first_recorded_at),
format_ts(summary.last_recorded_at),
));
}
out
}
#[must_use]
pub fn server_table(servers: &[RunningServer]) -> String {
let mut out = format!("{:>3} {:<8} {:<21} {}\n", "#", "PID", "BIND", "STORE");
for (index, server) in servers.iter().enumerate() {
out.push_str(&format!(
"{:>3} {:<8} {:<21} {}\n",
index + 1,
server.pid,
server.bind,
server.store,
));
}
out
}
#[must_use]
pub fn replay_summary(state: &RunState) -> String {
let mut out = format!(
"status: {}\n\
next seq: {}\n\
usage: in {} tokens, out {} tokens\n",
status_label(&state.status),
state.next_seq,
state.usage.input_tokens,
state.usage.output_tokens,
);
match &state.pending_call {
None => out.push_str("pending: none\n"),
Some(PendingCall::Model { seq, request_hash }) => out.push_str(&format!(
"pending: model call at seq {seq} (request {})\n",
short_hash(request_hash)
)),
Some(PendingCall::Tool {
seq, tool, effect, ..
}) => out.push_str(&format!(
"pending: tool `{tool}` [{effect:?}] at seq {seq}\n"
)),
}
out
}
#[must_use]
pub fn graph_summary(summary: &salvor_graph::GraphSummary) -> String {
format!(
"graph ok: {} node(s), {} edge(s)\n\
entry: {}\n\
terminal: {}\n",
summary.node_count,
summary.edge_count,
join_ids(&summary.entry_nodes),
join_ids(&summary.terminal_nodes),
)
}
fn join_ids(ids: &[String]) -> String {
if ids.is_empty() {
"(none)".to_owned()
} else {
ids.join(", ")
}
}
#[must_use]
pub fn status_label(status: &RunStatus) -> &'static str {
match status {
RunStatus::NotStarted => "not-started",
RunStatus::Running => "running",
RunStatus::AwaitingModel => "awaiting-model",
RunStatus::AwaitingTool => "awaiting-tool",
RunStatus::Suspended { .. } => "suspended",
RunStatus::BudgetExceeded { .. } => "budget-exceeded",
RunStatus::NeedsReconciliation => "needs-reconciliation",
RunStatus::Completed { .. } => "completed",
RunStatus::Failed { .. } => "failed",
RunStatus::Abandoned { .. } => "abandoned",
}
}
#[must_use]
pub fn pretty_json(value: &Value) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
}
#[must_use]
pub fn short_hash(hash: &str) -> String {
match hash.split_once(':') {
Some((scheme, hex)) => {
let head: String = hex.chars().take(7).collect();
if hex.len() > 7 {
format!("{scheme}:{head}\u{2026}")
} else {
format!("{scheme}:{hex}")
}
}
None => hash.chars().take(12).collect(),
}
}
fn extend_key(kind: BudgetKind) -> &'static str {
match kind {
BudgetKind::Steps => "steps",
BudgetKind::Tokens => "tokens",
BudgetKind::CostUsd => "cost_usd",
BudgetKind::WallTime => "wall_time_seconds",
}
}
fn budget_kind(kind: BudgetKind) -> &'static str {
match kind {
BudgetKind::Steps => "steps",
BudgetKind::Tokens => "tokens",
BudgetKind::CostUsd => "cost_usd",
BudgetKind::WallTime => "wall_time",
}
}
fn fmt_num(value: f64) -> String {
if value.fract() == 0.0 && value.abs() < 1e15 {
format!("{}", value as i64)
} else {
format!("{value}")
}
}
fn format_ts(ts: OffsetDateTime) -> String {
let utc = ts.to_offset(time::UtcOffset::UTC);
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}Z",
utc.year(),
u8::from(utc.month()),
utc.day(),
utc.hour(),
utc.minute(),
utc.second(),
)
}
fn indent(text: &str, spaces: usize) -> String {
let pad = " ".repeat(spaces);
text.lines()
.map(|line| format!("{pad}{line}"))
.collect::<Vec<_>>()
.join("\n")
}