Skip to main content

scv_server/
components.rs

1//! Server-owned lifecycle for every long-running integration.
2
3use 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);
17/// How long an operator command waits out a bridge's state commit.
18const BUSY_RETRY: Duration = Duration::from_secs(5);
19
20/// Components must observe cancellation and must not detach child tasks.
21/// Return on failure; the supervisor owns retries and bounded shutdown.
22#[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    /// Idempotent start: replacement must first stop and join the old instance.
90    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                // Catch task panics without letting them take down the daemon or skip retries.
110                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            // The runner owns abort/join of its child, so never abort the runner first.
164            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/// A chat channel whose accounts the daemon supervises.
180#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181enum Channel {
182    Wechat,
183    Feishu,
184}
185
186const CHANNELS: [Channel; 2] = [Channel::Wechat, Channel::Feishu];
187
188/// A channel account's saved credentials.
189#[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    /// Account discovery from saved credentials.
218    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    /// The authenticated owner, the only sender remote tools may reach.
272    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    /// The bot's identity shown in status: the iLink bot or the Feishu app.
280    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    /// Running or disabled accounts by component ID, `<channel>:<account>`.
355    desired: BTreeMap<String, (Credentials, AccountSettings)>,
356    inactive: BTreeMap<String, ComponentHealth>,
357    socket: PathBuf,
358    workspace: PathBuf,
359    /// What the daemon shares with the channel bridges it runs.
360    hub: Arc<scv_channels::hub::Hub>,
361    /// Plans the daemon's own restarts; set only in the socket daemon.
362    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    /// Match running components to every channel's saved accounts. A channel
405    /// whose accounts cannot be discovered stops its own components and
406    /// reports why; other channels keep running.
407    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    /// Component IDs of one channel, running or not.
434    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        // A bridge state commit briefly holds this same lock. Retry next refresh
523        // rather than interrupting healthy work for ordinary lock contention.
524        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            // Delegations belong to the connection handler, which adds them.
541            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                // Persist disabled and tool-free first, so failed deletion can
585                // neither resurrect a live account nor hand a later login the grant.
586                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
609/// The longest tool call an owner session in `workspace` may make, from the
610/// configuration its sessions load. Read at each (re)start of the component.
611fn 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
621/// Account transactions fail fast while their lock is held, and a running
622/// bridge holds it for every state commit. Operator commands retry through
623/// that contention instead of failing whenever they coincide with a commit.
624async 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
642/// Only the authenticated account owner may receive tools. Credentials without
643/// a known owner ID grant tools to nobody, even when the setting asks for it.
644fn 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}