Skip to main content

roba_types/
lib.rs

1//! roba's stable machine contract, as a dependency-light library.
2//!
3//! roba is meant to be consumed as a subprocess whose `--json` output and
4//! exit code are a stable ABI. This crate is that ABI, extracted so a
5//! downstream harness can deserialize against it and branch on the exit code
6//! without depending on the whole `roba` binary (tokio, clap, termimad, ...).
7//!
8//! Two pieces:
9//!
10//! - **Exit codes** ([`EXIT_FAILURE`] .. [`EXIT_MAX_BUDGET`]) -- the full map
11//!   the binary returns. The `roba` binary references these same constants, so
12//!   the crate and the binary cannot disagree.
13//! - **`--json` envelopes** ([`SuccessEnvelope`], [`VersionedResult`],
14//!   [`ErrorEnvelope`]) -- the uniform `{ version, result[, refusal] }` /
15//!   `{ version, error }` shapes. Each is generic over the payload and derives
16//!   both `Serialize` (roba serializes a borrow, no clone) and `Deserialize`
17//!   (a consumer deserializes owned), so there is one type per shape and no
18//!   drift between producer and consumer.
19//!
20//! The result payload is claude's own [`QueryResult`], re-exported here. This
21//! crate pulls `claude-wrapper` with `default-features = false, features =
22//! ["json"]`, so it carries serde but **no async runtime**.
23
24use serde::{Deserialize, Serialize};
25
26/// claude's result payload -- the shape inside a success envelope's `result`.
27/// Re-exported (not mirrored) so it cannot drift from claude's contract.
28pub use claude_wrapper::types::QueryResult;
29
30/// The current `--json` ABI version. Every envelope carries it as the first
31/// field a consumer should check before inspecting anything else.
32pub const VERSION: u32 = 1;
33
34// -- Exit codes -------------------------------------------------------------
35//
36// The full map the `roba` binary returns. `classify_exit_code` in the binary
37// references these constants, so a change here changes both. `0` (success) is
38// not a named constant -- it is the absence of a failure code.
39
40/// Generic failure -- a `claude` run failed for a reason with no more specific
41/// code, or roba itself errored.
42pub const EXIT_FAILURE: i32 = 1;
43/// Authentication failure (`claude` is not logged in / the key is bad).
44pub const EXIT_AUTH: i32 = 2;
45/// The wrapper's own `BudgetTracker` ceiling was hit (distinct from claude's
46/// `--max-budget-usd` CLI cap, which is [`EXIT_MAX_BUDGET`]).
47pub const EXIT_BUDGET: i32 = 3;
48/// The run exceeded its wall-clock `--timeout`.
49pub const EXIT_TIMEOUT: i32 = 4;
50/// The `--max-turns` cap was hit. Recoverable: the run is usually complete and
51/// just needs its lifecycle finished (gates + commit), so a caller can tell
52/// this apart from a hard failure and resume.
53pub const EXIT_MAX_TURNS: i32 = 5;
54/// A run produced no usable result (empty / `is_error`). Emitted by the binary
55/// directly (never as an error envelope); the reliable machine signal is this
56/// code, not stderr.
57pub const EXIT_UNUSABLE_RESULT: i32 = 6;
58/// The `--max-budget-usd` CLI spend cap was hit. Recoverable, like
59/// [`EXIT_MAX_TURNS`]: a guardrail tripped mid-run, not a defect.
60pub const EXIT_MAX_BUDGET: i32 = 7;
61
62// -- Envelopes --------------------------------------------------------------
63
64/// The `--json` success envelope for a prompt run: `{ version, result,
65/// refusal }`. `result` holds a [`QueryResult`]; `refusal` is true when the
66/// response looked like a refusal, so a consumer can branch on
67/// "got an answer" vs "got refused" without parsing the body.
68///
69/// Generic over the payload so the producer serializes with `T = &QueryResult`
70/// (no clone) and a consumer deserializes `SuccessEnvelope<QueryResult>`.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct SuccessEnvelope<T> {
73    /// ABI version ([`VERSION`]).
74    pub version: u32,
75    /// The run's result payload.
76    pub result: T,
77    /// True when the response looked like a refusal.
78    pub refusal: bool,
79}
80
81impl<T> SuccessEnvelope<T> {
82    /// Wrap a payload at the current ABI version.
83    pub fn new(result: T, refusal: bool) -> Self {
84        Self {
85            version: VERSION,
86            result,
87            refusal,
88        }
89    }
90}
91
92/// The `--json` success envelope for the read-only management commands
93/// (`cost`, `history`, `last`, `doctor`, `worktree list`, ...): `{ version,
94/// result }`, without the prompt-run-only `refusal` flag. Generic over the
95/// payload `T`, which differs per command.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct VersionedResult<T> {
98    /// ABI version ([`VERSION`]).
99    pub version: u32,
100    /// The command's result payload.
101    pub result: T,
102}
103
104impl<T> VersionedResult<T> {
105    /// Wrap a payload at the current ABI version.
106    pub fn new(result: T) -> Self {
107        Self {
108            version: VERSION,
109            result,
110        }
111    }
112}
113
114/// The `--json` failure envelope, emitted on stderr: `{ version, error }`.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ErrorEnvelope {
117    /// ABI version ([`VERSION`]).
118    pub version: u32,
119    /// The failure detail.
120    pub error: ErrorBody,
121}
122
123impl ErrorEnvelope {
124    /// Wrap an error body at the current ABI version.
125    pub fn new(error: ErrorBody) -> Self {
126        Self {
127            version: VERSION,
128            error,
129        }
130    }
131}
132
133/// The failure detail inside an [`ErrorEnvelope`].
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct ErrorBody {
136    /// A small string union: `"auth" | "budget" | "timeout" | "history" |
137    /// "other"`. Mirrors the exit-code dispatch; a consumer can match on it or
138    /// on the [`exit_code`](Self::exit_code).
139    pub kind: String,
140    /// A human-readable summary of the failure.
141    pub message: String,
142    /// The process exit code that accompanies this failure.
143    pub exit_code: i32,
144    /// The error-context chain, outermost first, root cause last.
145    pub chain: Vec<String>,
146    /// Optional doc URLs relevant to the failure. Omitted when empty.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub see_also: Vec<String>,
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn success_envelope_round_trips() {
157        // Serialize as a borrow (the producer shape), deserialize as owned
158        // (the consumer shape) -- the two directions agree on one struct.
159        let payload = serde_json::json!({ "result": "hi", "session_id": "s1" });
160        let json = serde_json::to_string(&SuccessEnvelope::new(&payload, false)).unwrap();
161        let back: SuccessEnvelope<serde_json::Value> = serde_json::from_str(&json).unwrap();
162        assert_eq!(back.version, 1);
163        assert!(!back.refusal);
164        assert_eq!(back.result["session_id"], "s1");
165    }
166
167    #[test]
168    fn versioned_result_round_trips() {
169        let json = serde_json::to_string(&VersionedResult::new(&vec![1, 2, 3])).unwrap();
170        assert_eq!(json, r#"{"version":1,"result":[1,2,3]}"#);
171        let back: VersionedResult<Vec<i32>> = serde_json::from_str(&json).unwrap();
172        assert_eq!(back.result, vec![1, 2, 3]);
173    }
174
175    #[test]
176    fn error_envelope_shape_and_see_also_omitted_when_empty() {
177        let env = ErrorEnvelope::new(ErrorBody {
178            kind: "auth".to_string(),
179            message: "not authenticated".to_string(),
180            exit_code: EXIT_AUTH,
181            chain: vec!["top".to_string(), "root".to_string()],
182            see_also: Vec::new(),
183        });
184        let json = serde_json::to_string(&env).unwrap();
185        assert!(json.contains(r#""version":1"#), "{json}");
186        assert!(json.contains(r#""kind":"auth""#), "{json}");
187        assert!(json.contains(r#""exit_code":2"#), "{json}");
188        assert!(
189            !json.contains("see_also"),
190            "empty see_also must be omitted: {json}"
191        );
192        // Round-trips back to an owned body.
193        let back: ErrorEnvelope = serde_json::from_str(&json).unwrap();
194        assert_eq!(back.error.exit_code, 2);
195        assert!(back.error.see_also.is_empty());
196    }
197
198    #[test]
199    fn exit_codes_are_the_stable_map() {
200        assert_eq!(
201            (
202                EXIT_FAILURE,
203                EXIT_AUTH,
204                EXIT_BUDGET,
205                EXIT_TIMEOUT,
206                EXIT_MAX_TURNS,
207                EXIT_UNUSABLE_RESULT,
208                EXIT_MAX_BUDGET
209            ),
210            (1, 2, 3, 4, 5, 6, 7)
211        );
212    }
213}