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//! - `{ "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}