1mod config;
16mod dispatch;
17mod pb;
18mod plan_runtime;
19mod rtdl_wire;
20mod service;
21
22use anyhow::{Context, Result};
23use clap::Parser;
24use config::{Args, EXECUTOR_NAMESPACE, ExecutorConfig};
25use dispatch::builtin::BUILTINS;
26use pb::contracts::robonix_lifecycle_driver_client::RobonixLifecycleDriverClient;
27use pb::contracts::robonix_lifecycle_driver_server::{
28 RobonixLifecycleDriver, RobonixLifecycleDriverServer,
29};
30use pb::contracts::robonix_system_executor_cancel_all_plans_server::RobonixSystemExecutorCancelAllPlansServer;
31use pb::contracts::robonix_system_executor_control_plan_server::RobonixSystemExecutorControlPlanServer;
32use pb::contracts::robonix_system_executor_execute_server::RobonixSystemExecutorExecuteServer;
33use pb::contracts::robonix_system_executor_get_health_server::RobonixSystemExecutorGetHealthServer;
34use pb::contracts::robonix_system_executor_list_active_plans_server::RobonixSystemExecutorListActivePlansServer;
35use pb::lifecycle::{DriverRequest, DriverResponse};
36use robonix_atlas::client::{self as atlas_client, AtlasClient};
37use robonix_atlas::pb as atlas_pb;
38use robonix_scribe::{info, warn};
39use service::ExecutorServiceImpl;
40use std::time::Duration;
41use tonic::{Request, Response, Status};
42
43const SHARED_DRIVER_CONTRACT: &str = "robonix/lifecycle/driver";
44const CMD_INIT: u32 = 0;
45const CMD_ACTIVATE: u32 = 1;
46const CMD_DEACTIVATE: u32 = 2;
47const CMD_SHUTDOWN: u32 = 3;
48
49#[derive(Clone)]
50struct SystemLifecycleDriver {
51 atlas: AtlasClient,
52 provider_id: String,
53 shutdown_tx: tokio::sync::watch::Sender<bool>,
54}
55
56impl SystemLifecycleDriver {
57 fn new(atlas: AtlasClient, provider_id: String) -> Self {
58 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
59 Self {
60 atlas,
61 provider_id,
62 shutdown_tx,
63 }
64 }
65
66 async fn transition(&self, command: u32) -> Result<&'static str> {
69 let (state, label) = lifecycle_target(command)
70 .ok_or_else(|| anyhow::anyhow!("unknown lifecycle command code {command}"))?;
71 let mut atlas = self.atlas.clone();
72 atlas
73 .set_lifecycle_state(&self.provider_id, state, "")
74 .await
75 .with_context(|| format!("publish lifecycle state for '{}'", self.provider_id))?;
76 if command == CMD_SHUTDOWN {
77 self.shutdown_tx.send_replace(true);
78 }
79 Ok(label)
80 }
81
82 fn subscribe_shutdown(&self) -> tokio::sync::watch::Receiver<bool> {
83 self.shutdown_tx.subscribe()
84 }
85}
86
87#[tonic::async_trait]
88impl RobonixLifecycleDriver for SystemLifecycleDriver {
89 async fn driver(
92 &self,
93 request: Request<DriverRequest>,
94 ) -> std::result::Result<Response<DriverResponse>, Status> {
95 let response = match self.transition(request.into_inner().command).await {
96 Ok(state) => DriverResponse {
97 ok: true,
98 state: state.to_string(),
99 error: String::new(),
100 },
101 Err(error) => DriverResponse {
102 ok: false,
103 state: "error".to_string(),
104 error: format!("{error:#}"),
105 },
106 };
107 Ok(Response::new(response))
108 }
109}
110
111fn lifecycle_target(command: u32) -> Option<(atlas_pb::LifecycleState, &'static str)> {
112 match command {
113 CMD_INIT => Some((atlas_pb::LifecycleState::StateInactive, "inactive")),
114 CMD_ACTIVATE => Some((atlas_pb::LifecycleState::StateActive, "active")),
115 CMD_DEACTIVATE => Some((atlas_pb::LifecycleState::StateInactive, "inactive")),
116 CMD_SHUTDOWN => Some((atlas_pb::LifecycleState::StateTerminated, "terminated")),
117 _ => None,
118 }
119}
120
121async fn wait_for_driver_shutdown(mut shutdown: tokio::sync::watch::Receiver<bool>) {
124 if *shutdown.borrow() {
125 return;
126 }
127 while shutdown.changed().await.is_ok() {
128 if *shutdown.borrow() {
129 return;
130 }
131 }
132}
133
134fn startup_driver_endpoint(listen_addr: std::net::SocketAddr) -> String {
137 let ip = match listen_addr.ip() {
138 std::net::IpAddr::V4(ip) if ip.is_unspecified() => {
139 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
140 }
141 std::net::IpAddr::V6(ip) if ip.is_unspecified() => {
142 std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
143 }
144 ip => ip,
145 };
146 std::net::SocketAddr::new(ip, listen_addr.port()).to_string()
147}
148
149async fn connect_startup_driver(
152 endpoint: &str,
153) -> Result<RobonixLifecycleDriverClient<tonic::transport::Channel>> {
154 let endpoint = if endpoint.starts_with("http") {
155 endpoint.to_string()
156 } else {
157 format!("http://{endpoint}")
158 };
159 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
160 loop {
161 match RobonixLifecycleDriverClient::connect(endpoint.clone()).await {
162 Ok(client) => return Ok(client),
163 Err(_) if tokio::time::Instant::now() < deadline => {
164 tokio::time::sleep(Duration::from_millis(25)).await;
165 }
166 Err(error) => return Err(error).context("connect startup lifecycle Driver"),
167 }
168 }
169}
170
171async fn call_startup_driver(
174 client: &mut RobonixLifecycleDriverClient<tonic::transport::Channel>,
175 command: u32,
176) -> Result<String> {
177 let response = client
178 .driver(DriverRequest {
179 command,
180 config_json: "{}".to_string(),
181 })
182 .await
183 .context("call startup lifecycle Driver")?
184 .into_inner();
185 if !response.ok {
186 anyhow::bail!("startup lifecycle Driver failed: {}", response.error);
187 }
188 Ok(response.state)
189}
190
191#[tokio::main]
192async fn main() -> Result<()> {
193 let parsed = Args::parse();
194 robonix_scribe::init_from_config("executor", parsed.config_json.as_deref());
197 info!("robonix-executor starting");
198
199 let cfg = ExecutorConfig::resolve(parsed)?;
200
201 info!("connecting to atlas at {}", cfg.atlas_endpoint);
202 let mut atlas =
203 AtlasClient::connect_with_retry(&cfg.atlas_endpoint, 10, Duration::from_secs(2))
204 .await
205 .context("connect to atlas")?;
206
207 atlas
208 .register_service(&cfg.id, EXECUTOR_NAMESPACE, "")
209 .await?;
210 info!("registered as '{}' under '{EXECUTOR_NAMESPACE}'", cfg.id);
211
212 let listen_addr: std::net::SocketAddr = cfg
213 .listen
214 .parse()
215 .with_context(|| format!("invalid executor listen address '{}'", cfg.listen))?;
216 let advertised = startup_driver_endpoint(listen_addr);
217
218 atlas
219 .declare_capability(
220 &cfg.id,
221 SHARED_DRIVER_CONTRACT,
222 atlas_pb::Transport::Grpc,
223 &advertised,
224 atlas_client::grpc_params(
225 "capabilities/lifecycle/driver.v1.toml",
226 "robonix.contracts.RobonixLifecycleDriver",
227 "/robonix.contracts.RobonixLifecycleDriver/Driver",
228 ),
229 )
230 .await
231 .context("declare executor shared lifecycle Driver")?;
232 let lifecycle = SystemLifecycleDriver::new(atlas.clone(), cfg.id.clone());
233
234 atlas
236 .declare_capability(
237 &cfg.id,
238 "robonix/system/executor/execute",
239 atlas_pb::Transport::Grpc,
240 &advertised,
241 atlas_client::grpc_params(
242 "capabilities/system/executor/execute.v1.toml",
243 "robonix.contracts.RobonixSystemExecutorExecute",
244 "/robonix.contracts.RobonixSystemExecutorExecute/Execute",
245 ),
246 )
247 .await?;
248
249 atlas
252 .declare_capability(
253 &cfg.id,
254 "robonix/system/executor/get_health",
255 atlas_pb::Transport::Grpc,
256 &advertised,
257 atlas_client::grpc_params(
258 "capabilities/system/executor/get_health.toml",
259 "robonix.contracts.RobonixSystemExecutorGetHealth",
260 "/robonix.contracts.RobonixSystemExecutorGetHealth/GetModuleHealth",
261 ),
262 )
263 .await?;
264
265 atlas
268 .declare_capability(
269 &cfg.id,
270 "robonix/system/executor/control_plan",
271 atlas_pb::Transport::Grpc,
272 &advertised,
273 atlas_client::grpc_params(
274 "capabilities/system/executor/control_plan.v1.toml",
275 "robonix.contracts.RobonixSystemExecutorControlPlan",
276 "/robonix.contracts.RobonixSystemExecutorControlPlan/ControlPlan",
277 ),
278 )
279 .await?;
280
281 atlas
284 .declare_capability(
285 &cfg.id,
286 "robonix/system/executor/list_active_plans",
287 atlas_pb::Transport::Grpc,
288 &advertised,
289 atlas_client::grpc_params(
290 "capabilities/system/executor/list_active_plans.v1.toml",
291 "robonix.contracts.RobonixSystemExecutorListActivePlans",
292 "/robonix.contracts.RobonixSystemExecutorListActivePlans/ListActivePlans",
293 ),
294 )
295 .await?;
296
297 atlas
299 .declare_capability(
300 &cfg.id,
301 "robonix/system/executor/cancel_all_plans",
302 atlas_pb::Transport::Grpc,
303 &advertised,
304 atlas_client::grpc_params(
305 "capabilities/system/executor/cancel_all_plans.v1.toml",
306 "robonix.contracts.RobonixSystemExecutorCancelAllPlans",
307 "/robonix.contracts.RobonixSystemExecutorCancelAllPlans/CancelAll",
308 ),
309 )
310 .await?;
311
312 let builtin_endpoint = format!("internal://{}/builtin", cfg.id);
317 for spec in BUILTINS {
318 let contract_id = format!("{EXECUTOR_NAMESPACE}/builtin/{}", spec.op);
319 atlas
320 .declare_capability_with_description(
321 &cfg.id,
322 &contract_id,
323 atlas_pb::Transport::Mcp,
324 &builtin_endpoint,
325 atlas_client::mcp_params(spec.input_schema_json),
326 spec.description,
327 )
328 .await
329 .with_context(|| format!("declare builtin '{}'", contract_id))?;
330 }
331 info!(
332 "declared executor gRPC capabilities + {} builtin capabilities at {advertised}",
333 BUILTINS.len()
334 );
335
336 let svc = ExecutorServiceImpl::new(atlas.clone(), cfg.id.clone());
337 let server_shutdown = lifecycle.subscribe_shutdown();
338 let server_lifecycle = lifecycle.clone();
339 let mut server_task = tokio::spawn(async move {
340 tonic::transport::Server::builder()
341 .add_service(RobonixLifecycleDriverServer::new(server_lifecycle))
342 .add_service(RobonixSystemExecutorExecuteServer::new(svc.clone()))
343 .add_service(RobonixSystemExecutorCancelAllPlansServer::new(svc.clone()))
344 .add_service(RobonixSystemExecutorControlPlanServer::new(svc.clone()))
345 .add_service(RobonixSystemExecutorListActivePlansServer::new(svc.clone()))
346 .add_service(RobonixSystemExecutorGetHealthServer::new(svc))
347 .serve_with_shutdown(listen_addr, wait_for_driver_shutdown(server_shutdown))
348 .await
349 });
350 let startup_endpoint = startup_driver_endpoint(listen_addr);
351 let mut startup_driver = tokio::select! {
352 client = connect_startup_driver(&startup_endpoint) => client?,
353 result = &mut server_task => {
354 result.context("join executor gRPC server")?
355 .context("executor gRPC server failed before readiness")?;
356 anyhow::bail!("executor gRPC server stopped before readiness");
357 }
358 };
359 call_startup_driver(&mut startup_driver, CMD_INIT)
360 .await
361 .context("initialize executor lifecycle")?;
362 call_startup_driver(&mut startup_driver, CMD_ACTIVATE)
363 .await
364 .context("activate executor lifecycle")?;
365 drop(startup_driver);
366
367 {
370 let mut hb = atlas.clone();
371 let provider_id = cfg.id.clone();
372 let shutdown = lifecycle.subscribe_shutdown();
373 tokio::spawn(async move {
374 let mut tick = tokio::time::interval(Duration::from_secs(20));
375 tick.tick().await;
376 let shutdown = wait_for_driver_shutdown(shutdown);
377 tokio::pin!(shutdown);
378 loop {
379 tokio::select! {
380 _ = &mut shutdown => break,
381 _ = tick.tick() => {
382 if let Err(e) = hb.heartbeat(&provider_id).await {
383 warn!("heartbeat failed: {e:#}");
384 }
385 }
386 }
387 }
388 });
389 }
390
391 info!("executor gRPC on {listen_addr}");
392 info!("robonix-executor ready on {listen_addr}");
393
394 server_task
395 .await
396 .context("join executor gRPC server")?
397 .context("executor gRPC server failed")?;
398
399 Ok(())
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use pb::contracts::robonix_lifecycle_driver_client::RobonixLifecycleDriverClient;
406 use robonix_atlas::service::{AtlasRegistry, serve_atlas};
407 use std::net::SocketAddr;
408 use std::sync::Arc;
409
410 fn reserve_address() -> SocketAddr {
411 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve port");
412 listener.local_addr().expect("reserved address")
413 }
414
415 #[test]
416 fn startup_dial_rewrites_unspecified_addresses() {
417 assert_eq!(
418 startup_driver_endpoint("0.0.0.0:51001".parse().unwrap()),
419 "127.0.0.1:51001"
420 );
421 assert_eq!(
422 startup_driver_endpoint("[::]:51001".parse().unwrap()),
423 "[::1]:51001"
424 );
425 }
426
427 async fn connect_driver(
429 endpoint: String,
430 ) -> RobonixLifecycleDriverClient<tonic::transport::Channel> {
431 let mut last_error = None;
432 for _ in 0..50 {
433 match RobonixLifecycleDriverClient::connect(endpoint.clone()).await {
434 Ok(client) => return client,
435 Err(error) => last_error = Some(error),
436 }
437 tokio::time::sleep(Duration::from_millis(10)).await;
438 }
439 panic!(
440 "connect Driver: {:#}",
441 last_error.expect("connection error")
442 );
443 }
444
445 #[tokio::test]
448 async fn shared_driver_rpc_is_callable() {
449 let atlas_addr = reserve_address();
450 let registry = Arc::new(AtlasRegistry::default());
451 let atlas_server = tokio::spawn(serve_atlas(Arc::clone(®istry), atlas_addr));
452 let mut atlas = AtlasClient::connect_with_retry(
453 format!("http://{atlas_addr}"),
454 50,
455 Duration::from_millis(10),
456 )
457 .await
458 .expect("connect test Atlas");
459 let provider_id = "executor-driver-rpc-test";
460 atlas
461 .register_service(provider_id, EXECUTOR_NAMESPACE, "")
462 .await
463 .expect("register test Provider");
464
465 let driver_addr = reserve_address();
466 atlas
467 .declare_capability(
468 provider_id,
469 SHARED_DRIVER_CONTRACT,
470 atlas_pb::Transport::Grpc,
471 &driver_addr.to_string(),
472 atlas_client::grpc_params(
473 "capabilities/lifecycle/driver.v1.toml",
474 "robonix.contracts.RobonixLifecycleDriver",
475 "/robonix.contracts.RobonixLifecycleDriver/Driver",
476 ),
477 )
478 .await
479 .expect("declare shared Driver");
480 let lifecycle = SystemLifecycleDriver::new(atlas.clone(), provider_id.to_string());
481 let server_shutdown = lifecycle.subscribe_shutdown();
482 let driver_server = tokio::spawn(
483 tonic::transport::Server::builder()
484 .add_service(RobonixLifecycleDriverServer::new(lifecycle))
485 .serve_with_shutdown(driver_addr, wait_for_driver_shutdown(server_shutdown)),
486 );
487 let mut client = connect_driver(format!("http://{driver_addr}")).await;
488
489 let init = client
490 .driver(DriverRequest {
491 command: CMD_INIT,
492 config_json: "{}".to_string(),
493 })
494 .await
495 .expect("Driver INIT RPC")
496 .into_inner();
497 assert!(init.ok, "{}", init.error);
498 assert_eq!(init.state, "inactive");
499 let activate = client
500 .driver(DriverRequest {
501 command: CMD_ACTIVATE,
502 config_json: String::new(),
503 })
504 .await
505 .expect("Driver ACTIVATE RPC")
506 .into_inner();
507 assert!(activate.ok, "{}", activate.error);
508 assert_eq!(activate.state, "active");
509
510 let shutdown = client
511 .driver(DriverRequest {
512 command: CMD_SHUTDOWN,
513 config_json: String::new(),
514 })
515 .await
516 .expect("Driver SHUTDOWN RPC")
517 .into_inner();
518 assert!(shutdown.ok, "{}", shutdown.error);
519 assert_eq!(shutdown.state, "terminated");
520 tokio::time::timeout(Duration::from_secs(2), driver_server)
521 .await
522 .expect("Driver server did not stop after SHUTDOWN")
523 .expect("join Driver server")
524 .expect("graceful Driver server shutdown");
525
526 let provider = atlas
527 .query(
528 atlas_pb::Kind::Service,
529 provider_id,
530 "",
531 "",
532 atlas_pb::Transport::Unspecified,
533 )
534 .await
535 .expect("query test Provider")
536 .pop()
537 .expect("test Provider");
538 assert_eq!(
539 provider.state,
540 atlas_pb::LifecycleState::StateTerminated as i32
541 );
542
543 atlas_server.abort();
544 }
545}