Skip to main content

robonix_executor/
service.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// gRPC contract handlers for executor plan execution and cancellation.
5
6use 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/// `AtlasClient` is cheap to clone — each Execute RPC clones it so per-plan
40/// dispatch runs without serialising on a single mutex.
41#[derive(Clone)]
42pub struct ExecutorServiceImpl {
43    atlas: AtlasClient,
44    /// Executor's own provider_id. Two roles:
45    ///   1. consumer_id passed to atlas on every ConnectCapability so the
46    ///      channel record reflects who is using each downstream provider.
47    ///   2. self-detection: when a CapabilityCall in the plan targets this
48    ///      provider_id, dispatch short-circuits to the in-process builtin
49    ///      handlers instead of going through MCP loopback.
50    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
273/// Build the wire context copied into every node_state event.
274fn 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
285/// Return whether a node kind is an RTDL operator rather than a leaf call.
286fn is_operator_node(node_kind: u32) -> bool {
287    matches!(node_kind, RTDL_SEQUENCE | RTDL_PARALLEL)
288}
289
290/// Map a completed leaf call to its RTDL state. Cancellation wins over the
291/// provider result: a command terminated by cancel_plan normally returns
292/// success=false, but that is an expected CANCELED outcome, not a FAILED tool.
293fn 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
303/// Emit the terminal event for an `on_enter` stop point on any RTDL node.
304async 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
337/// Stream the terminal event for a non-leaf RTDL operator node, and record the
338/// state so `get_plan_status` reflects it.
339async 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
363/// Dispatch one RTDL `do` node and stream node_state events.
364async 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    // Log the args too (bounded) so the log shows what each call requested —
373    // essential for debugging plan-control builtins (stop_plan_at / cancel_plan)
374    // and any cap call. Truncated to keep large payloads (images, file content)
375    // from bloating the log.
376    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    // Mark the op running so get_plan_status shows the in-flight node; the
388    // terminal state below (or async_poll for async caps) overwrites it.
389    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
605/// Validate Plan arena shape before spawning execution work.
606fn 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
680/// DFS cycle check on the plan arena following only sequence/parallel child edges.
681///
682/// Uses White/Gray/Black marks: entering a Gray node means a back-edge to an ancestor.
683/// `RTDL_DO` nodes have no children in this graph. Returns `Ok` when the subgraph from
684/// `index` is acyclic; otherwise an error naming the node where the cycle was found.
685fn 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}