Skip to main content

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}