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 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/// `AtlasClient` is cheap to clone — each Execute RPC clones it so per-plan
41/// dispatch runs without serialising on a single mutex.
42#[derive(Clone)]
43pub struct ExecutorServiceImpl {
44    atlas: AtlasClient,
45    /// Executor's own provider_id. Two roles:
46    ///   1. consumer_id passed to atlas on every ConnectCapability so the
47    ///      channel record reflects who is using each downstream provider.
48    ///   2. self-detection: when a CapabilityCall in the plan targets this
49    ///      provider_id, dispatch short-circuits to the in-process builtin
50    ///      handlers instead of going through MCP loopback.
51    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
287/// Build the wire context copied into every node_state event.
288fn 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
299/// Return whether a node kind is an RTDL operator rather than a leaf call.
300fn is_operator_node(node_kind: u32) -> bool {
301    matches!(node_kind, RTDL_SEQUENCE | RTDL_PARALLEL)
302}
303
304/// Map a completed leaf call to its RTDL state. Cancellation wins over the
305/// provider result: a command terminated by cancel_plan normally returns
306/// success=false, but that is an expected CANCELED outcome, not a FAILED tool.
307fn 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
317/// Emit the terminal event for an `on_enter` stop point on any RTDL node.
318async 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
351/// Stream the terminal event for a non-leaf RTDL operator node, and record the
352/// state so `get_plan_status` reflects it.
353async 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
377/// Dispatch one RTDL `do` node and stream node_state events.
378async 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    // Log the args too (bounded) so the log shows what each call requested —
388    // essential for debugging plan-control builtins (stop_plan_at / cancel_plan)
389    // and any cap call. Truncated to keep large payloads (images, file content)
390    // from bloating the log.
391    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    // Mark the op running so get_plan_status shows the in-flight node. Live
403    // async states may replace it, then this function records one final state.
404    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
625/// Validate Plan arena shape before spawning execution work.
626fn 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
700/// DFS cycle check on the plan arena following only sequence/parallel child edges.
701///
702/// Uses White/Gray/Black marks: entering a Gray node means a back-edge to an ancestor.
703/// `RTDL_DO` nodes have no children in this graph. Returns `Ok` when the subgraph from
704/// `index` is acyclic; otherwise an error naming the node where the cycle was found.
705fn 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}