robonix_executor/dispatch/
mod.rs1pub mod async_poll;
25pub mod async_registry;
26pub mod builtin;
27pub mod grpc;
28pub mod mcp;
29
30use std::collections::HashSet;
31use std::sync::Mutex;
32use std::time::Duration;
33
34use anyhow::{Context, Result};
35use tonic::Request;
36use tonic::transport::Endpoint;
37
38use crate::pb::lifecycle::{DriverRequest, DriverResponse};
39use crate::pb::pilot::{CapabilityCall, CapabilityCallResult};
40use crate::plan_runtime::PlanRuntime;
41use robonix_atlas::client::AtlasClient;
42use robonix_atlas::pb as atlas_pb;
43use robonix_scribe::{debug, info};
44
45const CMD_ACTIVATE: u32 = 1;
46const DRIVER_ACTIVATE_TIMEOUT: Duration = Duration::from_secs(60);
47const DEPLOY_CONSUMER_ID: &str = "com.robonix.executor.skill_activate";
48
49static ACTIVATED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
50
51fn mark_activated(provider_id: &str) -> bool {
52 let mut g = ACTIVATED.lock().expect("ACTIVATED poisoned");
53 let set = g.get_or_insert_with(HashSet::new);
54 set.insert(provider_id.to_string())
55}
56
57fn already_activated(provider_id: &str) -> bool {
58 let g = ACTIVATED.lock().expect("ACTIVATED poisoned");
59 g.as_ref().is_some_and(|s| s.contains(provider_id))
60}
61
62fn is_skill_namespace(ns: &str) -> bool {
63 let mut parts = ns.split('/').filter(|p| !p.is_empty());
64 let first = parts.next();
65 let second = parts.next();
66 matches!(first, Some("robonix")) && matches!(second, Some("skill"))
67 || matches!(first, Some("skill"))
68}
69
70pub async fn dispatch(
77 call: &CapabilityCall,
78 self_provider_id: &str,
79 atlas: &mut AtlasClient,
80 runtime: &PlanRuntime,
81 plan_id: &str,
82) -> CapabilityCallResult {
83 dispatch_with_timeout(call, self_provider_id, atlas, runtime, plan_id, None).await
84}
85
86pub async fn dispatch_with_timeout(
90 call: &CapabilityCall,
91 self_provider_id: &str,
92 atlas: &mut AtlasClient,
93 runtime: &PlanRuntime,
94 plan_id: &str,
95 timeout: Option<Duration>,
96) -> CapabilityCallResult {
97 if call.provider_id == self_provider_id {
98 return builtin::execute(call, runtime, self_provider_id, atlas, plan_id).await;
99 }
100
101 if let Err(e) = ensure_skill_active(atlas, &call.provider_id).await {
102 return error_result(call, format!("Driver(CMD_ACTIVATE) failed: {e:#}"));
103 }
104
105 let (channel_id, endpoint, _params) = match atlas
106 .connect_capability(
107 self_provider_id,
108 &call.provider_id,
109 &call.contract_id,
110 atlas_pb::Transport::Mcp,
111 )
112 .await
113 {
114 Ok(triple) => triple,
115 Err(e) => {
116 return error_result(call, format!("ConnectCapability failed: {e:#}"));
117 }
118 };
119
120 let result = match timeout {
121 Some(duration) => match tokio::time::timeout(duration, mcp::execute(call, &endpoint)).await
122 {
123 Ok(result) => result,
124 Err(_) => error_result(
125 call,
126 format!("MCP call timed out after {}s", duration.as_secs()),
127 ),
128 },
129 None => mcp::execute(call, &endpoint).await,
130 };
131
132 let _ = atlas.disconnect_capability(&channel_id).await;
133 result
134}
135
136async fn ensure_skill_active(atlas: &mut AtlasClient, provider_id: &str) -> Result<()> {
142 if already_activated(provider_id) {
143 debug!("[skill-activate] {provider_id}: already activated, skipping CMD_ACTIVATE");
144 return Ok(());
145 }
146 let providers = atlas
147 .query_capabilities(provider_id, "", atlas_pb::Transport::Unspecified)
148 .await
149 .with_context(|| format!("query_capabilities({provider_id})"))?;
150 let Some(provider) = providers.into_iter().next() else {
151 info!(
152 "[skill-activate] {provider_id}: not in atlas, letting connect_capability surface the error"
153 );
154 return Ok(());
155 };
156 if !is_skill_namespace(&provider.namespace) {
157 debug!(
158 "[skill-activate] {provider_id} (ns={}): not a skill, no CMD_ACTIVATE",
159 provider.namespace
160 );
161 return Ok(());
162 }
163 if provider.state == atlas_pb::LifecycleState::StateActive as i32 {
164 info!("[skill-activate] {provider_id}: already ACTIVE per atlas, marking sticky");
165 mark_activated(provider_id);
166 return Ok(());
167 }
168 if provider.state != atlas_pb::LifecycleState::StateInactive as i32 {
169 let state = lifecycle_state_label(provider.state);
170 anyhow::bail!(
171 "skill {} is {}; automatic activation is only valid from INACTIVE. The executor \
172 will not repeat CMD_ACTIVATE after an activation error; recover or restart the \
173 provider lifecycle first",
174 provider_id,
175 state
176 );
177 }
178 info!(
179 "[skill-activate] {provider_id} (ns={}, state={}): sending Driver(CMD_ACTIVATE)",
180 provider.namespace, provider.state
181 );
182 let driver_contract = provider
183 .capabilities
184 .iter()
185 .find(|c| c.contract_id.ends_with("/driver"))
186 .map(|c| c.contract_id.clone())
187 .ok_or_else(|| anyhow::anyhow!("skill {provider_id} has no */driver capability"))?;
188 let svc_name = contract_id_to_service_name(&driver_contract);
189 let (channel_id, endpoint, _) = atlas
190 .connect_capability(
191 DEPLOY_CONSUMER_ID,
192 provider_id,
193 &driver_contract,
194 atlas_pb::Transport::Grpc,
195 )
196 .await
197 .with_context(|| format!("ConnectCapability({driver_contract})"))?;
198 let normalized = if endpoint.starts_with("http") {
199 endpoint
200 } else {
201 format!("http://{endpoint}")
202 };
203 let result = async {
204 let channel = Endpoint::new(normalized.clone())
205 .with_context(|| format!("invalid driver endpoint '{normalized}'"))?
206 .connect()
207 .await
208 .with_context(|| format!("dial driver at '{normalized}'"))?;
209 let path: tonic::codegen::http::uri::PathAndQuery =
210 format!("/robonix.contracts.{svc_name}/Driver")
211 .parse()
212 .with_context(|| format!("build gRPC path for '{driver_contract}'"))?;
213 let mut grpc = tonic::client::Grpc::new(channel);
214 grpc.ready().await.with_context(|| "gRPC ready")?;
215 let codec: tonic_prost::ProstCodec<DriverRequest, DriverResponse> = Default::default();
216 let resp = tokio::time::timeout(
217 DRIVER_ACTIVATE_TIMEOUT,
218 grpc.unary(
219 Request::new(DriverRequest {
220 command: CMD_ACTIVATE,
221 config_json: String::new(),
222 }),
223 path,
224 codec,
225 ),
226 )
227 .await
228 .map_err(|_| {
229 anyhow::anyhow!("Driver(CMD_ACTIVATE) timed out after {DRIVER_ACTIVATE_TIMEOUT:?}")
230 })?
231 .with_context(|| "Driver(CMD_ACTIVATE) RPC failed")?;
232 Ok::<_, anyhow::Error>(resp.into_inner())
233 }
234 .await;
235 let _ = atlas.disconnect_capability(&channel_id).await;
236 let r = result?;
237 if !r.ok {
238 anyhow::bail!(
239 "Driver(CMD_ACTIVATE) returned ok=false (state={}, error={})",
240 r.state,
241 r.error
242 );
243 }
244 mark_activated(provider_id);
245 Ok(())
246}
247
248fn lifecycle_state_label(state: i32) -> &'static str {
249 if state == atlas_pb::LifecycleState::StateRegistered as i32 {
250 "REGISTERED"
251 } else if state == atlas_pb::LifecycleState::StateInactive as i32 {
252 "INACTIVE"
253 } else if state == atlas_pb::LifecycleState::StateActive as i32 {
254 "ACTIVE"
255 } else if state == atlas_pb::LifecycleState::StateError as i32 {
256 "ERROR"
257 } else if state == atlas_pb::LifecycleState::StateTerminated as i32 {
258 "TERMINATED"
259 } else {
260 "UNSPECIFIED"
261 }
262}
263
264fn contract_id_to_service_name(id: &str) -> String {
268 id.split('/')
269 .filter(|x| !x.is_empty())
270 .map(|seg| {
271 seg.split('_')
272 .filter(|p| !p.is_empty())
273 .map(|p| {
274 let mut c = p.chars();
275 match c.next() {
276 Some(f) => f
277 .to_uppercase()
278 .chain(c.flat_map(char::to_lowercase))
279 .collect::<String>(),
280 None => String::new(),
281 }
282 })
283 .collect::<String>()
284 })
285 .collect()
286}
287
288pub(crate) fn error_result(call: &CapabilityCall, msg: String) -> CapabilityCallResult {
289 CapabilityCallResult {
290 call_id: call.call_id.clone(),
291 provider_id: call.provider_id.clone(),
292 contract_id: call.contract_id.clone(),
293 success: false,
294 output: String::new(),
295 error: msg,
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::lifecycle_state_label;
302 use robonix_atlas::pb::LifecycleState;
303
304 #[test]
305 fn lifecycle_state_labels_are_actionable() {
306 assert_eq!(
307 lifecycle_state_label(LifecycleState::StateInactive as i32),
308 "INACTIVE"
309 );
310 assert_eq!(
311 lifecycle_state_label(LifecycleState::StateError as i32),
312 "ERROR"
313 );
314 assert_eq!(
315 lifecycle_state_label(LifecycleState::StateTerminated as i32),
316 "TERMINATED"
317 );
318 }
319}