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//! - `{ "state": "budget_exceeded", "budget": { "kind": "...", "limit": n },
19//! "observed": n }`
20//! - `{ "state": "completed", "output": <json> }`
21//! - `{ "state": "failed", "error": "..." }`
22//! - `{ "state": "abandoned" }`, optionally with `"reason": "..."` and, when a
23//! needs-reconciliation run was abandoned,
24//! `"unresolved_write": { "seq": n, "tool": "..." }` — the recorded evidence
25//! that the abandonment never claimed the dangling write settled.
26//!
27//! # The pending object
28//!
29//! A dangling call intent is `null` when there is none, or one of:
30//!
31//! - `{ "kind": "model", "seq": n, "request_hash": "..." }`
32//! - `{ "kind": "tool", "seq": n, "tool": "...", "input": <json>,
33//! "effect": "read|idempotent|write", "idempotency_key": "..."|null }`
34
35use salvor_core::{PendingCall, RunState, RunStatus};
36use serde_json::{Value, json};
37
38/// The status object for a derived status. See the module docs for the shapes.
39#[must_use]
40pub fn status(status: &RunStatus) -> Value {
41 match status {
42 RunStatus::NotStarted => json!({ "state": "not_started" }),
43 RunStatus::Running => json!({ "state": "running" }),
44 RunStatus::AwaitingModel => json!({ "state": "awaiting_model" }),
45 RunStatus::AwaitingTool => json!({ "state": "awaiting_tool" }),
46 RunStatus::Suspended {
47 reason,
48 input_schema,
49 } => json!({
50 "state": "suspended",
51 "reason": reason,
52 "input_schema": input_schema,
53 }),
54 RunStatus::BudgetExceeded { budget, observed } => json!({
55 "state": "budget_exceeded",
56 "budget": budget,
57 "observed": observed,
58 }),
59 RunStatus::NeedsReconciliation => json!({ "state": "needs_reconciliation" }),
60 RunStatus::Completed { output } => json!({ "state": "completed", "output": output }),
61 RunStatus::Failed { error } => json!({ "state": "failed", "error": error }),
62 RunStatus::Abandoned {
63 reason,
64 unresolved_write,
65 } => {
66 let mut obj = json!({ "state": "abandoned" });
67 let map = obj.as_object_mut().expect("status object");
68 // Omit rather than assert: a reasonless abandonment carries no
69 // `reason` key, and only a needs-reconciliation abandonment carries
70 // `unresolved_write` — the same zero-vs-absent honesty the rest of
71 // the API holds to.
72 if let Some(reason) = reason {
73 map.insert("reason".to_owned(), json!(reason));
74 }
75 if let Some(write) = unresolved_write {
76 map.insert(
77 "unresolved_write".to_owned(),
78 json!({ "seq": write.seq.get(), "tool": write.tool }),
79 );
80 }
81 obj
82 }
83 }
84}
85
86/// The pending-call object, or `null` when there is no dangling intent.
87#[must_use]
88pub fn pending(pending: Option<&PendingCall>) -> Value {
89 match pending {
90 None => Value::Null,
91 Some(PendingCall::Model { seq, request_hash }) => json!({
92 "kind": "model",
93 "seq": seq.get(),
94 "request_hash": request_hash,
95 }),
96 Some(PendingCall::Tool {
97 seq,
98 tool,
99 input,
100 effect,
101 idempotency_key,
102 }) => json!({
103 "kind": "tool",
104 "seq": seq.get(),
105 "tool": tool,
106 "input": input,
107 "effect": effect,
108 "idempotency_key": idempotency_key,
109 }),
110 }
111}
112
113/// The full derived-state object: the dry-run replay projection a client gets
114/// from the run and replay endpoints (status, usage, next position, pending
115/// intent). Nothing here executes; it is a pure fold of the recorded log.
116#[must_use]
117pub fn run_state(state: &RunState) -> Value {
118 json!({
119 "status": status(&state.status),
120 "usage": {
121 "input_tokens": state.usage.input_tokens,
122 "output_tokens": state.usage.output_tokens,
123 },
124 "next_seq": state.next_seq.get(),
125 "pending": pending(state.pending_call.as_ref()),
126 })
127}