1use agentos_sidecar_client::{ProtocolCodecError, TransportError};
12
13#[derive(Debug)]
16pub struct ResourceLimitDetails {
17 pub limit_name: Option<String>,
18 pub configured_limit: Option<u64>,
19 pub current_usage: Option<u64>,
20 pub requested: Option<u64>,
21 pub unit: Option<String>,
22 pub scope: Option<String>,
23 pub vm_id: Option<String>,
24 pub session_generation: Option<u64>,
25 pub capability_id: Option<u64>,
26 pub operation: Option<String>,
27 pub configuration_path: Option<String>,
28 pub retryable: Option<bool>,
29 pub errno: Option<String>,
30}
31
32#[derive(thiserror::Error, Debug)]
34pub enum ClientError {
35 #[error("Path must be absolute: {0}")]
41 PathNotAbsolute(String),
42
43 #[error("Path must be normalized: {0}")]
47 PathNotNormalized(String),
48
49 #[error("Path is read-only: {0}")]
53 PathReadOnly(String),
54
55 #[error("Process not found: {0}")]
61 ProcessNotFound(u32),
62
63 #[error("shell not found: {0}")]
65 ShellNotFound(String),
66
67 #[error("session not found: {0}")]
69 SessionNotFound(String),
70
71 #[error("kernel error [{code}]: {message}")]
75 Kernel { code: String, message: String },
76
77 #[error("resource limit [{code}]: {message}")]
80 ResourceLimit {
81 code: String,
82 message: String,
83 details: Box<ResourceLimitDetails>,
84 },
85
86 #[error("ACP operation [{code}]: {message}")]
90 AcpOperation { code: String, message: String },
91
92 #[error("invalid config: {0}")]
95 InvalidConfig(String),
96
97 #[error("invalid schedule: {0}")]
99 InvalidSchedule(String),
100
101 #[error("schedule is in the past: {0}")]
103 PastSchedule(String),
104
105 #[error("transport error: {0}")]
107 Transport(#[from] ProtocolCodecError),
108
109 #[error("sidecar error: {0}")]
111 Sidecar(String),
112}
113
114impl From<TransportError> for ClientError {
115 fn from(error: TransportError) -> Self {
116 match error {
117 TransportError::Protocol(error) => ClientError::Transport(error),
118 TransportError::Sidecar(message) => ClientError::Sidecar(message),
119 }
120 }
121}
122
123impl ClientError {
124 pub(crate) fn from_rejection(
125 rejection: agentos_sidecar_client::wire::RejectedResponse,
126 ) -> Self {
127 if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
128 || rejection.code == "ERR_AGENTOS_OVERLOADED"
129 {
130 return Self::ResourceLimit {
131 code: rejection.code,
132 message: rejection.message,
133 details: Box::new(ResourceLimitDetails {
134 limit_name: rejection.limit_name,
135 configured_limit: rejection.configured_limit,
136 current_usage: rejection.current_usage,
137 requested: rejection.requested,
138 unit: rejection.unit,
139 scope: rejection.scope,
140 vm_id: rejection.vm_id,
141 session_generation: rejection.session_generation,
142 capability_id: rejection.capability_id,
143 operation: rejection.operation,
144 configuration_path: rejection.configuration_path,
145 retryable: rejection.retryable,
146 errno: rejection.errno,
147 }),
148 };
149 }
150 Self::Kernel {
151 code: rejection.code,
152 message: rejection.message,
153 }
154 }
155
156 pub fn batch_message(&self) -> String {
165 match self {
166 ClientError::Kernel { code, message } => {
167 if message.starts_with(&format!("{code}:")) {
168 message.clone()
169 } else {
170 format!("{code}: {message}")
171 }
172 }
173 ClientError::ResourceLimit { code, message, .. } => {
174 if message.starts_with(&format!("{code}:")) {
175 message.clone()
176 } else {
177 format!("{code}: {message}")
178 }
179 }
180 ClientError::AcpOperation { message, .. } => message.clone(),
181 ClientError::PathNotAbsolute(_)
182 | ClientError::PathNotNormalized(_)
183 | ClientError::PathReadOnly(_)
184 | ClientError::InvalidConfig(_)
185 | ClientError::ProcessNotFound(_)
186 | ClientError::ShellNotFound(_)
187 | ClientError::SessionNotFound(_)
188 | ClientError::InvalidSchedule(_)
189 | ClientError::PastSchedule(_)
190 | ClientError::Transport(_)
191 | ClientError::Sidecar(_) => self.to_string(),
192 }
193 }
194}
195
196pub type ClientResult<T> = std::result::Result<T, ClientError>;
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn structured_resource_limit_metadata_survives_rejection_conversion() {
205 let error = ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
206 code: String::from("ERR_AGENTOS_RESOURCE_LIMIT"),
207 message: String::from("handle command bytes exceeded"),
208 limit_name: Some(String::from("handleCommandBytes")),
209 configured_limit: Some(4096),
210 current_usage: Some(3072),
211 requested: Some(2048),
212 unit: Some(String::from("bytes")),
213 scope: Some(String::from("vm")),
214 vm_id: Some(String::from("vm-1")),
215 session_generation: Some(3),
216 capability_id: Some(11),
217 operation: Some(String::from("socket.write")),
218 configuration_path: Some(String::from("limits.reactor.maxHandleCommandBytes")),
219 retryable: Some(true),
220 errno: Some(String::from("EAGAIN")),
221 });
222
223 match error {
224 ClientError::ResourceLimit { details, .. } => {
225 assert_eq!(details.configured_limit, Some(4096));
226 assert_eq!(details.current_usage, Some(3072));
227 assert_eq!(details.requested, Some(2048));
228 assert_eq!(
229 details.configuration_path.as_deref(),
230 Some("limits.reactor.maxHandleCommandBytes")
231 );
232 assert_eq!(details.retryable, Some(true));
233 assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
234 }
235 other => panic!("expected resource limit, got {other:?}"),
236 }
237 }
238
239 #[test]
240 fn acp_operation_keeps_code_separate_from_message() {
241 let error = ClientError::AcpOperation {
242 code: String::from("session_busy"),
243 message: String::from("session is running"),
244 };
245 match &error {
246 ClientError::AcpOperation { code, message } => {
247 assert_eq!(code, "session_busy");
248 assert_eq!(message, "session is running");
249 }
250 other => panic!("expected ACP operation error, got {other:?}"),
251 }
252 assert_eq!(error.batch_message(), "session is running");
253 }
254}