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