1use crate::dispatch::{async_poll, async_registry};
7use crate::pb::contracts::robonix_system_executor_cancel_all_plans_server::RobonixSystemExecutorCancelAllPlans;
8use crate::pb::contracts::robonix_system_executor_control_plan_server::RobonixSystemExecutorControlPlan;
9use crate::pb::contracts::robonix_system_executor_execute_server::RobonixSystemExecutorExecute;
10use crate::pb::contracts::robonix_system_executor_get_health_server::RobonixSystemExecutorGetHealth;
11use crate::pb::contracts::robonix_system_executor_list_active_plans_server::RobonixSystemExecutorListActivePlans;
12use crate::pb::executor::{
13 CancelAllResponse, ControlPlanResponse, ListActivePlansResponse, RtdlEvent,
14};
15use crate::pb::module_health::{
16 GetModuleHealthRequest, GetModuleHealthResponse, ModuleHealth, ModuleHealthReport,
17};
18use crate::pb::pilot::rtdl_node_state::RtdlNodeStateEnum;
19use crate::pb::pilot::{CapabilityCall, CapabilityCallResult, Plan};
20use crate::plan_runtime::{PlanRuntime, StopWhen};
21use crate::rtdl_wire::{self, NodeEventContext};
22use crate::verification::{self, VerificationPolicy};
23use robonix_atlas::client::AtlasClient;
24use robonix_scribe::{info, warn};
25use std::collections::HashSet;
26use std::future::Future;
27use std::pin::Pin;
28use std::sync::Arc;
29use tokio::sync::mpsc::Sender;
30use tokio_stream::wrappers::ReceiverStream;
31use tonic::{Request, Response, Status};
32
33const RTDL_SEQUENCE: u32 = 0;
34const RTDL_PARALLEL: u32 = 1;
35const RTDL_DO: u32 = 2;
36const MODULE_HEALTH_SCHEMA_VERSION: u32 = 1;
37const MODULE_HEALTH_OK: u32 = 0;
38const MODULE_HEALTH_TTL_MS: u32 = 5000;
39
40#[derive(Clone)]
43pub struct ExecutorServiceImpl {
44 atlas: AtlasClient,
45 provider_id: String,
52 runtime: PlanRuntime,
53 verification: Arc<VerificationPolicy>,
54}
55
56impl ExecutorServiceImpl {
57 pub fn new(
58 atlas: AtlasClient,
59 provider_id: String,
60 verification: Arc<VerificationPolicy>,
61 ) -> Self {
62 Self {
63 atlas,
64 provider_id,
65 runtime: PlanRuntime::default(),
66 verification,
67 }
68 }
69}
70
71#[tonic::async_trait]
72impl RobonixSystemExecutorExecute for ExecutorServiceImpl {
73 type ExecuteStream = ReceiverStream<Result<RtdlEvent, Status>>;
74
75 async fn execute(
76 &self,
77 request: Request<Plan>,
78 ) -> Result<Response<Self::ExecuteStream>, Status> {
79 let plan = request.into_inner();
80 validate_plan(&plan).map_err(Status::invalid_argument)?;
81 let (tx, rx) = tokio::sync::mpsc::channel(64);
82 let atlas = self.atlas.clone();
83 let provider_id = self.provider_id.clone();
84 let runtime = self.runtime.clone();
85 let verification = Arc::clone(&self.verification);
86
87 tokio::spawn(async move {
88 let plan_id = plan.plan_id.clone();
89 let plan = Arc::new(plan);
90 runtime.register_plan(&plan_id).await;
91 runtime.record_plan_ops(&plan).await;
92 let _ = tx.send(Ok(rtdl_wire::plan_started(plan_id.clone()))).await;
93 let any_failed = execute_node(
94 Arc::clone(&plan),
95 plan.root_index as usize,
96 tx.clone(),
97 atlas,
98 provider_id,
99 runtime.clone(),
100 verification,
101 )
102 .await;
103 let cancelled = runtime.is_cancelled(&plan_id).await;
104 runtime.complete_plan(&plan_id).await;
105
106 let _ = tx
107 .send(Ok(rtdl_wire::plan_complete(
108 plan_id,
109 any_failed || cancelled,
110 )))
111 .await;
112 });
113
114 Ok(Response::new(ReceiverStream::new(rx)))
115 }
116}
117
118type ExecuteNodeFuture = Pin<Box<dyn Future<Output = bool> + Send + 'static>>;
119
120fn execute_node(
121 plan: Arc<Plan>,
122 node_index: usize,
123 tx: Sender<Result<RtdlEvent, Status>>,
124 atlas: AtlasClient,
125 provider_id: String,
126 runtime: PlanRuntime,
127 verification: Arc<VerificationPolicy>,
128) -> ExecuteNodeFuture {
129 Box::pin(async move {
130 let node = &plan.nodes[node_index];
131 let node_ctx = node_event_context(&plan, node_index);
132 let op_id = node.op_id.clone();
133 if runtime.is_cancelled(&plan.plan_id).await {
134 if is_operator_node(node.node_kind) {
135 send_operator_terminal(
136 &tx,
137 &node_ctx,
138 RtdlNodeStateEnum::Canceled as u32,
139 "canceled",
140 &runtime,
141 )
142 .await;
143 }
144 return true;
145 }
146 if runtime
147 .should_stop_at(&plan.plan_id, &op_id, StopWhen::OnEnter)
148 .await
149 {
150 let mut atlas = atlas;
151 runtime
152 .trigger_stop(&plan.plan_id, &provider_id, &mut atlas)
153 .await;
154 send_stop_on_enter(&tx, &node_ctx, &runtime).await;
155 return true;
156 }
157 let mut atlas_after = atlas.clone();
158 let failed = match node.node_kind {
159 RTDL_SEQUENCE => {
160 let mut any_failed = false;
161 let mut cancelled = false;
162 for child in &node.children {
163 if runtime.is_cancelled(&plan.plan_id).await {
164 cancelled = true;
165 any_failed = true;
166 break;
167 }
168 any_failed |= execute_node(
169 Arc::clone(&plan),
170 *child as usize,
171 tx.clone(),
172 atlas.clone(),
173 provider_id.clone(),
174 runtime.clone(),
175 Arc::clone(&verification),
176 )
177 .await;
178 if any_failed {
179 break;
180 }
181 }
182 let cancelled = cancelled || runtime.is_cancelled(&plan.plan_id).await;
183 let state = if cancelled {
184 RtdlNodeStateEnum::Canceled as u32
185 } else if any_failed {
186 RtdlNodeStateEnum::Failed as u32
187 } else {
188 RtdlNodeStateEnum::Succeeded as u32
189 };
190 let reason = if cancelled {
191 "canceled before remaining children could run"
192 } else if any_failed {
193 "failed because a child node failed"
194 } else {
195 "completed successfully"
196 };
197 send_operator_terminal(&tx, &node_ctx, state, reason, &runtime).await;
198 any_failed || cancelled
199 }
200 RTDL_PARALLEL => {
201 let mut handles = Vec::with_capacity(node.children.len());
202 for child in &node.children {
203 let child_plan = Arc::clone(&plan);
204 let child_tx = tx.clone();
205 let child_atlas = atlas.clone();
206 let child_provider_id = provider_id.clone();
207 let child_runtime = runtime.clone();
208 let child_verification = Arc::clone(&verification);
209 let child_index = *child as usize;
210 handles.push(tokio::spawn(async move {
211 execute_node(
212 child_plan,
213 child_index,
214 child_tx,
215 child_atlas,
216 child_provider_id,
217 child_runtime,
218 child_verification,
219 )
220 .await
221 }));
222 }
223 let mut any_failed = false;
224 for handle in handles {
225 match handle.await {
226 Ok(child_failed) => any_failed |= child_failed,
227 Err(e) => {
228 any_failed = true;
229 warn!("[executor] parallel branch task failed: {e}");
230 }
231 }
232 }
233 let cancelled = runtime.is_cancelled(&plan.plan_id).await;
234 let state = if cancelled {
235 RtdlNodeStateEnum::Canceled as u32
236 } else if any_failed {
237 RtdlNodeStateEnum::Failed as u32
238 } else {
239 RtdlNodeStateEnum::Succeeded as u32
240 };
241 let reason = if cancelled {
242 "canceled"
243 } else if any_failed {
244 "failed because one or more child nodes failed"
245 } else {
246 "completed successfully"
247 };
248 send_operator_terminal(&tx, &node_ctx, state, reason, &runtime).await;
249 any_failed || cancelled
250 }
251 RTDL_DO => {
252 let call = node
253 .call
254 .as_ref()
255 .expect("validated do node must contain call");
256 execute_call(
257 call,
258 node_ctx,
259 tx,
260 atlas,
261 provider_id.clone(),
262 runtime.clone(),
263 verification,
264 )
265 .await
266 }
267 _ => {
268 warn!(
269 "[executor] invalid node_kind={} reached after validation",
270 node.node_kind
271 );
272 true
273 }
274 };
275 if runtime
276 .should_stop_at(&plan.plan_id, &op_id, StopWhen::OnComplete)
277 .await
278 {
279 runtime
280 .trigger_stop(&plan.plan_id, &provider_id, &mut atlas_after)
281 .await;
282 }
283 failed
284 })
285}
286
287fn node_event_context(plan: &Plan, node_index: usize) -> NodeEventContext {
289 let node = &plan.nodes[node_index];
290 NodeEventContext {
291 plan_id: plan.plan_id.clone(),
292 node_index: node_index as u32,
293 node_kind: node.node_kind,
294 op_id: node.op_id.clone(),
295 description: node.description.clone(),
296 }
297}
298
299fn is_operator_node(node_kind: u32) -> bool {
301 matches!(node_kind, RTDL_SEQUENCE | RTDL_PARALLEL)
302}
303
304fn leaf_terminal_state(success: bool, cancelled: bool) -> u32 {
308 if cancelled {
309 RtdlNodeStateEnum::Canceled as u32
310 } else if success {
311 RtdlNodeStateEnum::Succeeded as u32
312 } else {
313 RtdlNodeStateEnum::Failed as u32
314 }
315}
316
317async fn send_stop_on_enter(
319 tx: &Sender<Result<RtdlEvent, Status>>,
320 node: &NodeEventContext,
321 runtime: &PlanRuntime,
322) {
323 let detail = format!("stopped on entering op_id={}: plan cancelled", node.op_id);
324 runtime
325 .record_op_state(
326 &node.plan_id,
327 &node.op_id,
328 RtdlNodeStateEnum::Canceled as u32,
329 )
330 .await;
331 if is_operator_node(node.node_kind) {
332 let _ = tx
333 .send(Ok(rtdl_wire::operator_node_state(
334 node,
335 RtdlNodeStateEnum::Canceled as u32,
336 detail,
337 )))
338 .await;
339 } else {
340 let _ = tx
341 .send(Ok(rtdl_wire::node_state(
342 node,
343 RtdlNodeStateEnum::Canceled as u32,
344 detail,
345 None,
346 )))
347 .await;
348 }
349}
350
351async fn send_operator_terminal(
354 tx: &Sender<Result<RtdlEvent, Status>>,
355 node: &NodeEventContext,
356 state: u32,
357 reason: &str,
358 runtime: &PlanRuntime,
359) {
360 let op = match node.node_kind {
361 RTDL_SEQUENCE => "sequence",
362 RTDL_PARALLEL => "parallel",
363 _ => "operator",
364 };
365 let detail = format!(
366 "RTDL {op} op_id={} {reason}: {}",
367 node.op_id, node.description
368 );
369 runtime
370 .record_op_state(&node.plan_id, &node.op_id, state)
371 .await;
372 let _ = tx
373 .send(Ok(rtdl_wire::operator_node_state(node, state, detail)))
374 .await;
375}
376
377async fn execute_call(
379 call: &CapabilityCall,
380 node: NodeEventContext,
381 tx: Sender<Result<RtdlEvent, Status>>,
382 mut atlas: AtlasClient,
383 provider_id: String,
384 runtime: PlanRuntime,
385 verification_policy: Arc<VerificationPolicy>,
386) -> bool {
387 let args_preview: String = call.args_json.chars().take(256).collect();
392 let args_ellipsis = if call.args_json.len() > 256 {
393 "…"
394 } else {
395 ""
396 };
397 info!(
398 "[executor] dispatching call_id={} provider='{}' contract='{}' args={}{}",
399 call.call_id, call.provider_id, call.contract_id, args_preview, args_ellipsis,
400 );
401
402 runtime
405 .record_op_state(
406 &node.plan_id,
407 &node.op_id,
408 RtdlNodeStateEnum::Running as u32,
409 )
410 .await;
411
412 let async_group = if call.provider_id == provider_id {
413 Ok(None)
414 } else {
415 async_registry::resolve_async_group(&mut atlas, &call.provider_id, &call.contract_id).await
416 };
417
418 let (mut result, mut state) = match async_group {
419 Err(error) => {
420 let r = CapabilityCallResult {
421 call_id: call.call_id.clone(),
422 provider_id: call.provider_id.clone(),
423 contract_id: call.contract_id.clone(),
424 success: false,
425 output: String::new(),
426 error,
427 };
428 (r, RtdlNodeStateEnum::Failed as u32)
429 }
430 Ok(Some(group)) => {
431 async_poll::run_until_terminal(call, &group, &provider_id, &mut atlas, &node, &runtime)
432 .await
433 }
434 Ok(None) => {
435 let r =
436 crate::dispatch::dispatch(call, &provider_id, &mut atlas, &runtime, &node.plan_id)
437 .await;
438 let cancelled = runtime.is_cancelled(&node.plan_id).await;
439 let state = leaf_terminal_state(r.success, cancelled);
440 (r, state)
441 }
442 };
443
444 if result.success
445 && state == RtdlNodeStateEnum::Succeeded as u32
446 && !runtime.is_cancelled(&node.plan_id).await
447 {
448 result = verification::verify_result(
449 verification_policy.as_ref(),
450 call,
451 &node,
452 result,
453 &provider_id,
454 &mut atlas,
455 &runtime,
456 )
457 .await;
458 if !result.success {
459 state = RtdlNodeStateEnum::Failed as u32;
460 }
461 }
462
463 if runtime.is_cancelled(&node.plan_id).await {
464 state = RtdlNodeStateEnum::Canceled as u32;
465 }
466 runtime
467 .record_op_state(&node.plan_id, &node.op_id, state)
468 .await;
469 let _ = tx
470 .send(Ok(rtdl_wire::node_state_from_result(
471 &node,
472 result.clone(),
473 state,
474 )))
475 .await;
476 let failed = !result.success;
477
478 if result.success {
479 let preview: String = result.output.chars().take(512).collect();
480 let ellipsis = if result.output.len() > 512 { "..." } else { "" };
481 info!(
482 "[executor] '{}' ok: {}{}",
483 call.contract_id, preview, ellipsis
484 );
485 } else {
486 warn!("[executor] '{}' failed: {}", call.contract_id, result.error);
487 }
488
489 failed
490}
491
492#[tonic::async_trait]
493impl RobonixSystemExecutorCancelAllPlans for ExecutorServiceImpl {
494 async fn cancel_all(
495 &self,
496 _request: Request<crate::pb::executor::CancelAllRequest>,
497 ) -> Result<Response<CancelAllResponse>, Status> {
498 let mut atlas = self.atlas.clone();
499 let success = self
500 .runtime
501 .cancel_all_plans(&self.provider_id, &mut atlas)
502 .await;
503 Ok(Response::new(CancelAllResponse { success }))
504 }
505}
506
507#[tonic::async_trait]
508impl RobonixSystemExecutorListActivePlans for ExecutorServiceImpl {
509 async fn list_active_plans(
510 &self,
511 _request: Request<crate::pb::executor::ListActivePlansRequest>,
512 ) -> Result<Response<ListActivePlansResponse>, Status> {
513 Ok(Response::new(ListActivePlansResponse {
514 success: true,
515 plans_json: self.runtime.active_plans_json().await,
516 error: String::new(),
517 }))
518 }
519}
520
521#[tonic::async_trait]
522impl RobonixSystemExecutorControlPlan for ExecutorServiceImpl {
523 async fn control_plan(
524 &self,
525 request: Request<crate::pb::executor::ControlPlanRequest>,
526 ) -> Result<Response<ControlPlanResponse>, Status> {
527 let request = request.into_inner();
528 let mut atlas = self.atlas.clone();
529 let response = match request.action.as_str() {
530 "cancel" => {
531 let wait_ms = if request.wait_ms == 0 {
532 5_000
533 } else {
534 request.wait_ms
535 };
536 let (completed, message) = self
537 .runtime
538 .cancel_plan_control(&request.plan_id, wait_ms, &self.provider_id, &mut atlas)
539 .await;
540 ControlPlanResponse {
541 success: true,
542 completed,
543 message,
544 error: String::new(),
545 }
546 }
547 "cancel_all" => {
548 let wait_ms = if request.wait_ms == 0 {
549 5_000
550 } else {
551 request.wait_ms
552 };
553 let (target_count, completed) = self
554 .runtime
555 .cancel_all_plans_except(&self.provider_id, &mut atlas, None, wait_ms)
556 .await;
557 ControlPlanResponse {
558 success: true,
559 completed,
560 message: format!(
561 "Cancellation requested for all RTDL plans; target_count={target_count}, completed={completed}."
562 ),
563 error: String::new(),
564 }
565 }
566 "stop_at" => match self
567 .runtime
568 .stop_plan_at_control(&request.plan_id, &request.op_id, &request.when)
569 .await
570 {
571 Ok(message) => ControlPlanResponse {
572 success: true,
573 completed: true,
574 message,
575 error: String::new(),
576 },
577 Err(error) => ControlPlanResponse {
578 success: false,
579 completed: true,
580 message: String::new(),
581 error,
582 },
583 },
584 action => ControlPlanResponse {
585 success: false,
586 completed: true,
587 message: String::new(),
588 error: format!("unknown plan-control action '{action}'"),
589 },
590 };
591 Ok(Response::new(response))
592 }
593}
594
595#[tonic::async_trait]
596impl RobonixSystemExecutorGetHealth for ExecutorServiceImpl {
597 async fn get_module_health(
598 &self,
599 _request: Request<GetModuleHealthRequest>,
600 ) -> Result<Response<GetModuleHealthResponse>, Status> {
601 Ok(Response::new(GetModuleHealthResponse {
602 report: Some(executor_health_report(&self.provider_id)),
603 }))
604 }
605}
606
607fn executor_health_report(provider_id: &str) -> ModuleHealthReport {
608 ModuleHealthReport {
609 schema_version: MODULE_HEALTH_SCHEMA_VERSION,
610 module: Some(ModuleHealth {
611 module_key: String::new(),
612 module_id: "executor".to_string(),
613 provider_id: provider_id.to_string(),
614 health: MODULE_HEALTH_OK,
615 state: "active".to_string(),
616 reason_code: "OK".to_string(),
617 detail: "executor serving".to_string(),
618 source: String::new(),
619 received_ts_ns: 0,
620 ttl_ms: MODULE_HEALTH_TTL_MS,
621 }),
622 }
623}
624
625fn validate_plan(plan: &Plan) -> Result<(), String> {
627 if plan.nodes.is_empty() {
628 return Err("Plan.nodes must not be empty".to_string());
629 }
630 let root = plan.root_index as usize;
631 if root >= plan.nodes.len() {
632 return Err(format!(
633 "Plan.root_index {} is out of bounds for {} nodes",
634 plan.root_index,
635 plan.nodes.len()
636 ));
637 }
638
639 let mut op_ids = HashSet::new();
640 for (idx, node) in plan.nodes.iter().enumerate() {
641 let op_id = node.op_id.trim();
642 if op_id.is_empty() {
643 return Err(format!("node {idx} op_id must not be empty"));
644 }
645 if !op_ids.insert(op_id.to_string()) {
646 return Err(format!("node {idx} has duplicate op_id '{op_id}'"));
647 }
648 if node.description.trim().is_empty() {
649 return Err(format!("node {idx} description must not be empty"));
650 }
651 match node.node_kind {
652 RTDL_SEQUENCE | RTDL_PARALLEL => {
653 for child in &node.children {
654 if *child as usize >= plan.nodes.len() {
655 return Err(format!("node {idx} child index {child} is out of bounds"));
656 }
657 }
658 }
659 RTDL_DO => {
660 if !node.children.is_empty() {
661 return Err(format!("do node {idx} must not have children"));
662 }
663 let Some(call) = node.call.as_ref() else {
664 return Err(format!("do node {idx} must contain a call"));
665 };
666 validate_call(idx, call)?;
667 }
668 other => return Err(format!("node {idx} has invalid node_kind {other}")),
669 }
670 }
671
672 let mut colors = vec![VisitColor::White; plan.nodes.len()];
673 visit_for_cycles(root, plan, &mut colors)
674}
675
676fn validate_call(node_index: usize, call: &CapabilityCall) -> Result<(), String> {
677 if call.call_id.is_empty() {
678 return Err(format!("do node {node_index} call_id must not be empty"));
679 }
680 if call.provider_id.is_empty() {
681 return Err(format!(
682 "do node {node_index} provider_id must not be empty"
683 ));
684 }
685 if call.contract_id.is_empty() {
686 return Err(format!(
687 "do node {node_index} contract_id must not be empty"
688 ));
689 }
690 Ok(())
691}
692
693#[derive(Clone, Copy, PartialEq, Eq)]
694enum VisitColor {
695 White,
696 Gray,
697 Black,
698}
699
700fn visit_for_cycles(index: usize, plan: &Plan, colors: &mut [VisitColor]) -> Result<(), String> {
706 match colors[index] {
707 VisitColor::Gray => return Err(format!("cycle detected at node {index}")),
708 VisitColor::Black => return Ok(()),
709 VisitColor::White => {}
710 }
711 colors[index] = VisitColor::Gray;
712 let node = &plan.nodes[index];
713 if matches!(node.node_kind, RTDL_SEQUENCE | RTDL_PARALLEL) {
714 for child in &node.children {
715 visit_for_cycles(*child as usize, plan, colors)?;
716 }
717 }
718 colors[index] = VisitColor::Black;
719 Ok(())
720}
721
722#[cfg(test)]
723mod tests {
724 use super::{
725 MODULE_HEALTH_OK, MODULE_HEALTH_SCHEMA_VERSION, MODULE_HEALTH_TTL_MS, PlanRuntime, RTDL_DO,
726 RTDL_PARALLEL, RTDL_SEQUENCE, RtdlNodeStateEnum, executor_health_report,
727 leaf_terminal_state, send_operator_terminal, send_stop_on_enter, validate_plan,
728 };
729 use crate::pb::executor::rtdl_event::RtdlEventEnum;
730 use crate::pb::pilot::{CapabilityCall, Plan, RtdlNode};
731 use crate::rtdl_wire::NodeEventContext;
732
733 fn call(id: &str) -> CapabilityCall {
734 CapabilityCall {
735 call_id: id.to_string(),
736 provider_id: "provider".to_string(),
737 contract_id: "robonix/test/cap".to_string(),
738 args_json: "{}".to_string(),
739 }
740 }
741
742 #[test]
743 fn executor_health_report_uses_minimal_module_health_v1_fields() {
744 let report = executor_health_report("executor");
745 assert_eq!(report.schema_version, MODULE_HEALTH_SCHEMA_VERSION);
746
747 let module = report.module.expect("module health");
748 assert_eq!(module.module_id, "executor");
749 assert_eq!(module.provider_id, "executor");
750 assert_eq!(module.health, MODULE_HEALTH_OK);
751 assert_eq!(module.state, "active");
752 assert_eq!(module.reason_code, "OK");
753 assert_eq!(module.detail, "executor serving");
754 assert_eq!(module.ttl_ms, MODULE_HEALTH_TTL_MS);
755
756 assert!(module.module_key.is_empty());
757 assert!(module.source.is_empty());
758 assert_eq!(module.received_ts_ns, 0);
759 }
760
761 #[test]
762 fn canceled_leaf_is_not_reported_as_failed_provider_work() {
763 assert_eq!(
764 leaf_terminal_state(false, true),
765 RtdlNodeStateEnum::Canceled as u32
766 );
767 assert_eq!(
768 leaf_terminal_state(false, false),
769 RtdlNodeStateEnum::Failed as u32
770 );
771 assert_eq!(
772 leaf_terminal_state(true, false),
773 RtdlNodeStateEnum::Succeeded as u32
774 );
775 }
776
777 fn node(kind: u32, children: Vec<u32>, call: Option<CapabilityCall>) -> RtdlNode {
778 node_with_identity("op", "test node", kind, children, call)
779 }
780
781 fn node_with_identity(
782 op_id: &str,
783 description: &str,
784 kind: u32,
785 children: Vec<u32>,
786 call: Option<CapabilityCall>,
787 ) -> RtdlNode {
788 RtdlNode {
789 node_kind: kind,
790 children,
791 call,
792 op_id: op_id.to_string(),
793 description: description.to_string(),
794 }
795 }
796
797 fn plan(nodes: Vec<RtdlNode>, root_index: u32) -> Plan {
798 Plan {
799 plan_id: "p".to_string(),
800 session_id: "s".to_string(),
801 round: 0,
802 nodes,
803 root_index,
804 }
805 }
806
807 #[test]
808 fn validates_sequence_and_parallel_nodes() {
809 let p = plan(
810 vec![
811 node_with_identity("op_1", "run sequence", RTDL_SEQUENCE, vec![1, 2], None),
812 node_with_identity("op_2", "call first cap", RTDL_DO, vec![], Some(call("p:0"))),
813 node_with_identity("op_3", "run parallel", RTDL_PARALLEL, vec![3, 4], None),
814 node_with_identity(
815 "op_4",
816 "call second cap",
817 RTDL_DO,
818 vec![],
819 Some(call("p:1")),
820 ),
821 node_with_identity("op_5", "call third cap", RTDL_DO, vec![], Some(call("p:2"))),
822 ],
823 0,
824 );
825 validate_plan(&p).unwrap();
826 }
827
828 #[test]
829 fn rejects_empty_op_id() {
830 let p = plan(
831 vec![node_with_identity("", "root", RTDL_SEQUENCE, vec![], None)],
832 0,
833 );
834 assert!(validate_plan(&p).unwrap_err().contains("op_id"));
835 }
836
837 #[test]
838 fn rejects_empty_description() {
839 let p = plan(
840 vec![node_with_identity("op_1", "", RTDL_SEQUENCE, vec![], None)],
841 0,
842 );
843 assert!(validate_plan(&p).unwrap_err().contains("description"));
844 }
845
846 #[test]
847 fn rejects_duplicate_op_id() {
848 let p = plan(
849 vec![
850 node_with_identity("op_1", "root", RTDL_SEQUENCE, vec![1], None),
851 node_with_identity("op_1", "child", RTDL_DO, vec![], Some(call("p:0"))),
852 ],
853 0,
854 );
855 assert!(validate_plan(&p).unwrap_err().contains("duplicate op_id"));
856 }
857
858 #[test]
859 fn rejects_invalid_root() {
860 let p = plan(vec![node(RTDL_SEQUENCE, vec![], None)], 3);
861 assert!(validate_plan(&p).unwrap_err().contains("root_index"));
862 }
863
864 #[test]
865 fn rejects_out_of_bounds_child() {
866 let p = plan(vec![node(RTDL_SEQUENCE, vec![9], None)], 0);
867 assert!(validate_plan(&p).unwrap_err().contains("out of bounds"));
868 }
869
870 #[test]
871 fn rejects_cycle() {
872 let p = plan(
873 vec![
874 node_with_identity("op_1", "root", RTDL_SEQUENCE, vec![1], None),
875 node_with_identity("op_2", "child", RTDL_PARALLEL, vec![0], None),
876 ],
877 0,
878 );
879 assert!(validate_plan(&p).unwrap_err().contains("cycle"));
880 }
881
882 #[test]
883 fn rejects_do_without_call() {
884 let p = plan(vec![node(RTDL_DO, vec![], None)], 0);
885 assert!(
886 validate_plan(&p)
887 .unwrap_err()
888 .contains("must contain a call")
889 );
890 }
891
892 #[tokio::test]
893 async fn operator_terminal_event_carries_node_identity() {
894 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
895 let node = NodeEventContext {
896 plan_id: "p".to_string(),
897 node_index: 0,
898 node_kind: RTDL_SEQUENCE,
899 op_id: "op_1".to_string(),
900 description: "run the ordered checks".to_string(),
901 };
902
903 let runtime = PlanRuntime::default();
904 send_operator_terminal(
905 &tx,
906 &node,
907 RtdlNodeStateEnum::Succeeded as u32,
908 "completed successfully",
909 &runtime,
910 )
911 .await;
912
913 let event = rx.recv().await.unwrap().unwrap();
914 let ns = event.node_state.unwrap();
915 assert_eq!(event.event_kind, RtdlEventEnum::NodeState as u32);
916 assert_eq!(ns.op_id, "op_1");
917 assert_eq!(ns.description, "run the ordered checks");
918 assert_eq!(ns.state, RtdlNodeStateEnum::Succeeded as u32);
919 assert!(ns.leaf_result.is_none());
920 assert!(ns.operator_detail.contains("RTDL sequence op_id=op_1"));
921 }
922
923 #[tokio::test]
924 async fn stop_on_enter_event_supports_operator_nodes() {
925 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
926 let runtime = PlanRuntime::default();
927 runtime.register_plan("p").await;
928 let node = NodeEventContext {
929 plan_id: "p".to_string(),
930 node_index: 0,
931 node_kind: RTDL_PARALLEL,
932 op_id: "op_1".to_string(),
933 description: "run branches".to_string(),
934 };
935
936 send_stop_on_enter(&tx, &node, &runtime).await;
937
938 let event = rx.recv().await.unwrap().unwrap();
939 let ns = event.node_state.unwrap();
940 assert_eq!(event.event_kind, RtdlEventEnum::NodeState as u32);
941 assert_eq!(ns.op_id, "op_1");
942 assert_eq!(ns.state, RtdlNodeStateEnum::Canceled as u32);
943 assert!(ns.leaf_result.is_none());
944 assert!(ns.operator_detail.contains("stopped on entering"));
945 }
946}