use salvor_core::{PendingCall, RunState, RunStatus};
use serde_json::{Value, json};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
#[must_use]
pub fn status(status: &RunStatus, now: OffsetDateTime) -> Value {
match status {
RunStatus::NotStarted => json!({ "state": "not_started" }),
RunStatus::Running => json!({ "state": "running" }),
RunStatus::AwaitingModel => json!({ "state": "awaiting_model" }),
RunStatus::AwaitingTool => json!({ "state": "awaiting_tool" }),
RunStatus::Suspended {
reason,
input_schema,
kind,
} => {
let mut obj = json!({
"state": "suspended",
"reason": reason,
"input_schema": input_schema,
});
if let Some(kind) = kind {
obj.as_object_mut()
.expect("status object")
.insert("kind".to_owned(), json!(kind));
}
obj
}
RunStatus::Sleeping { wake_at } => {
let mut obj = json!({
"state": "sleeping",
"wake_at": rfc3339(*wake_at),
});
if now > *wake_at {
let map = obj.as_object_mut().expect("status object");
map.insert("overdue".to_owned(), json!(true));
map.insert(
"overdue_seconds".to_owned(),
json!((now - *wake_at).whole_seconds()),
);
}
obj
}
RunStatus::BudgetExceeded { budget, observed } => json!({
"state": "budget_exceeded",
"budget": budget,
"observed": observed,
}),
RunStatus::NeedsReconciliation => json!({ "state": "needs_reconciliation" }),
RunStatus::Completed { output } => json!({ "state": "completed", "output": output }),
RunStatus::Failed { error } => json!({ "state": "failed", "error": error }),
RunStatus::Abandoned {
reason,
unresolved_write,
} => {
let mut obj = json!({ "state": "abandoned" });
let map = obj.as_object_mut().expect("status object");
if let Some(reason) = reason {
map.insert("reason".to_owned(), json!(reason));
}
if let Some(write) = unresolved_write {
map.insert(
"unresolved_write".to_owned(),
json!({ "seq": write.seq.get(), "tool": write.tool }),
);
}
obj
}
}
}
#[must_use]
pub fn pending(pending: Option<&PendingCall>) -> Value {
match pending {
None => Value::Null,
Some(PendingCall::Model { seq, request_hash }) => json!({
"kind": "model",
"seq": seq.get(),
"request_hash": request_hash,
}),
Some(PendingCall::Tool {
seq,
tool,
input,
effect,
idempotency_key,
}) => json!({
"kind": "tool",
"seq": seq.get(),
"tool": tool,
"input": input,
"effect": effect,
"idempotency_key": idempotency_key,
}),
}
}
pub(crate) fn rfc3339(timestamp: OffsetDateTime) -> String {
timestamp
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.expect("an instant a run can hold formats as RFC 3339 in UTC")
}
#[must_use]
pub fn run_state(state: &RunState, now: OffsetDateTime) -> Value {
json!({
"status": status(&state.status, now),
"usage": {
"input_tokens": state.usage.input_tokens,
"output_tokens": state.usage.output_tokens,
},
"next_seq": state.next_seq.get(),
"pending": pending(state.pending_call.as_ref()),
})
}
#[cfg(test)]
mod tests {
use salvor_core::SuspensionKind;
use serde_json::json;
use time::macros::datetime;
use super::status;
use salvor_core::RunStatus;
const ANY_NOW: time::OffsetDateTime = datetime!(2026-01-01 00:00:00 UTC);
#[test]
fn a_suspended_status_names_a_signal_and_stays_silent_about_a_gate() {
let schema = json!({"type": "object", "required": ["approved"]});
let signal = status(
&RunStatus::Suspended {
reason: "awaiting the payment webhook".to_owned(),
input_schema: schema.clone(),
kind: Some(SuspensionKind::Signal),
},
ANY_NOW,
);
assert_eq!(
signal,
json!({
"state": "suspended",
"reason": "awaiting the payment webhook",
"input_schema": schema,
"kind": "signal",
})
);
let gate = status(
&RunStatus::Suspended {
reason: "awaiting operator approval".to_owned(),
input_schema: schema.clone(),
kind: None,
},
ANY_NOW,
);
assert_eq!(
gate,
json!({
"state": "suspended",
"reason": "awaiting operator approval",
"input_schema": schema,
})
);
assert!(
gate.get("kind").is_none(),
"a gate carries no discriminator, not even a null one: {gate}"
);
}
#[test]
fn a_sleeping_run_not_yet_due_carries_no_overdue_keys() {
let wake_at = datetime!(2026-01-01 12:00:00 UTC);
let now = wake_at - time::Duration::minutes(5);
let value = status(&RunStatus::Sleeping { wake_at }, now);
assert_eq!(
value,
json!({
"state": "sleeping",
"wake_at": "2026-01-01T12:00:00Z",
})
);
assert!(
value.get("overdue").is_none(),
"not due yet, so no overdue key at all: {value}"
);
assert!(value.get("overdue_seconds").is_none());
}
#[test]
fn a_sleeping_run_past_its_deadline_reports_overdue() {
let wake_at = datetime!(2026-01-01 12:00:00 UTC);
let now = wake_at + time::Duration::seconds(90);
let value = status(&RunStatus::Sleeping { wake_at }, now);
assert_eq!(
value,
json!({
"state": "sleeping",
"wake_at": "2026-01-01T12:00:00Z",
"overdue": true,
"overdue_seconds": 90,
})
);
}
#[test]
fn the_deadline_instant_itself_is_not_overdue() {
let wake_at = datetime!(2026-01-01 12:00:00 UTC);
let value = status(&RunStatus::Sleeping { wake_at }, wake_at);
assert!(value.get("overdue").is_none());
}
}