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}