Skip to main content

roboticus_agent/
services.rs

1use chrono::{DateTime, Utc};
2use roboticus_core::{Result, RoboticusError};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use tracing::{debug, info, warn};
6
7/// A service the agent can offer to other agents or users.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ServiceDefinition {
10    pub id: String,
11    pub name: String,
12    pub description: String,
13    pub price_usdc: f64,
14    #[serde(default)]
15    pub capabilities_required: Vec<String>,
16    #[serde(default)]
17    pub max_concurrent: usize,
18    #[serde(default)]
19    pub estimated_duration_seconds: u64,
20}
21
22/// A request for a service from a client.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ServiceRequest {
25    pub id: String,
26    pub service_id: String,
27    pub requester: String,
28    pub parameters: serde_json::Value,
29    pub status: ServiceStatus,
30    pub payment_tx: Option<String>,
31    pub created_at: DateTime<Utc>,
32    pub completed_at: Option<DateTime<Utc>>,
33}
34
35/// Status of a service request.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37pub enum ServiceStatus {
38    Quoted,
39    PaymentPending,
40    PaymentVerified,
41    InProgress,
42    Completed,
43    Failed,
44    Refunded,
45}
46
47/// Manages the service catalog and request lifecycle.
48pub struct ServiceManager {
49    catalog: HashMap<String, ServiceDefinition>,
50    requests: HashMap<String, ServiceRequest>,
51    request_counter: u64,
52}
53
54impl ServiceManager {
55    pub fn new() -> Self {
56        Self {
57            catalog: HashMap::new(),
58            requests: HashMap::new(),
59            request_counter: 0,
60        }
61    }
62
63    /// Register a service in the catalog.
64    pub fn register_service(&mut self, service: ServiceDefinition) -> Result<()> {
65        if service.id.is_empty() {
66            return Err(RoboticusError::Config("service id cannot be empty".into()));
67        }
68        if service.price_usdc < 0.0 {
69            return Err(RoboticusError::Config("price cannot be negative".into()));
70        }
71        info!(id = %service.id, name = %service.name, price = service.price_usdc, "registered service");
72        self.catalog.insert(service.id.clone(), service);
73        Ok(())
74    }
75
76    /// Get a service definition by ID.
77    pub fn get_service(&self, service_id: &str) -> Option<&ServiceDefinition> {
78        self.catalog.get(service_id)
79    }
80
81    /// List all available services.
82    pub fn list_services(&self) -> Vec<&ServiceDefinition> {
83        self.catalog.values().collect()
84    }
85
86    /// Create a quote for a service request.
87    pub fn create_quote(
88        &mut self,
89        service_id: &str,
90        requester: &str,
91        params: serde_json::Value,
92    ) -> Result<ServiceRequest> {
93        let service = self
94            .catalog
95            .get(service_id)
96            .ok_or_else(|| RoboticusError::Config(format!("service '{}' not found", service_id)))?;
97
98        self.request_counter += 1;
99        let request_id = format!("req_{}", self.request_counter);
100
101        let request = ServiceRequest {
102            id: request_id.clone(),
103            service_id: service_id.to_string(),
104            requester: requester.to_string(),
105            parameters: params,
106            status: ServiceStatus::Quoted,
107            payment_tx: None,
108            created_at: Utc::now(),
109            completed_at: None,
110        };
111
112        info!(
113            request_id = %request.id,
114            service = %service.name,
115            price = service.price_usdc,
116            "created service quote"
117        );
118
119        self.requests.insert(request_id, request.clone());
120        Ok(request)
121    }
122
123    /// Record a payment for a request.
124    pub fn record_payment(&mut self, request_id: &str, tx_hash: &str) -> Result<()> {
125        let request = self
126            .requests
127            .get_mut(request_id)
128            .ok_or_else(|| RoboticusError::Config(format!("request '{}' not found", request_id)))?;
129
130        if request.status != ServiceStatus::Quoted {
131            return Err(RoboticusError::Config(format!(
132                "request '{}' is not in quoted state",
133                request_id
134            )));
135        }
136
137        request.payment_tx = Some(tx_hash.to_string());
138        request.status = ServiceStatus::PaymentVerified;
139        info!(request_id, tx = tx_hash, "payment recorded");
140        Ok(())
141    }
142
143    /// Mark a request as in progress.
144    pub fn start_fulfillment(&mut self, request_id: &str) -> Result<()> {
145        let request = self
146            .requests
147            .get_mut(request_id)
148            .ok_or_else(|| RoboticusError::Config(format!("request '{}' not found", request_id)))?;
149
150        if request.status != ServiceStatus::PaymentVerified {
151            return Err(RoboticusError::Config(format!(
152                "request '{}' payment not verified",
153                request_id
154            )));
155        }
156
157        request.status = ServiceStatus::InProgress;
158        debug!(request_id, "fulfillment started");
159        Ok(())
160    }
161
162    /// Mark a request as completed.
163    pub fn complete_fulfillment(&mut self, request_id: &str) -> Result<()> {
164        let request = self
165            .requests
166            .get_mut(request_id)
167            .ok_or_else(|| RoboticusError::Config(format!("request '{}' not found", request_id)))?;
168
169        if request.status != ServiceStatus::InProgress {
170            return Err(RoboticusError::Config(format!(
171                "request '{}' is not in progress",
172                request_id
173            )));
174        }
175
176        request.status = ServiceStatus::Completed;
177        request.completed_at = Some(Utc::now());
178        info!(request_id, "fulfillment completed");
179        Ok(())
180    }
181
182    /// Mark a request as failed.
183    pub fn fail_fulfillment(&mut self, request_id: &str) -> Result<()> {
184        let request = self
185            .requests
186            .get_mut(request_id)
187            .ok_or_else(|| RoboticusError::Config(format!("request '{}' not found", request_id)))?;
188
189        request.status = ServiceStatus::Failed;
190        warn!(request_id, "fulfillment failed");
191        Ok(())
192    }
193
194    /// Get a request by ID.
195    pub fn get_request(&self, request_id: &str) -> Option<&ServiceRequest> {
196        self.requests.get(request_id)
197    }
198
199    /// List requests by status.
200    pub fn requests_by_status(&self, status: ServiceStatus) -> Vec<&ServiceRequest> {
201        self.requests
202            .values()
203            .filter(|r| r.status == status)
204            .collect()
205    }
206
207    /// Calculate total revenue from completed requests.
208    pub fn total_revenue(&self) -> f64 {
209        self.requests
210            .values()
211            .filter(|r| r.status == ServiceStatus::Completed)
212            .filter_map(|r| self.catalog.get(&r.service_id))
213            .map(|s| s.price_usdc)
214            .sum()
215    }
216
217    pub fn catalog_size(&self) -> usize {
218        self.catalog.len()
219    }
220
221    pub fn request_count(&self) -> usize {
222        self.requests.len()
223    }
224}
225
226impl Default for ServiceManager {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn test_service() -> ServiceDefinition {
237        ServiceDefinition {
238            id: "code-review".into(),
239            name: "Code Review".into(),
240            description: "Automated code review".into(),
241            price_usdc: 5.0,
242            capabilities_required: vec!["coding".into()],
243            max_concurrent: 3,
244            estimated_duration_seconds: 300,
245        }
246    }
247
248    #[test]
249    fn register_and_list() {
250        let mut mgr = ServiceManager::new();
251        mgr.register_service(test_service()).unwrap();
252        assert_eq!(mgr.catalog_size(), 1);
253        assert!(mgr.get_service("code-review").is_some());
254    }
255
256    #[test]
257    fn reject_empty_id() {
258        let mut mgr = ServiceManager::new();
259        let mut svc = test_service();
260        svc.id = String::new();
261        assert!(mgr.register_service(svc).is_err());
262    }
263
264    #[test]
265    fn reject_negative_price() {
266        let mut mgr = ServiceManager::new();
267        let mut svc = test_service();
268        svc.price_usdc = -1.0;
269        assert!(mgr.register_service(svc).is_err());
270    }
271
272    #[test]
273    fn full_lifecycle() {
274        let mut mgr = ServiceManager::new();
275        mgr.register_service(test_service()).unwrap();
276
277        let quote = mgr
278            .create_quote("code-review", "client-1", serde_json::json!({}))
279            .unwrap();
280        assert_eq!(quote.status, ServiceStatus::Quoted);
281
282        mgr.record_payment(&quote.id, "0xabc123").unwrap();
283        assert_eq!(
284            mgr.get_request(&quote.id).unwrap().status,
285            ServiceStatus::PaymentVerified
286        );
287
288        mgr.start_fulfillment(&quote.id).unwrap();
289        assert_eq!(
290            mgr.get_request(&quote.id).unwrap().status,
291            ServiceStatus::InProgress
292        );
293
294        mgr.complete_fulfillment(&quote.id).unwrap();
295        let req = mgr.get_request(&quote.id).unwrap();
296        assert_eq!(req.status, ServiceStatus::Completed);
297        assert!(req.completed_at.is_some());
298    }
299
300    #[test]
301    fn invalid_state_transitions() {
302        let mut mgr = ServiceManager::new();
303        mgr.register_service(test_service()).unwrap();
304        let quote = mgr
305            .create_quote("code-review", "client", serde_json::json!({}))
306            .unwrap();
307
308        assert!(mgr.start_fulfillment(&quote.id).is_err());
309        assert!(mgr.complete_fulfillment(&quote.id).is_err());
310    }
311
312    #[test]
313    fn total_revenue() {
314        let mut mgr = ServiceManager::new();
315        mgr.register_service(test_service()).unwrap();
316
317        for i in 0..3 {
318            let q = mgr
319                .create_quote("code-review", &format!("client-{i}"), serde_json::json!({}))
320                .unwrap();
321            mgr.record_payment(&q.id, &format!("tx-{i}")).unwrap();
322            mgr.start_fulfillment(&q.id).unwrap();
323            if i < 2 {
324                mgr.complete_fulfillment(&q.id).unwrap();
325            }
326        }
327
328        assert!((mgr.total_revenue() - 10.0).abs() < f64::EPSILON);
329    }
330
331    #[test]
332    fn requests_by_status_filter() {
333        let mut mgr = ServiceManager::new();
334        mgr.register_service(test_service()).unwrap();
335
336        mgr.create_quote("code-review", "a", serde_json::json!({}))
337            .unwrap();
338        mgr.create_quote("code-review", "b", serde_json::json!({}))
339            .unwrap();
340
341        assert_eq!(mgr.requests_by_status(ServiceStatus::Quoted).len(), 2);
342        assert_eq!(mgr.requests_by_status(ServiceStatus::Completed).len(), 0);
343    }
344
345    #[test]
346    fn fail_fulfillment() {
347        let mut mgr = ServiceManager::new();
348        mgr.register_service(test_service()).unwrap();
349        let q = mgr
350            .create_quote("code-review", "c", serde_json::json!({}))
351            .unwrap();
352        mgr.record_payment(&q.id, "tx").unwrap();
353        mgr.start_fulfillment(&q.id).unwrap();
354        mgr.fail_fulfillment(&q.id).unwrap();
355        assert_eq!(
356            mgr.get_request(&q.id).unwrap().status,
357            ServiceStatus::Failed
358        );
359    }
360
361    #[test]
362    fn service_status_serde() {
363        for status in [
364            ServiceStatus::Quoted,
365            ServiceStatus::PaymentPending,
366            ServiceStatus::PaymentVerified,
367            ServiceStatus::InProgress,
368            ServiceStatus::Completed,
369            ServiceStatus::Failed,
370            ServiceStatus::Refunded,
371        ] {
372            let json = serde_json::to_string(&status).unwrap();
373            let back: ServiceStatus = serde_json::from_str(&json).unwrap();
374            assert_eq!(status, back);
375        }
376    }
377}