Skip to main content

rust_supervisor/control/
handle.rs

1//! Public runtime control handle.
2//!
3//! The handle owns the command sender side and exposes asynchronous control
4//! methods. It keeps command construction separate from runtime execution.
5
6use crate::child_runner::runner::ChildRunReport;
7use crate::control::command::{CommandMeta, CommandResult, ControlCommand};
8#[cfg(unix)]
9use crate::dashboard::runtime::DashboardIpcRuntimeGuard;
10use crate::error::types::SupervisorError;
11use crate::id::types::{ChildId, SupervisorPath};
12use crate::observe::pipeline::{ObservabilityPipeline, TestRecorder};
13use crate::runtime::lifecycle::{RuntimeControlPlane, RuntimeExitReport, RuntimeHealthReport};
14use crate::runtime::message::{ControlPlaneMessage, RuntimeLoopMessage};
15use std::sync::Arc;
16use std::sync::Mutex;
17use tokio::sync::{broadcast, mpsc, oneshot};
18
19/// Cloneable handle used to control a running supervisor.
20#[derive(Debug, Clone)]
21pub struct SupervisorHandle {
22    /// Sender side used to submit runtime control commands.
23    command_sender: mpsc::Sender<RuntimeLoopMessage>,
24    /// Broadcast sender used to create lifecycle event subscriptions.
25    event_sender: broadcast::Sender<String>,
26    /// Runtime control plane lifecycle state.
27    control_plane: RuntimeControlPlane,
28    /// Shared typed observability pipeline.
29    observability: Arc<Mutex<ObservabilityPipeline>>,
30    /// Optional dashboard IPC runtime guard.
31    #[cfg(unix)]
32    dashboard_runtime: Option<Arc<DashboardIpcRuntimeGuard>>,
33}
34
35impl SupervisorHandle {
36    /// Creates a runtime handle from channel senders.
37    ///
38    /// # Arguments
39    ///
40    /// - `command_sender`: Sender used to submit runtime commands.
41    /// - `event_sender`: Sender used to subscribe to lifecycle events.
42    ///
43    /// # Returns
44    ///
45    /// Returns a [`SupervisorHandle`].
46    pub fn new(
47        command_sender: mpsc::Sender<RuntimeLoopMessage>,
48        event_sender: broadcast::Sender<String>,
49        control_plane: RuntimeControlPlane,
50    ) -> Self {
51        Self::new_with_observability(
52            command_sender,
53            event_sender,
54            control_plane,
55            Arc::new(Mutex::new(ObservabilityPipeline::new(16, 16))),
56        )
57    }
58
59    /// Creates a runtime handle with a shared observability pipeline.
60    ///
61    /// # Arguments
62    ///
63    /// - `command_sender`: Sender used to submit runtime commands.
64    /// - `event_sender`: Sender used to subscribe to lifecycle event text.
65    /// - `control_plane`: Runtime control plane lifecycle state.
66    /// - `observability`: Shared typed observability pipeline.
67    ///
68    /// # Returns
69    ///
70    /// Returns a [`SupervisorHandle`].
71    pub(crate) fn new_with_observability(
72        command_sender: mpsc::Sender<RuntimeLoopMessage>,
73        event_sender: broadcast::Sender<String>,
74        control_plane: RuntimeControlPlane,
75        observability: Arc<Mutex<ObservabilityPipeline>>,
76    ) -> Self {
77        Self {
78            command_sender,
79            event_sender,
80            control_plane,
81            observability,
82            #[cfg(unix)]
83            dashboard_runtime: None,
84        }
85    }
86
87    /// Attaches a dashboard IPC runtime guard to this handle.
88    ///
89    /// # Arguments
90    ///
91    /// - `dashboard_runtime`: Guard that owns dashboard IPC runtime tasks.
92    ///
93    /// # Returns
94    ///
95    /// Returns this handle with dashboard runtime lifecycle attached.
96    #[cfg(unix)]
97    pub(crate) fn with_dashboard_runtime(
98        mut self,
99        dashboard_runtime: Arc<DashboardIpcRuntimeGuard>,
100    ) -> Self {
101        self.dashboard_runtime = Some(dashboard_runtime);
102        self
103    }
104
105    /// Adds a child manifest under a supervisor path.
106    ///
107    /// # Arguments
108    ///
109    /// - `target`: Supervisor path that should receive the child.
110    /// - `child_manifest`: Child manifest text supplied by the caller.
111    /// - `requested_by`: Caller that requested the command.
112    /// - `reason`: Human-readable command reason.
113    ///
114    /// # Child Manifest Example
115    ///
116    /// The runtime expects a YAML child declaration. The smallest useful
117    /// manifest names the child and selects a task kind:
118    ///
119    /// ```yaml
120    /// name: worker
121    /// kind: async_worker
122    /// ```
123    ///
124    /// Optional fields can be added when the child needs dependencies,
125    /// lifecycle policy, command permissions, environment variables, or secret
126    /// references:
127    ///
128    /// ```yaml
129    /// name: worker
130    /// kind: async_worker
131    /// criticality: optional
132    /// restart_policy: transient
133    /// dependencies:
134    ///   - cache
135    /// health_check:
136    ///   check_interval_secs: 10
137    ///   timeout_secs: 5
138    ///   max_retries: 3
139    /// readiness: explicit
140    /// command_permissions:
141    ///   allow_shutdown: false
142    ///   allow_restart: true
143    ///   allowed_signals:
144    ///     - SIGTERM
145    /// environment:
146    ///   - name: WORKER_MODE
147    ///     value: queue
148    ///   - name: API_TOKEN
149    ///     secret_ref: ${API_TOKEN}
150    /// secrets:
151    ///   - name: API_TOKEN
152    ///     key: workers/api_token
153    ///     required: true
154    /// ```
155    ///
156    /// # Example
157    ///
158    /// ```no_run
159    /// # async fn add_child_example() -> Result<(), rust_supervisor::error::types::SupervisorError> {
160    /// use rust_supervisor::control::command::CommandResult;
161    /// use rust_supervisor::id::types::SupervisorPath;
162    /// use rust_supervisor::runtime::supervisor::Supervisor;
163    /// use rust_supervisor::spec::supervisor::SupervisorSpec;
164    ///
165    /// let handle = Supervisor::start(SupervisorSpec::root(Vec::new())).await?;
166    /// let result = handle
167    ///     .add_child(
168    ///         SupervisorPath::root(),
169    ///         "name: worker\nkind: async_worker\n",
170    ///         "operator",
171    ///         "attach worker during runtime update",
172    ///     )
173    ///     .await?;
174    ///
175    /// assert!(matches!(result, CommandResult::ChildAdded { .. }));
176    /// # Ok(())
177    /// # }
178    /// ```
179    ///
180    /// # Returns
181    ///
182    /// Returns a [`CommandResult`] after the runtime accepts the command.
183    pub async fn add_child(
184        &self,
185        target: SupervisorPath,
186        child_manifest: impl Into<String>,
187        requested_by: impl Into<String>,
188        reason: impl Into<String>,
189    ) -> Result<CommandResult, SupervisorError> {
190        self.send(ControlCommand::AddChild {
191            meta: CommandMeta::new(requested_by, reason),
192            target,
193            child_manifest: child_manifest.into(),
194        })
195        .await
196    }
197
198    /// Removes a child from runtime governance.
199    ///
200    /// # Arguments
201    ///
202    /// - `child_id`: Target child identifier.
203    /// - `requested_by`: Caller that requested the command.
204    /// - `reason`: Human-readable command reason.
205    ///
206    /// # Returns
207    ///
208    /// Returns a [`CommandResult`] after removal or idempotent reuse.
209    pub async fn remove_child(
210        &self,
211        child_id: ChildId,
212        requested_by: impl Into<String>,
213        reason: impl Into<String>,
214    ) -> Result<CommandResult, SupervisorError> {
215        self.child_command(child_id, requested_by, reason, |meta, child_id| {
216            ControlCommand::RemoveChild { meta, child_id }
217        })
218        .await
219    }
220
221    /// Restarts a child explicitly.
222    ///
223    /// # Arguments
224    ///
225    /// - `child_id`: Target child identifier.
226    /// - `requested_by`: Caller that requested the command.
227    /// - `reason`: Human-readable command reason.
228    ///
229    /// # Returns
230    ///
231    /// Returns a [`CommandResult`] after restart dispatch.
232    pub async fn restart_child(
233        &self,
234        child_id: ChildId,
235        requested_by: impl Into<String>,
236        reason: impl Into<String>,
237    ) -> Result<CommandResult, SupervisorError> {
238        self.child_command(child_id, requested_by, reason, |meta, child_id| {
239            ControlCommand::RestartChild { meta, child_id }
240        })
241        .await
242    }
243
244    /// Pauses a child idempotently.
245    ///
246    /// # Arguments
247    ///
248    /// - `child_id`: Target child identifier.
249    /// - `requested_by`: Caller that requested the command.
250    /// - `reason`: Human-readable command reason.
251    ///
252    /// # Returns
253    ///
254    /// Returns the current child state after the command.
255    pub async fn pause_child(
256        &self,
257        child_id: ChildId,
258        requested_by: impl Into<String>,
259        reason: impl Into<String>,
260    ) -> Result<CommandResult, SupervisorError> {
261        self.child_command(child_id, requested_by, reason, |meta, child_id| {
262            ControlCommand::PauseChild { meta, child_id }
263        })
264        .await
265    }
266
267    /// Resumes a child idempotently.
268    ///
269    /// # Arguments
270    ///
271    /// - `child_id`: Target child identifier.
272    /// - `requested_by`: Caller that requested the command.
273    /// - `reason`: Human-readable command reason.
274    ///
275    /// # Returns
276    ///
277    /// Returns the current child state after the command.
278    pub async fn resume_child(
279        &self,
280        child_id: ChildId,
281        requested_by: impl Into<String>,
282        reason: impl Into<String>,
283    ) -> Result<CommandResult, SupervisorError> {
284        self.child_command(child_id, requested_by, reason, |meta, child_id| {
285            ControlCommand::ResumeChild { meta, child_id }
286        })
287        .await
288    }
289
290    /// Quarantines a child idempotently.
291    ///
292    /// # Arguments
293    ///
294    /// - `child_id`: Target child identifier.
295    /// - `requested_by`: Caller that requested the command.
296    /// - `reason`: Human-readable command reason.
297    ///
298    /// # Returns
299    ///
300    /// Returns the current child state after the command.
301    pub async fn quarantine_child(
302        &self,
303        child_id: ChildId,
304        requested_by: impl Into<String>,
305        reason: impl Into<String>,
306    ) -> Result<CommandResult, SupervisorError> {
307        self.child_command(child_id, requested_by, reason, |meta, child_id| {
308            ControlCommand::QuarantineChild { meta, child_id }
309        })
310        .await
311    }
312
313    /// Shuts down the supervisor tree idempotently.
314    ///
315    /// # Arguments
316    ///
317    /// - `requested_by`: Caller that requested shutdown.
318    /// - `reason`: Human-readable shutdown reason.
319    ///
320    /// # Returns
321    ///
322    /// Returns the current shutdown result.
323    pub async fn shutdown_tree(
324        &self,
325        requested_by: impl Into<String>,
326        reason: impl Into<String>,
327    ) -> Result<CommandResult, SupervisorError> {
328        self.send(ControlCommand::ShutdownTree {
329            meta: CommandMeta::new(requested_by, reason),
330        })
331        .await
332    }
333
334    /// Queries the current runtime state.
335    ///
336    /// # Arguments
337    ///
338    /// This function has no arguments.
339    ///
340    /// # Returns
341    ///
342    /// Returns a [`CommandResult::CurrentState`] value.
343    pub async fn current_state(&self) -> Result<CommandResult, SupervisorError> {
344        self.send(ControlCommand::CurrentState {
345            meta: CommandMeta::new("system", "current_state"),
346        })
347        .await
348    }
349
350    /// Reports whether the runtime control loop is alive.
351    ///
352    /// # Arguments
353    ///
354    /// This function has no arguments.
355    ///
356    /// # Returns
357    ///
358    /// Returns `true` when ordinary control commands may still be accepted.
359    pub fn is_alive(&self) -> bool {
360        self.control_plane.is_alive()
361    }
362
363    /// Returns a runtime control plane health report.
364    ///
365    /// # Arguments
366    ///
367    /// This function has no arguments.
368    ///
369    /// # Returns
370    ///
371    /// Returns a [`RuntimeHealthReport`] value for the current observation.
372    pub fn health(&self) -> RuntimeHealthReport {
373        self.control_plane.health()
374    }
375
376    /// Waits until the runtime control plane reaches a final state.
377    ///
378    /// # Arguments
379    ///
380    /// This function has no arguments.
381    ///
382    /// # Returns
383    ///
384    /// Returns the cached [`RuntimeExitReport`]. If the underlying
385    /// `control_plane.join()` does not complete within 60 seconds, a
386    /// timeout error is returned to prevent infinite hangs caused by
387    /// shutdown pipeline bugs.
388    pub async fn join(&self) -> Result<RuntimeExitReport, SupervisorError> {
389        let join_deadline = std::time::Duration::from_secs(60);
390        let report = tokio::time::timeout(join_deadline, self.control_plane.join())
391            .await
392            .map_err(|_elapsed| SupervisorError::FatalConfig {
393                message: "control plane join timed out after 60 seconds".to_owned(),
394            })?;
395        let _ignored = self.event_sender.send(format!(
396            "runtime_control_loop_join_completed:{}:{}:{}",
397            report.state.as_str(),
398            report.phase,
399            report.reason
400        ));
401        Ok(report)
402    }
403
404    /// Requests shutdown for the runtime control plane itself.
405    ///
406    /// # Arguments
407    ///
408    /// - `requested_by`: Caller that requested shutdown.
409    /// - `reason`: Human-readable shutdown reason.
410    ///
411    /// # Returns
412    ///
413    /// Returns the final [`RuntimeExitReport`].
414    pub async fn shutdown(
415        &self,
416        requested_by: impl Into<String>,
417        reason: impl Into<String>,
418    ) -> Result<RuntimeExitReport, SupervisorError> {
419        let meta = CommandMeta::new(requested_by, reason);
420        if let Some(report) = self
421            .control_plane
422            .mark_shutdown_requested(meta.requested_by.clone(), meta.reason.clone())?
423        {
424            return Ok(report);
425        }
426
427        let (reply_sender, reply_receiver) = oneshot::channel();
428        if self
429            .command_sender
430            .send(RuntimeLoopMessage::ControlPlane(
431                ControlPlaneMessage::Shutdown { meta, reply_sender },
432            ))
433            .await
434            .is_err()
435        {
436            return Err(self
437                .closed_runtime_error_after_join("runtime control loop is closed")
438                .await);
439        }
440        match reply_receiver.await {
441            Ok(result) => {
442                result?;
443            }
444            Err(_) => {
445                return Err(self
446                    .closed_runtime_error_after_join("runtime control loop dropped shutdown reply")
447                    .await);
448            }
449        }
450        self.join().await
451    }
452
453    /// Subscribes to runtime event text emitted by the control loop.
454    ///
455    /// # Arguments
456    ///
457    /// This function has no arguments.
458    ///
459    /// # Returns
460    ///
461    /// Returns a broadcast receiver for event text.
462    pub fn subscribe_events(&self) -> broadcast::Receiver<String> {
463        let receiver = self.event_sender.subscribe();
464        if self.control_plane.is_alive() {
465            let _ignored = self
466                .event_sender
467                .send("runtime_control_loop_started:startup".to_owned());
468        }
469        receiver
470    }
471
472    /// Returns a copy of the typed observability test recorder.
473    ///
474    /// # Arguments
475    ///
476    /// This function has no arguments.
477    ///
478    /// # Returns
479    ///
480    /// Returns the currently retained [`TestRecorder`] contents.
481    pub fn observability_recorder(&self) -> TestRecorder {
482        self.observability
483            .lock()
484            .map(|pipeline| pipeline.test_recorder.clone())
485            .unwrap_or_default()
486    }
487
488    /// Hidden integration-test hook that feeds a synthetic [`ChildRunReport`] through the mailbox.
489    ///
490    /// Production callers must not rely on this hook.
491    #[doc(hidden)]
492    pub async fn generation_fencing_replay_child_exit_for_test(
493        &self,
494        report: ChildRunReport,
495    ) -> Result<(), SupervisorError> {
496        if let Some(report_final) = self.control_plane.final_report() {
497            return Err(runtime_exit_error(&report_final));
498        }
499        if !self.control_plane.is_alive() {
500            return Err(SupervisorError::InvalidTransition {
501                message: format!(
502                    "runtime control loop is not alive: state={}",
503                    self.control_plane.health().state.as_str()
504                ),
505            });
506        }
507        self.command_sender
508            .send(RuntimeLoopMessage::ControlPlane(
509                ControlPlaneMessage::ReplayChildExitForTest {
510                    report: Box::new(report),
511                },
512            ))
513            .await
514            .map_err(|_| SupervisorError::InvalidTransition {
515                message: "runtime control loop is closed".to_owned(),
516            })
517    }
518
519    /// Sends one control command and waits for the result.
520    ///
521    /// # Arguments
522    ///
523    /// - `command`: Command that should be executed by the runtime loop.
524    ///
525    /// # Returns
526    ///
527    /// Returns a command result or a supervisor error when the runtime is gone.
528    async fn send(&self, command: ControlCommand) -> Result<CommandResult, SupervisorError> {
529        if let Some(report) = self.control_plane.final_report() {
530            return Err(runtime_exit_error(&report));
531        }
532        if !self.control_plane.is_alive() {
533            return Err(SupervisorError::InvalidTransition {
534                message: format!(
535                    "runtime control loop is not alive: state={}",
536                    self.control_plane.health().state.as_str()
537                ),
538            });
539        }
540        command.validate_audit_metadata()?;
541        let (reply_sender, reply_receiver) = oneshot::channel();
542        if self
543            .command_sender
544            .send(RuntimeLoopMessage::Control {
545                command,
546                reply_sender,
547            })
548            .await
549            .is_err()
550        {
551            return Err(self
552                .closed_runtime_error_after_join("runtime control loop is closed")
553                .await);
554        }
555        match reply_receiver.await {
556            Ok(result) => result,
557            Err(_) => Err(self
558                .closed_runtime_error_after_join("runtime control loop dropped command reply")
559                .await),
560        }
561    }
562
563    /// Builds and sends a child-targeted command.
564    ///
565    /// # Arguments
566    ///
567    /// - `child_id`: Target child identifier.
568    /// - `requested_by`: Caller that requested the command.
569    /// - `reason`: Human-readable command reason.
570    /// - `builder`: Command builder for the child operation.
571    ///
572    /// # Returns
573    ///
574    /// Returns a command result from the runtime loop.
575    async fn child_command<F>(
576        &self,
577        child_id: ChildId,
578        requested_by: impl Into<String>,
579        reason: impl Into<String>,
580        builder: F,
581    ) -> Result<CommandResult, SupervisorError>
582    where
583        F: FnOnce(CommandMeta, ChildId) -> ControlCommand,
584    {
585        let meta = CommandMeta::new(requested_by, reason);
586        self.send(builder(meta, child_id)).await
587    }
588
589    /// Builds an error after waiting for the runtime exit report when possible.
590    async fn closed_runtime_error_after_join(&self, fallback: &str) -> SupervisorError {
591        if let Some(report) = self.control_plane.final_report() {
592            return runtime_exit_error(&report);
593        }
594        if self.command_sender.is_closed() {
595            let report = self.control_plane.join().await;
596            return runtime_exit_error(&report);
597        }
598        SupervisorError::InvalidTransition {
599            message: fallback.to_owned(),
600        }
601    }
602
603    /// Sends a raw control command with an explicit command_id for
604    /// end-to-end tracing from relay/dashboard through runtime events.
605    ///
606    /// Used by dashboard IPC to preserve the relay-supplied command_id
607    /// across the full dispatch path, so audit events and runtime state
608    /// carry the same identifier that the relay and UI see.
609    ///
610    /// # Arguments
611    ///
612    /// - `command`: The control command to execute.
613    ///
614    /// # Returns
615    ///
616    /// Returns the command result from the runtime loop.
617    pub async fn execute_with_command_id(
618        &self,
619        command: ControlCommand,
620    ) -> Result<CommandResult, SupervisorError> {
621        self.send(command).await
622    }
623}
624
625/// Builds a control command error from a runtime exit report.
626fn runtime_exit_error(report: &RuntimeExitReport) -> SupervisorError {
627    SupervisorError::InvalidTransition {
628        message: format!(
629            "runtime control loop already exited: state={}, phase={}, reason={}",
630            report.state.as_str(),
631            report.phase,
632            report.reason
633        ),
634    }
635}