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 if call.provider_id == self_provider_id {
84 return builtin::execute(call, runtime, self_provider_id, atlas, plan_id).await;
85 }
86
87 if let Err(e) = ensure_skill_active(atlas, &call.provider_id).await {
88 return error_result(call, format!("Driver(CMD_ACTIVATE) failed: {e:#}"));
89 }
90
91 let (channel_id, endpoint, _params) = match atlas
92 .connect_capability(
93 self_provider_id,
94 &call.provider_id,
95 &call.contract_id,
96 atlas_pb::Transport::Mcp,
97 )
98 .await
99 {
100 Ok(triple) => triple,
101 Err(e) => {
102 return error_result(call, format!("ConnectCapability failed: {e:#}"));
103 }
104 };
105
106 let result = mcp::execute(call, &endpoint).await;
107
108 let _ = atlas.disconnect_capability(&channel_id).await;
109 result
110}
111
112async fn ensure_skill_active(atlas: &mut AtlasClient, provider_id: &str) -> Result<()> {
118 if already_activated(provider_id) {
119 debug!("[skill-activate] {provider_id}: already activated, skipping CMD_ACTIVATE");
120 return Ok(());
121 }
122 let providers = atlas
123 .query_capabilities(provider_id, "", atlas_pb::Transport::Unspecified)
124 .await
125 .with_context(|| format!("query_capabilities({provider_id})"))?;
126 let Some(provider) = providers.into_iter().next() else {
127 info!(
128 "[skill-activate] {provider_id}: not in atlas, letting connect_capability surface the error"
129 );
130 return Ok(());
131 };
132 if !is_skill_namespace(&provider.namespace) {
133 debug!(
134 "[skill-activate] {provider_id} (ns={}): not a skill, no CMD_ACTIVATE",
135 provider.namespace
136 );
137 return Ok(());
138 }
139 if provider.state == atlas_pb::LifecycleState::StateActive as i32 {
140 info!("[skill-activate] {provider_id}: already ACTIVE per atlas, marking sticky");
141 mark_activated(provider_id);
142 return Ok(());
143 }
144 if provider.state != atlas_pb::LifecycleState::StateInactive as i32 {
145 let state = lifecycle_state_label(provider.state);
146 anyhow::bail!(
147 "skill {} is {}; automatic activation is only valid from INACTIVE. The executor \
148 will not repeat CMD_ACTIVATE after an activation error; recover or restart the \
149 provider lifecycle first",
150 provider_id,
151 state
152 );
153 }
154 info!(
155 "[skill-activate] {provider_id} (ns={}, state={}): sending Driver(CMD_ACTIVATE)",
156 provider.namespace, provider.state
157 );
158 let driver_contract = provider
159 .capabilities
160 .iter()
161 .find(|c| c.contract_id.ends_with("/driver"))
162 .map(|c| c.contract_id.clone())
163 .ok_or_else(|| anyhow::anyhow!("skill {provider_id} has no */driver capability"))?;
164 let svc_name = contract_id_to_service_name(&driver_contract);
165 let (channel_id, endpoint, _) = atlas
166 .connect_capability(
167 DEPLOY_CONSUMER_ID,
168 provider_id,
169 &driver_contract,
170 atlas_pb::Transport::Grpc,
171 )
172 .await
173 .with_context(|| format!("ConnectCapability({driver_contract})"))?;
174 let normalized = if endpoint.starts_with("http") {
175 endpoint
176 } else {
177 format!("http://{endpoint}")
178 };
179 let result = async {
180 let channel = Endpoint::new(normalized.clone())
181 .with_context(|| format!("invalid driver endpoint '{normalized}'"))?
182 .connect()
183 .await
184 .with_context(|| format!("dial driver at '{normalized}'"))?;
185 let path: tonic::codegen::http::uri::PathAndQuery =
186 format!("/robonix.contracts.{svc_name}/Driver")
187 .parse()
188 .with_context(|| format!("build gRPC path for '{driver_contract}'"))?;
189 let mut grpc = tonic::client::Grpc::new(channel);
190 grpc.ready().await.with_context(|| "gRPC ready")?;
191 let codec: tonic_prost::ProstCodec<DriverRequest, DriverResponse> = Default::default();
192 let resp = tokio::time::timeout(
193 DRIVER_ACTIVATE_TIMEOUT,
194 grpc.unary(
195 Request::new(DriverRequest {
196 command: CMD_ACTIVATE,
197 config_json: String::new(),
198 }),
199 path,
200 codec,
201 ),
202 )
203 .await
204 .map_err(|_| {
205 anyhow::anyhow!("Driver(CMD_ACTIVATE) timed out after {DRIVER_ACTIVATE_TIMEOUT:?}")
206 })?
207 .with_context(|| "Driver(CMD_ACTIVATE) RPC failed")?;
208 Ok::<_, anyhow::Error>(resp.into_inner())
209 }
210 .await;
211 let _ = atlas.disconnect_capability(&channel_id).await;
212 let r = result?;
213 if !r.ok {
214 anyhow::bail!(
215 "Driver(CMD_ACTIVATE) returned ok=false (state={}, error={})",
216 r.state,
217 r.error
218 );
219 }
220 mark_activated(provider_id);
221 Ok(())
222}
223
224fn lifecycle_state_label(state: i32) -> &'static str {
225 if state == atlas_pb::LifecycleState::StateRegistered as i32 {
226 "REGISTERED"
227 } else if state == atlas_pb::LifecycleState::StateInactive as i32 {
228 "INACTIVE"
229 } else if state == atlas_pb::LifecycleState::StateActive as i32 {
230 "ACTIVE"
231 } else if state == atlas_pb::LifecycleState::StateError as i32 {
232 "ERROR"
233 } else if state == atlas_pb::LifecycleState::StateTerminated as i32 {
234 "TERMINATED"
235 } else {
236 "UNSPECIFIED"
237 }
238}
239
240fn contract_id_to_service_name(id: &str) -> String {
244 id.split('/')
245 .filter(|x| !x.is_empty())
246 .map(|seg| {
247 seg.split('_')
248 .filter(|p| !p.is_empty())
249 .map(|p| {
250 let mut c = p.chars();
251 match c.next() {
252 Some(f) => f
253 .to_uppercase()
254 .chain(c.flat_map(char::to_lowercase))
255 .collect::<String>(),
256 None => String::new(),
257 }
258 })
259 .collect::<String>()
260 })
261 .collect()
262}
263
264pub(crate) fn error_result(call: &CapabilityCall, msg: String) -> CapabilityCallResult {
265 CapabilityCallResult {
266 call_id: call.call_id.clone(),
267 provider_id: call.provider_id.clone(),
268 contract_id: call.contract_id.clone(),
269 success: false,
270 output: String::new(),
271 error: msg,
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::lifecycle_state_label;
278 use robonix_atlas::pb::LifecycleState;
279
280 #[test]
281 fn lifecycle_state_labels_are_actionable() {
282 assert_eq!(
283 lifecycle_state_label(LifecycleState::StateInactive as i32),
284 "INACTIVE"
285 );
286 assert_eq!(
287 lifecycle_state_label(LifecycleState::StateError as i32),
288 "ERROR"
289 );
290 assert_eq!(
291 lifecycle_state_label(LifecycleState::StateTerminated as i32),
292 "TERMINATED"
293 );
294 }
295}