salvor_server/json.rs
1//! Turning derived run state into the JSON shapes the control-plane returns.
2//!
3//! The core [`RunState`] and its parts are Rust enums with data; the wire
4//! wants stable, self-describing objects a thin SDK can read without knowing
5//! the Rust types. This module is that translation, and it is the one place
6//! that fixes those shapes, so every endpoint returns a run's status the same
7//! way.
8//!
9//! # The status object
10//!
11//! A status is always `{ "state": "<name>", ... }`, where `state` is a stable
12//! snake_case token and any extra keys carry that state's data:
13//!
14//! - `{ "state": "running" }`, `{ "state": "awaiting_model" }`,
15//! `{ "state": "awaiting_tool" }`, `{ "state": "not_started" }`,
16//! `{ "state": "needs_reconciliation" }` carry nothing more.
17//! - `{ "state": "suspended", "reason": "...", "input_schema": { ... } }`,
18//! with `"kind": "signal"` added when the run is waiting on an external
19//! system rather than a person. The key is omitted for a human gate, which
20//! is what a suspension recorded before the discriminator existed meant, so
21//! a client that has never heard of it reads every old and new gate exactly
22//! as it did before.
23//! - `{ "state": "sleeping", "wake_at": "<RFC 3339>" }`: the run is parked on
24//! a durable timer until that instant, which is a different thing from
25//! `suspended` and never reported as one, because nothing is waiting on a
26//! human. Once the server's clock is past `wake_at`, the object also
27//! carries `"overdue": true` and `"overdue_seconds": n` (whole seconds
28//! since `wake_at`), naming a nap nobody has re-driven yet rather than
29//! leaving a caller to work it out against `wake_at` itself; before the
30//! deadline neither key appears.
31//! - `{ "state": "budget_exceeded", "budget": { "kind": "...", "limit": n },
32//! "observed": n }`
33//! - `{ "state": "completed", "output": <json> }`
34//! - `{ "state": "failed", "error": "..." }`
35//! - `{ "state": "abandoned" }`, optionally with `"reason": "..."` and, when a
36//! needs-reconciliation run was abandoned,
37//! `"unresolved_write": { "seq": n, "tool": "..." }`: the recorded evidence
38//! that the abandonment never claimed the dangling write settled.
39//!
40//! # The pending object
41//!
42//! A dangling call intent is `null` when there is none, or one of:
43//!
44//! - `{ "kind": "model", "seq": n, "request_hash": "..." }`
45//! - `{ "kind": "tool", "seq": n, "tool": "...", "input": <json>,
46//! "effect": "read|idempotent|write", "idempotency_key": "..."|null }`
47
48use salvor_core::{PendingCall, RunState, RunStatus};
49use serde_json::{Value, json};
50use time::OffsetDateTime;
51use time::format_description::well_known::Rfc3339;
52
53/// The status object for a derived status. See the module docs for the shapes.
54///
55/// `now` is the server's clock, read once by the caller and passed in rather
56/// than read here, so this stays a pure fold: a `sleeping` status compares it
57/// against the recorded `wake_at` to decide whether `overdue` and
58/// `overdue_seconds` belong in the object. Every other arm ignores it.
59#[must_use]
60pub fn status(status: &RunStatus, now: OffsetDateTime) -> Value {
61 match status {
62 RunStatus::NotStarted => json!({ "state": "not_started" }),
63 RunStatus::Running => json!({ "state": "running" }),
64 RunStatus::AwaitingModel => json!({ "state": "awaiting_model" }),
65 RunStatus::AwaitingTool => json!({ "state": "awaiting_tool" }),
66 RunStatus::Suspended {
67 reason,
68 input_schema,
69 kind,
70 } => {
71 let mut obj = json!({
72 "state": "suspended",
73 "reason": reason,
74 "input_schema": input_schema,
75 });
76 // Omitted rather than sent as null, the same absent-is-absent rule
77 // `abandoned` follows below: a gate says nothing about what it
78 // waits on, because a person is the assumption.
79 if let Some(kind) = kind {
80 obj.as_object_mut()
81 .expect("status object")
82 .insert("kind".to_owned(), json!(kind));
83 }
84 obj
85 }
86 RunStatus::Sleeping { wake_at } => {
87 let mut obj = json!({
88 "state": "sleeping",
89 "wake_at": rfc3339(*wake_at),
90 });
91 // Omitted rather than sent as false/zero when the deadline is
92 // still ahead: the same absent-is-absent rule `kind` follows on
93 // `suspended` above, so a client that has never heard of these
94 // keys reads a not-yet-due nap exactly as it always has.
95 if now > *wake_at {
96 let map = obj.as_object_mut().expect("status object");
97 map.insert("overdue".to_owned(), json!(true));
98 map.insert(
99 "overdue_seconds".to_owned(),
100 json!((now - *wake_at).whole_seconds()),
101 );
102 }
103 obj
104 }
105 RunStatus::BudgetExceeded { budget, observed } => json!({
106 "state": "budget_exceeded",
107 "budget": budget,
108 "observed": observed,
109 }),
110 RunStatus::NeedsReconciliation => json!({ "state": "needs_reconciliation" }),
111 RunStatus::Completed { output } => json!({ "state": "completed", "output": output }),
112 RunStatus::Failed { error } => json!({ "state": "failed", "error": error }),
113 RunStatus::Abandoned {
114 reason,
115 unresolved_write,
116 } => {
117 let mut obj = json!({ "state": "abandoned" });
118 let map = obj.as_object_mut().expect("status object");
119 // Omit rather than assert: a reasonless abandonment carries no
120 // `reason` key, and only a needs-reconciliation abandonment carries
121 // `unresolved_write`: the same zero-vs-absent honesty the rest of
122 // the API holds to.
123 if let Some(reason) = reason {
124 map.insert("reason".to_owned(), json!(reason));
125 }
126 if let Some(write) = unresolved_write {
127 map.insert(
128 "unresolved_write".to_owned(),
129 json!({ "seq": write.seq.get(), "tool": write.tool }),
130 );
131 }
132 obj
133 }
134 }
135}
136
137/// The pending-call object, or `null` when there is no dangling intent.
138#[must_use]
139pub fn pending(pending: Option<&PendingCall>) -> Value {
140 match pending {
141 None => Value::Null,
142 Some(PendingCall::Model { seq, request_hash }) => json!({
143 "kind": "model",
144 "seq": seq.get(),
145 "request_hash": request_hash,
146 }),
147 Some(PendingCall::Tool {
148 seq,
149 tool,
150 input,
151 effect,
152 idempotency_key,
153 }) => json!({
154 "kind": "tool",
155 "seq": seq.get(),
156 "tool": tool,
157 "input": input,
158 "effect": effect,
159 "idempotency_key": idempotency_key,
160 }),
161 }
162}
163
164/// Formats a recorded instant as RFC 3339, the wire form every timestamp this
165/// API returns takes.
166///
167/// Infallible in practice, and deliberately: normalizing to UTC rules out an
168/// offset with seconds, and without the `time` crate's `large-dates` feature
169/// an `OffsetDateTime` cannot hold a year outside 0000..=9999. Those are the
170/// only two ways RFC 3339 formatting fails, and a recorded instant on this
171/// server can hit neither, so `unwrap_or_default` would only ever hide a bug
172/// behind a silently empty `"wake_at": ""` on the wire rather than surface
173/// it; matches `salvor_runtime::wire`'s private `rfc3339`.
174pub(crate) fn rfc3339(timestamp: OffsetDateTime) -> String {
175 timestamp
176 .to_offset(time::UtcOffset::UTC)
177 .format(&Rfc3339)
178 .expect("an instant a run can hold formats as RFC 3339 in UTC")
179}
180
181/// The full derived-state object: the dry-run replay projection a client gets
182/// from the run and replay endpoints (status, usage, next position, pending
183/// intent). Nothing here executes; it is a pure fold of the recorded log,
184/// except for the clock `now` carries in for [`status`]'s overdue check.
185#[must_use]
186pub fn run_state(state: &RunState, now: OffsetDateTime) -> Value {
187 json!({
188 "status": status(&state.status, now),
189 "usage": {
190 "input_tokens": state.usage.input_tokens,
191 "output_tokens": state.usage.output_tokens,
192 },
193 "next_seq": state.next_seq.get(),
194 "pending": pending(state.pending_call.as_ref()),
195 })
196}
197
198#[cfg(test)]
199mod tests {
200 use salvor_core::SuspensionKind;
201 use serde_json::json;
202 use time::macros::datetime;
203
204 use super::status;
205 use salvor_core::RunStatus;
206
207 /// A fixed instant for tests that do not care what "now" is, only that
208 /// `status` needs one to fold a `sleeping` status; every other arm ignores
209 /// it entirely.
210 const ANY_NOW: time::OffsetDateTime = datetime!(2026-01-01 00:00:00 UTC);
211
212 /// A suspension says what it waits on only when there is something to
213 /// say. A signal wait carries `"kind": "signal"` so a client can keep it
214 /// out of an approval inbox; a human gate carries no `kind` key at all,
215 /// which is the shape every client already reads and the shape every log
216 /// written before the discriminator existed derives to.
217 #[test]
218 fn a_suspended_status_names_a_signal_and_stays_silent_about_a_gate() {
219 let schema = json!({"type": "object", "required": ["approved"]});
220
221 let signal = status(
222 &RunStatus::Suspended {
223 reason: "awaiting the payment webhook".to_owned(),
224 input_schema: schema.clone(),
225 kind: Some(SuspensionKind::Signal),
226 },
227 ANY_NOW,
228 );
229 assert_eq!(
230 signal,
231 json!({
232 "state": "suspended",
233 "reason": "awaiting the payment webhook",
234 "input_schema": schema,
235 "kind": "signal",
236 })
237 );
238
239 let gate = status(
240 &RunStatus::Suspended {
241 reason: "awaiting operator approval".to_owned(),
242 input_schema: schema.clone(),
243 kind: None,
244 },
245 ANY_NOW,
246 );
247 assert_eq!(
248 gate,
249 json!({
250 "state": "suspended",
251 "reason": "awaiting operator approval",
252 "input_schema": schema,
253 })
254 );
255 assert!(
256 gate.get("kind").is_none(),
257 "a gate carries no discriminator, not even a null one: {gate}"
258 );
259 }
260
261 /// Before its deadline, a sleeping run's status carries only `wake_at`:
262 /// no `overdue` key, not even `false`, the same absent-is-absent rule the
263 /// suspended `kind` follows above.
264 #[test]
265 fn a_sleeping_run_not_yet_due_carries_no_overdue_keys() {
266 let wake_at = datetime!(2026-01-01 12:00:00 UTC);
267 let now = wake_at - time::Duration::minutes(5);
268
269 let value = status(&RunStatus::Sleeping { wake_at }, now);
270 assert_eq!(
271 value,
272 json!({
273 "state": "sleeping",
274 "wake_at": "2026-01-01T12:00:00Z",
275 })
276 );
277 assert!(
278 value.get("overdue").is_none(),
279 "not due yet, so no overdue key at all: {value}"
280 );
281 assert!(value.get("overdue_seconds").is_none());
282 }
283
284 /// Once the server's clock is past `wake_at`, the status names it: a caller
285 /// reads `overdue` and how long, rather than computing it against
286 /// `wake_at` itself.
287 #[test]
288 fn a_sleeping_run_past_its_deadline_reports_overdue() {
289 let wake_at = datetime!(2026-01-01 12:00:00 UTC);
290 let now = wake_at + time::Duration::seconds(90);
291
292 let value = status(&RunStatus::Sleeping { wake_at }, now);
293 assert_eq!(
294 value,
295 json!({
296 "state": "sleeping",
297 "wake_at": "2026-01-01T12:00:00Z",
298 "overdue": true,
299 "overdue_seconds": 90,
300 })
301 );
302 }
303
304 /// The instant of the deadline itself is not yet "passed": a caller whose
305 /// clock reads exactly `wake_at` sees the same not-due shape a moment
306 /// earlier did.
307 #[test]
308 fn the_deadline_instant_itself_is_not_overdue() {
309 let wake_at = datetime!(2026-01-01 12:00:00 UTC);
310
311 let value = status(&RunStatus::Sleeping { wake_at }, wake_at);
312 assert!(value.get("overdue").is_none());
313 }
314}