1use anyhow::{Result, bail};
4use async_trait::async_trait;
5use scv_channels::state::{self, AccountSettings};
6use scv_protocol::{ComponentHealth, ComponentState, DaemonCommand, DaemonStatus, RemoteTools};
7use std::{
8 collections::BTreeMap,
9 path::PathBuf,
10 sync::{Arc, Mutex},
11 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
12};
13use tokio::task::JoinHandle;
14use tokio_util::sync::CancellationToken;
15
16const STOP_GRACE: Duration = Duration::from_secs(5);
17const BUSY_RETRY: Duration = Duration::from_secs(5);
19
20#[async_trait]
23pub trait Component: Send + Sync + 'static {
24 async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()>;
25}
26
27#[derive(Clone)]
28pub struct HealthReporter(Arc<Mutex<ComponentHealth>>);
29
30impl HealthReporter {
31 pub fn contact(&self, connected: bool) {
32 let mut health = self.0.lock().unwrap();
33 if matches!(
34 health.state,
35 ComponentState::Stopping | ComponentState::Stopped
36 ) {
37 return;
38 }
39 health.state = if connected {
40 ComponentState::Connected
41 } else {
42 ComponentState::Disconnected
43 };
44 health.error = (!connected).then(|| "Component contact failed".into());
45 if connected {
46 health.last_success_unix_seconds = Some(
47 SystemTime::now()
48 .duration_since(UNIX_EPOCH)
49 .unwrap_or_default()
50 .as_secs(),
51 );
52 }
53 }
54
55 fn transition(&self, state: ComponentState, error: Option<&str>) {
56 let mut health = self.0.lock().unwrap();
57 health.state = state;
58 health.error = error.map(str::to_owned);
59 }
60
61 fn snapshot(&self) -> ComponentHealth {
62 self.0.lock().unwrap().clone()
63 }
64}
65
66pub struct Supervisor {
67 tasks: BTreeMap<String, RunningComponent>,
68 grace: Duration,
69 initial_backoff: Duration,
70}
71
72struct RunningComponent {
73 cancellation: CancellationToken,
74 task: JoinHandle<()>,
75 health: HealthReporter,
76}
77
78impl Default for Supervisor {
79 fn default() -> Self {
80 Self {
81 tasks: BTreeMap::new(),
82 grace: STOP_GRACE,
83 initial_backoff: Duration::from_secs(1),
84 }
85 }
86}
87
88impl Supervisor {
89 pub fn start(&mut self, component: Arc<dyn Component>, health: ComponentHealth) {
91 if self.tasks.contains_key(&health.id) {
92 return;
93 }
94 let id = health.id.clone();
95 let health = HealthReporter(Arc::new(Mutex::new(health)));
96 let cancellation = CancellationToken::new();
97 let cancel = cancellation.clone();
98 let report = health.clone();
99 let initial_backoff = self.initial_backoff;
100 let grace = self.grace;
101 let task = tokio::spawn(async move {
102 let mut delay = initial_backoff;
103 loop {
104 if cancel.is_cancelled() {
105 break;
106 }
107 report.transition(ComponentState::Starting, None);
108 let started = Instant::now();
109 let instance = component.clone();
111 let child_cancel = cancel.clone();
112 let child_report = report.clone();
113 let mut child =
114 tokio::spawn(async move { instance.run(child_cancel, child_report).await });
115 tokio::select! {
116 biased;
117 _ = cancel.cancelled() => {
118 report.transition(ComponentState::Stopping, None);
119 if tokio::time::timeout(grace, &mut child).await.is_err() {
120 child.abort();
121 let _ = child.await;
122 }
123 break;
124 }
125 _ = &mut child => {}
126 }
127 report.transition(
128 ComponentState::Backoff,
129 Some("Component stopped unexpectedly; retrying"),
130 );
131 report.0.lock().unwrap().restarts += 1;
132 if started.elapsed() >= Duration::from_secs(60) {
133 delay = initial_backoff;
134 }
135 tokio::select! {
136 _ = cancel.cancelled() => break,
137 _ = tokio::time::sleep(delay) => {}
138 }
139 delay = (delay * 2).min(Duration::from_secs(60));
140 }
141 report.transition(ComponentState::Stopped, None);
142 });
143 self.tasks.insert(
144 id,
145 RunningComponent {
146 cancellation,
147 task,
148 health,
149 },
150 );
151 }
152
153 pub fn health(&self) -> Vec<ComponentHealth> {
154 self.tasks
155 .values()
156 .map(|task| task.health.snapshot())
157 .collect()
158 }
159
160 pub async fn stop(&mut self, id: &str) {
161 if let Some(running) = self.tasks.get_mut(id) {
162 running.cancellation.cancel();
163 let _ = (&mut running.task).await;
165 }
166 self.tasks.remove(id);
167 }
168
169 pub async fn shutdown(&mut self) {
170 for task in self.tasks.values() {
171 task.cancellation.cancel();
172 }
173 for id in self.tasks.keys().cloned().collect::<Vec<_>>() {
174 self.stop(&id).await;
175 }
176 }
177}
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181enum Channel {
182 Wechat,
183 Feishu,
184}
185
186const CHANNELS: [Channel; 2] = [Channel::Wechat, Channel::Feishu];
187
188#[derive(Clone, PartialEq)]
190enum Credentials {
191 Wechat(scv_clawbot::state::Account),
192 Feishu(scv_feishu::state::Account),
193}
194
195impl Channel {
196 fn parse(name: &str) -> Result<Self> {
197 CHANNELS
198 .into_iter()
199 .find(|channel| channel.name() == name)
200 .ok_or_else(|| anyhow::anyhow!("Unknown channel {name:?}"))
201 }
202
203 fn name(self) -> &'static str {
204 match self {
205 Self::Wechat => scv_clawbot::CHANNEL,
206 Self::Feishu => scv_feishu::CHANNEL,
207 }
208 }
209
210 fn title(self) -> &'static str {
211 match self {
212 Self::Wechat => "WeChat",
213 Self::Feishu => "Feishu",
214 }
215 }
216
217 fn account_names(self) -> std::result::Result<Vec<String>, &'static str> {
219 const DISCOVERY: &str =
220 "Account discovery failed; components stopped until configuration is readable";
221 match self {
222 Self::Wechat => scv_clawbot::state::account_names(),
223 Self::Feishu => scv_feishu::state::account_names(),
224 }
225 .map_err(|_| DISCOVERY)
226 }
227
228 fn snapshot(self, account: &str) -> Result<(Option<Credentials>, AccountSettings)> {
229 Ok(match self {
230 Self::Wechat => {
231 let (credentials, settings) = scv_clawbot::state::account_snapshot(account)?;
232 (credentials.map(Credentials::Wechat), settings)
233 }
234 Self::Feishu => {
235 let (credentials, settings) = scv_feishu::state::account_snapshot(account)?;
236 (credentials.map(Credentials::Feishu), settings)
237 }
238 })
239 }
240
241 fn signed_in(self, account: &str) -> Result<bool> {
242 Ok(match self {
243 Self::Wechat => scv_clawbot::state::account(account)?.is_some(),
244 Self::Feishu => scv_feishu::state::account(account)?.is_some(),
245 })
246 }
247
248 fn settings(self, account: &str) -> Result<AccountSettings> {
249 match self {
250 Self::Wechat => scv_clawbot::state::settings(account),
251 Self::Feishu => scv_feishu::state::settings(account),
252 }
253 }
254
255 fn save_settings(self, account: &str, settings: &AccountSettings) -> Result<()> {
256 match self {
257 Self::Wechat => scv_clawbot::state::save_settings(account, settings),
258 Self::Feishu => scv_feishu::state::save_settings(account, settings),
259 }
260 }
261
262 fn remove(self, account: &str) -> Result<()> {
263 match self {
264 Self::Wechat => scv_clawbot::state::remove(account),
265 Self::Feishu => scv_feishu::state::remove(account),
266 }
267 }
268}
269
270impl Credentials {
271 fn owner(&self) -> Option<&str> {
273 match self {
274 Self::Wechat(account) => account.user_id.as_deref(),
275 Self::Feishu(account) => account.owner_open_id.as_deref(),
276 }
277 }
278
279 fn bot_id(&self) -> Option<String> {
281 match self {
282 Self::Wechat(account) => account.bot_id.clone(),
283 Self::Feishu(account) => Some(account.app_id.clone()),
284 }
285 }
286}
287
288struct ChannelAccount {
289 channel: Channel,
290 account: String,
291 credentials: Credentials,
292 workspace: PathBuf,
293 socket: PathBuf,
294 tool_owner: Option<String>,
295 link: scv_channels::hub::Link,
296 media: scv_channels::MediaSettings,
297}
298
299#[async_trait]
300impl Component for ChannelAccount {
301 async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()> {
302 let tool_owner = self.tool_owner.clone().map(|user_id| {
303 let turn_timeout = scv_channels::owner_turn_timeout(max_tool_timeout(&self.workspace));
304 tracing::info!(
305 "{} {} owner turns may run up to {} seconds",
306 self.channel.title(),
307 self.account,
308 turn_timeout.as_secs()
309 );
310 scv_channels::ToolOwner {
311 user_id,
312 turn_timeout,
313 }
314 });
315 let report = Arc::new(move |connected| health.contact(connected));
316 match &self.credentials {
317 Credentials::Wechat(credentials) => {
318 let media = scv_clawbot::state::media_options(&self.account, self.media.clone())?;
319 scv_clawbot::run_supervised(
320 &credentials.token,
321 &credentials.base_url,
322 &self.account,
323 &self.workspace,
324 &self.socket,
325 tool_owner.as_ref(),
326 &media,
327 cancellation,
328 report,
329 self.link.clone(),
330 )
331 .await
332 }
333 Credentials::Feishu(credentials) => {
334 let media = scv_feishu::state::media_options(&self.account, self.media.clone())?;
335 scv_feishu::run_supervised(
336 credentials,
337 &self.account,
338 &self.workspace,
339 &self.socket,
340 tool_owner.as_ref(),
341 &media,
342 cancellation,
343 report,
344 self.link.clone(),
345 )
346 .await
347 }
348 }
349 }
350}
351
352pub(crate) struct Components {
353 supervisor: Supervisor,
354 desired: BTreeMap<String, (Credentials, AccountSettings)>,
356 inactive: BTreeMap<String, ComponentHealth>,
357 socket: PathBuf,
358 workspace: PathBuf,
359 hub: Arc<scv_channels::hub::Hub>,
361 restarter: Option<Arc<crate::restart::Restarter>>,
363}
364
365impl Components {
366 #[cfg(test)]
367 pub fn new(socket: PathBuf, workspace: PathBuf) -> Self {
368 Self::with_hub(socket, workspace, scv_channels::hub::Hub::new(None))
369 }
370
371 pub fn with_hub(socket: PathBuf, workspace: PathBuf, hub: Arc<scv_channels::hub::Hub>) -> Self {
372 Self {
373 supervisor: Supervisor::default(),
374 desired: BTreeMap::new(),
375 inactive: BTreeMap::new(),
376 socket,
377 workspace,
378 hub,
379 restarter: None,
380 }
381 }
382
383 pub(crate) fn set_restarter(&mut self, restarter: Arc<crate::restart::Restarter>) {
384 self.restarter = Some(restarter);
385 }
386
387 pub(crate) fn restarter(&self) -> Option<Arc<crate::restart::Restarter>> {
388 self.restarter.clone()
389 }
390
391 pub fn status(&self) -> DaemonStatus {
392 let mut components = self.supervisor.health();
393 components.extend(self.inactive.values().cloned());
394 components.sort_by(|a, b| a.id.cmp(&b.id));
395 DaemonStatus {
396 version: env!("CARGO_PKG_VERSION").into(),
397 pid: std::process::id(),
398 components,
399 delegations: Default::default(),
400 restart: None,
401 }
402 }
403
404 pub async fn reconcile(&mut self) -> Result<()> {
408 let mut failed = false;
409 for channel in CHANNELS {
410 match channel.account_names() {
411 Ok(names) => self.reconcile_channel(channel, names).await,
412 Err(message) => {
413 failed = true;
414 for id in self.ids(channel) {
415 self.supervisor.stop(&id).await;
416 self.desired.remove(&id);
417 self.inactive.remove(&id);
418 }
419 let mut health = initial_health(channel, "discovery", None, false);
420 health.id = component_id(channel, "discovery-error");
421 health.state = ComponentState::Failed;
422 health.error = Some(message.into());
423 self.inactive.insert(health.id.clone(), health);
424 }
425 }
426 }
427 if failed {
428 bail!("Account discovery failed");
429 }
430 Ok(())
431 }
432
433 fn ids(&self, channel: Channel) -> Vec<String> {
435 let prefix = format!("{}:", channel.name());
436 self.desired
437 .keys()
438 .chain(self.inactive.keys())
439 .filter(|id| id.starts_with(&prefix))
440 .cloned()
441 .collect()
442 }
443
444 async fn reconcile_channel(&mut self, channel: Channel, names: Vec<String>) {
445 let wanted: Vec<String> = names
446 .iter()
447 .map(|name| component_id(channel, name))
448 .collect();
449 for id in self.ids(channel) {
450 if !wanted.contains(&id) {
451 self.supervisor.stop(&id).await;
452 self.desired.remove(&id);
453 self.inactive.remove(&id);
454 }
455 }
456 for name in names {
457 let id = component_id(channel, &name);
458 let loaded = (|| -> Result<_> {
459 let (account, settings) = channel.snapshot(&name)?;
460 Ok((
461 account.ok_or_else(|| anyhow::anyhow!("missing account"))?,
462 settings,
463 ))
464 })();
465 let (credentials, settings) = match loaded {
466 Ok(value) => value,
467 Err(error) => {
468 self.account_error(channel, &name, error).await;
469 continue;
470 }
471 };
472 if self.desired.get(&id) == Some(&(credentials.clone(), settings.clone())) {
473 continue;
474 }
475 self.supervisor.stop(&id).await;
476 self.inactive.remove(&id);
477 let mut health = initial_health(channel, &name, Some(&credentials), settings.enabled);
478 let tool_owner = tool_owner(&credentials, &settings);
479 if tool_owner.is_some() {
480 health.remote_tools = RemoteTools::Owner;
481 }
482 if settings.enabled {
483 let workspace = settings
484 .workspace
485 .clone()
486 .unwrap_or_else(|| self.workspace.clone());
487 if !workspace.is_absolute() || !workspace.is_dir() {
488 health.state = ComponentState::Failed;
489 health.error =
490 Some("Component workspace must be an existing absolute directory".into());
491 self.inactive.insert(id.clone(), health);
492 self.desired.remove(&id);
493 continue;
494 }
495 let link = scv_channels::hub::Link::new(
496 Arc::clone(&self.hub),
497 id.clone(),
498 credentials.owner().map(str::to_owned),
499 );
500 self.supervisor.start(
501 Arc::new(ChannelAccount {
502 channel,
503 account: name.clone(),
504 credentials: credentials.clone(),
505 workspace,
506 socket: self.socket.clone(),
507 tool_owner,
508 link,
509 media: settings.media.clone(),
510 }),
511 health,
512 );
513 } else {
514 health.state = ComponentState::Disabled;
515 self.inactive.insert(id.clone(), health);
516 }
517 self.desired.insert(id, (credentials, settings));
518 }
519 }
520
521 async fn account_error(&mut self, channel: Channel, name: &str, error: anyhow::Error) {
522 if is_busy(&error) {
525 return;
526 }
527 let id = component_id(channel, name);
528 self.supervisor.stop(&id).await;
529 self.desired.remove(&id);
530 let mut health = initial_health(channel, name, None, true);
531 health.state = ComponentState::Failed;
532 health.error = Some(
533 "Invalid or inaccessible account or settings; `scv config show` says which".into(),
534 );
535 self.inactive.insert(id, health);
536 }
537
538 pub async fn control(&mut self, command: DaemonCommand) -> Result<DaemonStatus> {
539 match command {
540 DaemonCommand::Status
542 | DaemonCommand::Delegations { .. }
543 | DaemonCommand::DelegationKill { .. }
544 | DaemonCommand::RestartWhenIdle { .. } => return Ok(self.status()),
545 DaemonCommand::Reload => {}
546 DaemonCommand::ChannelSet {
547 channel,
548 account,
549 enabled,
550 workspace,
551 remote_tools,
552 } => {
553 let channel = Channel::parse(&channel)?;
554 state::validate_name(&account)?;
555 let workspace = match workspace {
556 Some(path) => {
557 let path = PathBuf::from(path);
558 if !path.is_absolute() || !path.is_dir() {
559 bail!("Invalid component workspace");
560 }
561 Some(std::fs::canonicalize(path)?)
562 }
563 None => None,
564 };
565 retry_while_busy(|| {
566 if !channel.signed_in(&account)? {
567 bail!("Account is not logged in");
568 }
569 let mut settings = channel.settings(&account)?;
570 settings.enabled = enabled;
571 if let Some(path) = &workspace {
572 settings.workspace = Some(path.clone());
573 }
574 if let Some(mode) = remote_tools {
575 settings.remote_tools = mode;
576 }
577 channel.save_settings(&account, &settings)
578 })
579 .await?;
580 }
581 DaemonCommand::ChannelLogout { channel, account } => {
582 let channel = Channel::parse(&channel)?;
583 state::validate_name(&account)?;
584 retry_while_busy(|| {
587 let mut settings = channel.settings(&account)?;
588 settings.enabled = false;
589 settings.remote_tools = RemoteTools::None;
590 channel.save_settings(&account, &settings)
591 })
592 .await?;
593 let id = component_id(channel, &account);
594 self.supervisor.stop(&id).await;
595 self.desired.remove(&id);
596 self.inactive.remove(&id);
597 retry_while_busy(|| channel.remove(&account)).await?;
598 }
599 }
600 self.reconcile().await?;
601 Ok(self.status())
602 }
603
604 pub async fn shutdown(&mut self) {
605 self.supervisor.shutdown().await;
606 }
607}
608
609fn max_tool_timeout(workspace: &std::path::Path) -> std::time::Duration {
612 let seconds = crate::Config::load(workspace, crate::ConfigOverrides::default())
613 .map(|config| config.tools.max_timeout_seconds)
614 .unwrap_or_else(|error| {
615 tracing::warn!("Channel owner turns use the default tool timeout ceiling: {error:#}");
616 crate::config::ToolConfig::default().max_timeout_seconds
617 });
618 std::time::Duration::from_secs(seconds)
619}
620
621async fn retry_while_busy<T>(mut operation: impl FnMut() -> Result<T>) -> Result<T> {
625 let deadline = tokio::time::Instant::now() + BUSY_RETRY;
626 loop {
627 match operation() {
628 Err(error) if is_busy(&error) && tokio::time::Instant::now() < deadline => {
629 tokio::time::sleep(Duration::from_millis(10)).await;
630 }
631 result => return result,
632 }
633 }
634}
635
636fn is_busy(error: &anyhow::Error) -> bool {
637 error
638 .downcast_ref::<std::io::Error>()
639 .is_some_and(|error| error.kind() == std::io::ErrorKind::WouldBlock)
640}
641
642fn tool_owner(credentials: &Credentials, settings: &AccountSettings) -> Option<String> {
645 (settings.remote_tools == RemoteTools::Owner)
646 .then(|| credentials.owner().map(str::to_owned))
647 .flatten()
648 .filter(|owner| !owner.is_empty())
649}
650
651fn component_id(channel: Channel, account: &str) -> String {
652 format!("{}:{account}", channel.name())
653}
654
655fn initial_health(
656 channel: Channel,
657 account: &str,
658 credentials: Option<&Credentials>,
659 enabled: bool,
660) -> ComponentHealth {
661 ComponentHealth {
662 id: component_id(channel, account),
663 channel: channel.name().into(),
664 account: account.into(),
665 bot_id: credentials.and_then(Credentials::bot_id),
666 user_id: credentials.and_then(|c| c.owner().map(str::to_owned)),
667 enabled,
668 state: ComponentState::Starting,
669 last_success_unix_seconds: None,
670 error: None,
671 restarts: 0,
672 remote_tools: RemoteTools::None,
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use std::sync::atomic::{AtomicUsize, Ordering};
680
681 struct Fake {
682 starts: Arc<AtomicUsize>,
683 stops: Arc<AtomicUsize>,
684 fail_first: bool,
685 }
686 #[async_trait]
687 impl Component for Fake {
688 async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()> {
689 let attempt = self.starts.fetch_add(1, Ordering::SeqCst);
690 if self.fail_first && attempt == 0 {
691 bail!("secret error must never enter status");
692 }
693 health.contact(true);
694 cancellation.cancelled().await;
695 self.stops.fetch_add(1, Ordering::SeqCst);
696 Ok(())
697 }
698 }
699
700 #[tokio::test]
701 async fn starts_once_recovers_reports_contact_and_joins_before_restoration() {
702 let starts = Arc::new(AtomicUsize::new(0));
703 let stops = Arc::new(AtomicUsize::new(0));
704 let fake = Arc::new(Fake {
705 starts: starts.clone(),
706 stops: stops.clone(),
707 fail_first: true,
708 });
709 let mut supervisor = Supervisor {
710 initial_backoff: Duration::from_millis(10),
711 ..Supervisor::default()
712 };
713 supervisor.start(
714 fake.clone(),
715 initial_health(Channel::Wechat, "test", None, true),
716 );
717 supervisor.start(
718 fake.clone(),
719 initial_health(Channel::Wechat, "test", None, true),
720 );
721 tokio::time::timeout(Duration::from_secs(2), async {
722 loop {
723 if supervisor.health()[0].state == ComponentState::Connected {
724 break;
725 }
726 tokio::time::sleep(Duration::from_millis(1)).await;
727 }
728 })
729 .await
730 .unwrap();
731 let health = &supervisor.health()[0];
732 assert_eq!(starts.load(Ordering::SeqCst), 2);
733 assert_eq!(health.restarts, 1);
734 assert!(health.last_success_unix_seconds.is_some());
735 assert!(health.error.is_none());
736 supervisor.shutdown().await;
737 assert_eq!(stops.load(Ordering::SeqCst), 1);
738 supervisor.start(fake, initial_health(Channel::Wechat, "test", None, true));
739 tokio::time::sleep(Duration::from_millis(20)).await;
740 supervisor.shutdown().await;
741 assert_eq!(starts.load(Ordering::SeqCst), 3);
742 assert_eq!(stops.load(Ordering::SeqCst), 2);
743 }
744
745 #[test]
746 fn credentials_are_not_connection_evidence() {
747 let health = initial_health(Channel::Wechat, "saved", None, true);
748 assert_eq!(health.state, ComponentState::Starting);
749 assert_eq!(health.last_success_unix_seconds, None);
750 }
751
752 #[tokio::test]
753 async fn busy_account_snapshot_preserves_live_work_but_invalid_settings_stop_it() {
754 let starts = Arc::new(AtomicUsize::new(0));
755 let stops = Arc::new(AtomicUsize::new(0));
756 let mut components = Components::new(PathBuf::from("/unused.sock"), PathBuf::from("/"));
757 components.supervisor.start(
758 Arc::new(Fake {
759 starts: starts.clone(),
760 stops: stops.clone(),
761 fail_first: false,
762 }),
763 initial_health(Channel::Wechat, "test", None, true),
764 );
765 tokio::time::timeout(Duration::from_secs(1), async {
766 while starts.load(Ordering::SeqCst) == 0 {
767 tokio::task::yield_now().await;
768 }
769 })
770 .await
771 .unwrap();
772 components
773 .account_error(
774 Channel::Wechat,
775 "test",
776 std::io::Error::from(std::io::ErrorKind::WouldBlock).into(),
777 )
778 .await;
779 assert_eq!(
780 components.status().components[0].state,
781 ComponentState::Connected
782 );
783 assert_eq!(starts.load(Ordering::SeqCst), 1);
784 assert_eq!(stops.load(Ordering::SeqCst), 0);
785 components
786 .account_error(Channel::Wechat, "test", anyhow::anyhow!("invalid settings"))
787 .await;
788 assert_eq!(stops.load(Ordering::SeqCst), 1);
789 assert_eq!(
790 components.status().components[0].state,
791 ComponentState::Failed
792 );
793 }
794
795 struct Stubborn;
796 #[async_trait]
797 impl Component for Stubborn {
798 async fn run(&self, _: CancellationToken, _: HealthReporter) -> Result<()> {
799 std::future::pending().await
800 }
801 }
802
803 #[tokio::test]
804 async fn bounded_stop_aborts_uncooperative_component_and_cancels_backoff() {
805 let mut supervisor = Supervisor {
806 grace: Duration::from_millis(20),
807 ..Supervisor::default()
808 };
809 supervisor.start(
810 Arc::new(Stubborn),
811 initial_health(Channel::Wechat, "stubborn", None, true),
812 );
813 tokio::task::yield_now().await;
814 tokio::time::timeout(Duration::from_secs(1), supervisor.shutdown())
815 .await
816 .unwrap();
817 assert!(supervisor.health().is_empty());
818 let fake = Arc::new(Fake {
819 starts: Arc::new(AtomicUsize::new(0)),
820 stops: Arc::new(AtomicUsize::new(0)),
821 fail_first: true,
822 });
823 supervisor.start(fake, initial_health(Channel::Wechat, "backoff", None, true));
824 tokio::time::sleep(Duration::from_millis(10)).await;
825 assert_eq!(supervisor.health()[0].state, ComponentState::Backoff);
826 assert_eq!(
827 supervisor.health()[0].error.as_deref(),
828 Some("Component stopped unexpectedly; retrying")
829 );
830 tokio::time::timeout(Duration::from_millis(100), supervisor.shutdown())
831 .await
832 .unwrap();
833 }
834
835 #[test]
836 fn remote_tools_require_owner_mode_and_known_owner() {
837 let wechat = |user_id: Option<&str>| {
838 Credentials::Wechat(scv_clawbot::state::Account {
839 token: "token".into(),
840 base_url: "https://example.invalid".into(),
841 bot_id: Some("bot".into()),
842 user_id: user_id.map(Into::into),
843 })
844 };
845 let feishu = |owner: Option<&str>| {
846 Credentials::Feishu(scv_feishu::state::Account {
847 app_id: "cli_a1b2".into(),
848 app_secret: "secret".into(),
849 brand: scv_feishu::state::Brand::Feishu,
850 owner_open_id: owner.map(Into::into),
851 })
852 };
853 let owner = AccountSettings {
854 remote_tools: RemoteTools::Owner,
855 ..Default::default()
856 };
857 assert_eq!(
858 tool_owner(&wechat(Some("owner@im.wechat")), &owner).as_deref(),
859 Some("owner@im.wechat")
860 );
861 assert_eq!(tool_owner(&wechat(None), &owner), None);
862 assert_eq!(tool_owner(&wechat(Some("")), &owner), None);
863 assert_eq!(
864 tool_owner(
865 &wechat(Some("owner@im.wechat")),
866 &AccountSettings::default()
867 ),
868 None
869 );
870 assert_eq!(
871 tool_owner(&feishu(Some("ou_owner")), &owner).as_deref(),
872 Some("ou_owner")
873 );
874 assert_eq!(tool_owner(&feishu(None), &owner), None);
875 assert_eq!(
876 tool_owner(&feishu(Some("ou_owner")), &AccountSettings::default()),
877 None
878 );
879 }
880
881 #[test]
882 fn channels_are_named_and_health_shows_the_app_and_owner() {
883 assert_eq!(Channel::parse("wechat").unwrap(), Channel::Wechat);
884 assert_eq!(Channel::parse("feishu").unwrap(), Channel::Feishu);
885 assert!(Channel::parse("lark").is_err());
886 let credentials = Credentials::Feishu(scv_feishu::state::Account {
887 app_id: "cli_a1b2".into(),
888 app_secret: "secret".into(),
889 brand: scv_feishu::state::Brand::Feishu,
890 owner_open_id: Some("ou_owner".into()),
891 });
892 let health = initial_health(Channel::Feishu, "default", Some(&credentials), true);
893 assert_eq!(health.id, "feishu:default");
894 assert_eq!(health.channel, "feishu");
895 assert_eq!(health.bot_id.as_deref(), Some("cli_a1b2"));
896 assert_eq!(health.user_id.as_deref(), Some("ou_owner"));
897 assert!(!serde_json::to_string(&health).unwrap().contains("secret"));
898 }
899}