1use std::collections::HashMap;
28use std::future::Future;
29use std::pin::Pin;
30use std::sync::Arc;
31use std::time::{Duration, Instant};
32
33use dashmap::DashMap;
34use serde_json::json;
35
36use crate::tenant::TenantContext;
37
38const CATALOG_TTL: Duration = Duration::from_secs(5 * 60);
40
41#[derive(Clone, Debug)]
44pub struct ComponentToolEntry {
45 pub description: String,
46 pub parameters: serde_json::Value,
47}
48
49#[derive(Clone, Debug)]
53pub struct ComponentOperation {
54 pub component_ref: String,
55 pub operation: String,
56 pub description: String,
57 pub parameters: serde_json::Value,
58}
59
60pub trait ComponentInvoker: Send + Sync {
70 fn list_operations(&self) -> Vec<ComponentOperation>;
72
73 fn invoke<'a>(
77 &'a self,
78 component_ref: &'a str,
79 operation: &'a str,
80 args_json: &'a str,
81 ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, String>> + Send + 'a>>;
82}
83
84pub struct ComponentToolCatalog {
88 tools: HashMap<(String, String), ComponentToolEntry>,
90 invoker: Arc<dyn ComponentInvoker>,
91 fetched_at: Instant,
92}
93
94impl ComponentToolCatalog {
95 fn from_invoker(invoker: Arc<dyn ComponentInvoker>) -> Self {
96 let mut tools = HashMap::new();
97 for op in invoker.list_operations() {
98 tools.insert(
99 (op.component_ref, op.operation),
100 ComponentToolEntry {
101 description: op.description,
102 parameters: op.parameters,
103 },
104 );
105 }
106 Self {
107 tools,
108 invoker,
109 fetched_at: Instant::now(),
110 }
111 }
112
113 pub fn tools(&self) -> impl Iterator<Item = (&(String, String), &ComponentToolEntry)> {
115 self.tools.iter()
116 }
117
118 pub fn len(&self) -> usize {
120 self.tools.len()
121 }
122
123 pub fn is_empty(&self) -> bool {
125 self.tools.is_empty()
126 }
127
128 pub fn tool_entry(&self, component_ref: &str, operation: &str) -> Option<&ComponentToolEntry> {
130 self.tools
131 .get(&(component_ref.to_string(), operation.to_string()))
132 }
133
134 pub async fn dispatch(
138 &self,
139 component_ref: &str,
140 operation: &str,
141 args_json: &str,
142 ) -> serde_json::Value {
143 if self.tool_entry(component_ref, operation).is_none() {
144 return json!({
145 "error": format!("unknown component tool '{component_ref}/{operation}'")
146 });
147 }
148 match self
149 .invoker
150 .invoke(component_ref, operation, args_json)
151 .await
152 {
153 Ok(value) => value,
154 Err(e) => json!({ "error": e }),
155 }
156 }
157
158 #[cfg(test)]
162 pub(crate) fn for_tests(
163 tools: HashMap<(String, String), ComponentToolEntry>,
164 invoker: Arc<dyn ComponentInvoker>,
165 ) -> Self {
166 Self {
167 tools,
168 invoker,
169 fetched_at: Instant::now(),
170 }
171 }
172}
173
174pub struct ComponentToolSource {
182 invoker: Arc<dyn ComponentInvoker>,
183 cache: DashMap<String, Arc<ComponentToolCatalog>>,
184}
185
186impl ComponentToolSource {
187 pub fn new(invoker: Arc<dyn ComponentInvoker>) -> Self {
189 Self {
190 invoker,
191 cache: DashMap::new(),
192 }
193 }
194
195 fn cache_key(tenant: &TenantContext) -> String {
198 format!("{}:{}", tenant.tenant_id, tenant.env_id)
199 }
200
201 pub async fn catalog(&self, tenant: &TenantContext) -> Arc<ComponentToolCatalog> {
204 let key = Self::cache_key(tenant);
205
206 if let Some(entry) = self.cache.get(&key) {
207 let snap = entry.value();
208 if snap.fetched_at.elapsed() < CATALOG_TTL {
209 return snap.clone();
210 }
211 }
212
213 let built = Arc::new(ComponentToolCatalog::from_invoker(self.invoker.clone()));
214 self.cache.insert(key, built.clone());
215 built
216 }
217}
218
219#[cfg(test)]
220#[allow(clippy::unwrap_used, clippy::expect_used)]
221pub(crate) mod test_support {
222 use super::*;
224 use std::sync::atomic::{AtomicUsize, Ordering};
225
226 pub(crate) struct FakeInvoker {
230 ops: Vec<ComponentOperation>,
231 result: Result<serde_json::Value, String>,
232 pub list_calls: AtomicUsize,
233 }
234
235 impl FakeInvoker {
236 pub(crate) fn new(
237 ops: Vec<ComponentOperation>,
238 result: Result<serde_json::Value, String>,
239 ) -> Self {
240 Self {
241 ops,
242 result,
243 list_calls: AtomicUsize::new(0),
244 }
245 }
246 }
247
248 impl ComponentInvoker for FakeInvoker {
249 fn list_operations(&self) -> Vec<ComponentOperation> {
250 self.list_calls.fetch_add(1, Ordering::SeqCst);
251 self.ops.clone()
252 }
253
254 fn invoke<'a>(
255 &'a self,
256 _component_ref: &'a str,
257 _operation: &'a str,
258 _args_json: &'a str,
259 ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, String>> + Send + 'a>> {
260 let result = self.result.clone();
261 Box::pin(async move { result })
262 }
263 }
264
265 pub(crate) fn op(
267 component_ref: &str,
268 operation: &str,
269 description: &str,
270 ) -> ComponentOperation {
271 ComponentOperation {
272 component_ref: component_ref.to_string(),
273 operation: operation.to_string(),
274 description: description.to_string(),
275 parameters: json!({ "type": "object", "properties": {} }),
276 }
277 }
278
279 pub(crate) fn one_tool(
281 component_ref: &str,
282 operation: &str,
283 description: &str,
284 parameters: serde_json::Value,
285 ) -> HashMap<(String, String), ComponentToolEntry> {
286 let mut m = HashMap::new();
287 m.insert(
288 (component_ref.to_string(), operation.to_string()),
289 ComponentToolEntry {
290 description: description.to_string(),
291 parameters,
292 },
293 );
294 m
295 }
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used, clippy::expect_used)]
300mod tests {
301 use super::test_support::*;
302 use super::*;
303
304 fn tenant() -> TenantContext {
305 TenantContext::new("acme", "prod")
306 }
307
308 #[tokio::test]
309 async fn source_lists_component_operations() {
310 let invoker = Arc::new(FakeInvoker::new(
311 vec![
312 op("greentic.refund", "issue_refund", "Issue a refund"),
313 op("greentic.refund", "lookup_order", "Look up an order"),
314 ],
315 Ok(json!({})),
316 ));
317 let source = ComponentToolSource::new(invoker);
318 let catalog = source.catalog(&tenant()).await;
319
320 assert_eq!(catalog.len(), 2);
321 let entry = catalog
322 .tool_entry("greentic.refund", "issue_refund")
323 .expect("operation present");
324 assert_eq!(entry.description, "Issue a refund");
325 assert!(
326 catalog
327 .tool_entry("greentic.refund", "lookup_order")
328 .is_some()
329 );
330 assert!(catalog.tool_entry("greentic.refund", "absent").is_none());
331 }
332
333 #[tokio::test]
334 async fn dispatch_returns_component_value_on_success() {
335 let invoker = Arc::new(FakeInvoker::new(
336 vec![op("greentic.refund", "issue_refund", "Issue a refund")],
337 Ok(json!({ "refund_id": "r-1" })),
338 ));
339 let source = ComponentToolSource::new(invoker);
340 let catalog = source.catalog(&tenant()).await;
341
342 let out = catalog
343 .dispatch("greentic.refund", "issue_refund", "{}")
344 .await;
345 assert_eq!(out, json!({ "refund_id": "r-1" }), "got: {out}");
346 assert!(!out.to_string().contains("error"), "got: {out}");
347 }
348
349 #[tokio::test]
350 async fn dispatch_wraps_invoker_error() {
351 let invoker = Arc::new(FakeInvoker::new(
352 vec![op("greentic.refund", "issue_refund", "Issue a refund")],
353 Err("component trapped".to_string()),
354 ));
355 let source = ComponentToolSource::new(invoker);
356 let catalog = source.catalog(&tenant()).await;
357
358 let out = catalog
359 .dispatch("greentic.refund", "issue_refund", "{}")
360 .await;
361 assert_eq!(out, json!({ "error": "component trapped" }), "got: {out}");
362 }
363
364 #[tokio::test]
365 async fn dispatch_unknown_operation_errors_without_invoking() {
366 let invoker = Arc::new(FakeInvoker::new(
367 vec![op("greentic.refund", "issue_refund", "Issue a refund")],
368 Ok(json!({ "should": "not be returned" })),
369 ));
370 let source = ComponentToolSource::new(invoker);
371 let catalog = source.catalog(&tenant()).await;
372
373 let out = catalog.dispatch("greentic.refund", "no_such", "{}").await;
375 assert!(out.to_string().contains("error"), "got: {out}");
376 assert!(
377 out.to_string().contains("greentic.refund/no_such"),
378 "got: {out}"
379 );
380 }
381
382 #[tokio::test]
383 async fn ttl_cache_reuses_within_window() {
384 let invoker = Arc::new(FakeInvoker::new(
385 vec![op("greentic.refund", "issue_refund", "Issue a refund")],
386 Ok(json!({})),
387 ));
388 let source = ComponentToolSource::new(invoker.clone());
389 let t = tenant();
390 let first = source.catalog(&t).await;
391 let second = source.catalog(&t).await;
392
393 assert!(
394 Arc::ptr_eq(&first, &second),
395 "second call must hit TTL cache"
396 );
397 assert_eq!(
398 invoker.list_calls.load(std::sync::atomic::Ordering::SeqCst),
399 1,
400 "operations enumerated exactly once within the TTL window"
401 );
402 }
403
404 #[tokio::test]
405 async fn for_tests_builds_catalog_with_entry() {
406 let invoker = Arc::new(FakeInvoker::new(vec![], Ok(json!({ "ok": true }))));
407 let catalog = ComponentToolCatalog::for_tests(
408 one_tool(
409 "greentic.refund",
410 "issue_refund",
411 "Issue a refund",
412 json!({ "type": "object" }),
413 ),
414 invoker,
415 );
416 assert_eq!(catalog.len(), 1);
417 let out = catalog
418 .dispatch("greentic.refund", "issue_refund", "{}")
419 .await;
420 assert_eq!(out, json!({ "ok": true }), "got: {out}");
421 }
422}