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 schedule: {0}")]
94 InvalidSchedule(String),
95
96 #[error("schedule is in the past: {0}")]
98 PastSchedule(String),
99
100 #[error("transport error: {0}")]
102 Transport(#[from] ProtocolCodecError),
103
104 #[error("sidecar error: {0}")]
106 Sidecar(String),
107}
108
109impl From<TransportError> for ClientError {
110 fn from(error: TransportError) -> Self {
111 match error {
112 TransportError::Protocol(error) => ClientError::Transport(error),
113 TransportError::Sidecar(message) => ClientError::Sidecar(message),
114 }
115 }
116}
117
118impl ClientError {
119 pub(crate) fn from_rejection(
120 rejection: agentos_sidecar_client::wire::RejectedResponse,
121 ) -> Self {
122 if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
123 || rejection.code == "ERR_AGENTOS_OVERLOADED"
124 {
125 return Self::ResourceLimit {
126 code: rejection.code,
127 message: rejection.message,
128 details: Box::new(ResourceLimitDetails {
129 limit_name: rejection.limit_name,
130 configured_limit: rejection.configured_limit,
131 current_usage: rejection.current_usage,
132 requested: rejection.requested,
133 unit: rejection.unit,
134 scope: rejection.scope,
135 vm_id: rejection.vm_id,
136 session_generation: rejection.session_generation,
137 capability_id: rejection.capability_id,
138 operation: rejection.operation,
139 configuration_path: rejection.configuration_path,
140 retryable: rejection.retryable,
141 errno: rejection.errno,
142 }),
143 };
144 }
145 Self::Kernel {
146 code: rejection.code,
147 message: rejection.message,
148 }
149 }
150
151 pub fn batch_message(&self) -> String {
160 match self {
161 ClientError::Kernel { code, message } => {
162 if message.starts_with(&format!("{code}:")) {
163 message.clone()
164 } else {
165 format!("{code}: {message}")
166 }
167 }
168 ClientError::ResourceLimit { code, message, .. } => {
169 if message.starts_with(&format!("{code}:")) {
170 message.clone()
171 } else {
172 format!("{code}: {message}")
173 }
174 }
175 ClientError::AcpOperation { message, .. } => message.clone(),
176 ClientError::PathNotAbsolute(_)
177 | ClientError::PathNotNormalized(_)
178 | ClientError::PathReadOnly(_)
179 | ClientError::ProcessNotFound(_)
180 | ClientError::ShellNotFound(_)
181 | ClientError::SessionNotFound(_)
182 | ClientError::InvalidSchedule(_)
183 | ClientError::PastSchedule(_)
184 | ClientError::Transport(_)
185 | ClientError::Sidecar(_) => self.to_string(),
186 }
187 }
188}
189
190pub type ClientResult<T> = std::result::Result<T, ClientError>;
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn structured_resource_limit_metadata_survives_rejection_conversion() {
199 let error = ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
200 code: String::from("ERR_AGENTOS_RESOURCE_LIMIT"),
201 message: String::from("handle command bytes exceeded"),
202 limit_name: Some(String::from("handleCommandBytes")),
203 configured_limit: Some(4096),
204 current_usage: Some(3072),
205 requested: Some(2048),
206 unit: Some(String::from("bytes")),
207 scope: Some(String::from("vm")),
208 vm_id: Some(String::from("vm-1")),
209 session_generation: Some(3),
210 capability_id: Some(11),
211 operation: Some(String::from("socket.write")),
212 configuration_path: Some(String::from("limits.reactor.maxHandleCommandBytes")),
213 retryable: Some(true),
214 errno: Some(String::from("EAGAIN")),
215 });
216
217 match error {
218 ClientError::ResourceLimit { details, .. } => {
219 assert_eq!(details.configured_limit, Some(4096));
220 assert_eq!(details.current_usage, Some(3072));
221 assert_eq!(details.requested, Some(2048));
222 assert_eq!(
223 details.configuration_path.as_deref(),
224 Some("limits.reactor.maxHandleCommandBytes")
225 );
226 assert_eq!(details.retryable, Some(true));
227 assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
228 }
229 other => panic!("expected resource limit, got {other:?}"),
230 }
231 }
232
233 #[test]
234 fn acp_operation_keeps_code_separate_from_message() {
235 let error = ClientError::AcpOperation {
236 code: String::from("session_busy"),
237 message: String::from("session is running"),
238 };
239 match &error {
240 ClientError::AcpOperation { code, message } => {
241 assert_eq!(code, "session_busy");
242 assert_eq!(message, "session is running");
243 }
244 other => panic!("expected ACP operation error, got {other:?}"),
245 }
246 assert_eq!(error.batch_message(), "session is running");
247 }
248}