ralph_workflow/cloud/
reporter.rs1use super::types::{CloudError, PipelineResult, ProgressUpdate};
4use crate::config::types::CloudConfig;
5
6pub trait CloudReporter: Send + Sync {
11 fn report_progress(&self, update: &ProgressUpdate) -> Result<(), CloudError>;
17
18 fn heartbeat(&self) -> Result<(), CloudError>;
24
25 fn report_completion(&self, result: &PipelineResult) -> Result<(), CloudError>;
31}
32
33pub struct NoopCloudReporter;
38
39impl CloudReporter for NoopCloudReporter {
40 fn report_progress(&self, _update: &ProgressUpdate) -> Result<(), CloudError> {
41 Ok(())
42 }
43
44 fn heartbeat(&self) -> Result<(), CloudError> {
45 Ok(())
46 }
47
48 fn report_completion(&self, _result: &PipelineResult) -> Result<(), CloudError> {
49 Ok(())
50 }
51}
52
53pub struct HttpCloudReporter {
57 config: CloudConfig,
58}
59
60impl HttpCloudReporter {
61 #[must_use]
62 pub const fn new(config: CloudConfig) -> Self {
63 Self { config }
64 }
65
66 fn build_url(api_url: &str, path: &str) -> Result<String, CloudError> {
67 let base = api_url.trim();
68 if !base.to_ascii_lowercase().starts_with("https://") {
69 return Err(CloudError::Configuration(
70 "Cloud API URL must use https://".to_string(),
71 ));
72 }
73
74 let base = base.trim_end_matches('/');
75 let path = path.trim_start_matches('/');
76
77 if path.is_empty() {
78 return Ok(base.to_string());
79 }
80
81 Ok(format!("{base}/{path}"))
82 }
83
84 fn post_json<T: serde::Serialize>(&self, path: &str, body: &T) -> Result<(), CloudError> {
85 let api_url = self
86 .config
87 .api_url
88 .as_ref()
89 .ok_or_else(|| CloudError::Configuration("API URL not configured".to_string()))?;
90 let api_token = self
91 .config
92 .api_token
93 .as_ref()
94 .ok_or_else(|| CloudError::Configuration("API token not configured".to_string()))?;
95
96 let url = Self::build_url(api_url, path)?;
97
98 let agent = ureq::Agent::new_with_config(
100 ureq::config::Config::builder()
101 .timeout_global(Some(std::time::Duration::from_secs(30)))
102 .http_status_as_error(false)
104 .build(),
105 );
106
107 let json_body =
108 serde_json::to_value(body).map_err(|e| CloudError::Serialization(e.to_string()))?;
109
110 let response = agent
111 .post(&url)
112 .header("Authorization", &format!("Bearer {api_token}"))
113 .header("Content-Type", "application/json")
114 .send_json(json_body);
115
116 match response {
117 Ok(mut resp) => {
118 let status = resp.status();
119 if status.is_success() {
120 Ok(())
121 } else {
122 let body = resp.body_mut().read_to_string().unwrap_or_default();
123 Err(CloudError::HttpError(status.as_u16(), body))
124 }
125 }
126 Err(e) => Err(CloudError::NetworkError(e.to_string())),
127 }
128 }
129}
130
131impl CloudReporter for HttpCloudReporter {
132 fn report_progress(&self, update: &ProgressUpdate) -> Result<(), CloudError> {
133 let run_id = self
134 .config
135 .run_id
136 .as_ref()
137 .ok_or_else(|| CloudError::Configuration("Run ID not configured".to_string()))?;
138
139 let path = format!("runs/{run_id}/progress");
140 self.post_json(&path, update)
141 }
142
143 fn heartbeat(&self) -> Result<(), CloudError> {
144 let run_id = self
145 .config
146 .run_id
147 .as_ref()
148 .ok_or_else(|| CloudError::Configuration("Run ID not configured".to_string()))?;
149
150 let path = format!("runs/{run_id}/heartbeat");
151 let body = serde_json::json!({
152 "timestamp": chrono::Utc::now().to_rfc3339(),
153 });
154 self.post_json(&path, &body)
155 }
156
157 fn report_completion(&self, result: &PipelineResult) -> Result<(), CloudError> {
158 let run_id = self
159 .config
160 .run_id
161 .as_ref()
162 .ok_or_else(|| CloudError::Configuration("Run ID not configured".to_string()))?;
163
164 let path = format!("runs/{run_id}/complete");
165 self.post_json(&path, result)
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::config::types::CloudConfig;
173
174 #[test]
175 fn test_build_url_trims_slashes_and_joins_paths() {
176 let base = "https://api.example.com/v1/";
177 let url = HttpCloudReporter::build_url(base, "/runs/run_1/progress").unwrap();
178 assert_eq!(
179 url, "https://api.example.com/v1/runs/run_1/progress",
180 "URL join should avoid double slashes"
181 );
182 }
183
184 #[test]
185 fn test_build_url_rejects_non_https() {
186 let err = HttpCloudReporter::build_url("http://api.example.com", "/runs/x").unwrap_err();
187 match err {
188 CloudError::Configuration(_) => {}
189 other => panic!("expected Configuration error, got: {other:?}"),
190 }
191 }
192
193 #[test]
194 fn test_noop_reporter_returns_ok() {
195 let reporter = NoopCloudReporter;
196 let update = ProgressUpdate {
197 timestamp: "2025-02-15T10:00:00Z".to_string(),
198 phase: "Planning".to_string(),
199 previous_phase: None,
200 iteration: Some(1),
201 total_iterations: Some(3),
202 review_pass: None,
203 total_review_passes: None,
204 message: "Test".to_string(),
205 event_type: super::super::types::ProgressEventType::PipelineStarted,
206 };
207
208 let result = super::super::types::PipelineResult {
209 success: true,
210 commit_sha: None,
211 pr_url: None,
212 push_count: 0,
213 last_pushed_commit: None,
214 unpushed_commits: Vec::new(),
215 last_push_error: None,
216 iterations_used: 1,
217 review_passes_used: 0,
218 issues_found: false,
219 duration_secs: 100,
220 error_message: None,
221 };
222
223 assert!(reporter.report_progress(&update).is_ok());
224 assert!(reporter.heartbeat().is_ok());
225 assert!(reporter.report_completion(&result).is_ok());
226 }
227
228 #[test]
229 fn test_http_reporter_requires_config() {
230 let config = CloudConfig::disabled();
231 let reporter = HttpCloudReporter::new(config);
232
233 let update = ProgressUpdate {
234 timestamp: "2025-02-15T10:00:00Z".to_string(),
235 phase: "Planning".to_string(),
236 previous_phase: None,
237 iteration: Some(1),
238 total_iterations: Some(3),
239 review_pass: None,
240 total_review_passes: None,
241 message: "Test".to_string(),
242 event_type: super::super::types::ProgressEventType::PipelineStarted,
243 };
244
245 assert!(reporter.report_progress(&update).is_err());
247 }
248}