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}