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_clawbot::state::{self, Account, 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
18/// Components must observe cancellation and must not detach child tasks.
19/// Return on failure; the supervisor owns retries and bounded shutdown.
20#[async_trait]
21pub trait Component: Send + Sync + 'static {
22    async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()>;
23}
24
25#[derive(Clone)]
26pub struct HealthReporter(Arc<Mutex<ComponentHealth>>);
27
28impl HealthReporter {
29    pub fn contact(&self, connected: bool) {
30        let mut health = self.0.lock().unwrap();
31        if matches!(
32            health.state,
33            ComponentState::Stopping | ComponentState::Stopped
34        ) {
35            return;
36        }
37        health.state = if connected {
38            ComponentState::Connected
39        } else {
40            ComponentState::Disconnected
41        };
42        health.error = (!connected).then(|| "Component contact failed".into());
43        if connected {
44            health.last_success_unix_seconds = Some(
45                SystemTime::now()
46                    .duration_since(UNIX_EPOCH)
47                    .unwrap_or_default()
48                    .as_secs(),
49            );
50        }
51    }
52
53    fn transition(&self, state: ComponentState, error: Option<&str>) {
54        let mut health = self.0.lock().unwrap();
55        health.state = state;
56        health.error = error.map(str::to_owned);
57    }
58
59    fn snapshot(&self) -> ComponentHealth {
60        self.0.lock().unwrap().clone()
61    }
62}
63
64pub struct Supervisor {
65    tasks: BTreeMap<String, RunningComponent>,
66    grace: Duration,
67    initial_backoff: Duration,
68}
69
70struct RunningComponent {
71    cancellation: CancellationToken,
72    task: JoinHandle<()>,
73    health: HealthReporter,
74}
75
76impl Default for Supervisor {
77    fn default() -> Self {
78        Self {
79            tasks: BTreeMap::new(),
80            grace: STOP_GRACE,
81            initial_backoff: Duration::from_secs(1),
82        }
83    }
84}
85
86impl Supervisor {
87    /// Idempotent start: replacement must first stop and join the old instance.
88    pub fn start(&mut self, component: Arc<dyn Component>, health: ComponentHealth) {
89        if self.tasks.contains_key(&health.id) {
90            return;
91        }
92        let id = health.id.clone();
93        let health = HealthReporter(Arc::new(Mutex::new(health)));
94        let cancellation = CancellationToken::new();
95        let cancel = cancellation.clone();
96        let report = health.clone();
97        let initial_backoff = self.initial_backoff;
98        let grace = self.grace;
99        let task = tokio::spawn(async move {
100            let mut delay = initial_backoff;
101            loop {
102                if cancel.is_cancelled() {
103                    break;
104                }
105                report.transition(ComponentState::Starting, None);
106                let started = Instant::now();
107                // Catch task panics without letting them take down the daemon or skip retries.
108                let instance = component.clone();
109                let child_cancel = cancel.clone();
110                let child_report = report.clone();
111                let mut child =
112                    tokio::spawn(async move { instance.run(child_cancel, child_report).await });
113                tokio::select! {
114                    biased;
115                    _ = cancel.cancelled() => {
116                        report.transition(ComponentState::Stopping, None);
117                        if tokio::time::timeout(grace, &mut child).await.is_err() {
118                            child.abort();
119                            let _ = child.await;
120                        }
121                        break;
122                    }
123                    _ = &mut child => {}
124                }
125                report.transition(
126                    ComponentState::Backoff,
127                    Some("Component stopped unexpectedly; retrying"),
128                );
129                report.0.lock().unwrap().restarts += 1;
130                if started.elapsed() >= Duration::from_secs(60) {
131                    delay = initial_backoff;
132                }
133                tokio::select! {
134                    _ = cancel.cancelled() => break,
135                    _ = tokio::time::sleep(delay) => {}
136                }
137                delay = (delay * 2).min(Duration::from_secs(60));
138            }
139            report.transition(ComponentState::Stopped, None);
140        });
141        self.tasks.insert(
142            id,
143            RunningComponent {
144                cancellation,
145                task,
146                health,
147            },
148        );
149    }
150
151    pub fn health(&self) -> Vec<ComponentHealth> {
152        self.tasks
153            .values()
154            .map(|task| task.health.snapshot())
155            .collect()
156    }
157
158    pub async fn stop(&mut self, id: &str) {
159        if let Some(running) = self.tasks.get_mut(id) {
160            running.cancellation.cancel();
161            // The runner owns abort/join of its child, so never abort the runner first.
162            let _ = (&mut running.task).await;
163        }
164        self.tasks.remove(id);
165    }
166
167    pub async fn shutdown(&mut self) {
168        for task in self.tasks.values() {
169            task.cancellation.cancel();
170        }
171        for id in self.tasks.keys().cloned().collect::<Vec<_>>() {
172            self.stop(&id).await;
173        }
174    }
175}
176
177struct ClawBot {
178    account: String,
179    credentials: Account,
180    workspace: PathBuf,
181    socket: PathBuf,
182    tool_owner: Option<String>,
183}
184
185#[async_trait]
186impl Component for ClawBot {
187    async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()> {
188        scv_clawbot::run_supervised(
189            &self.credentials.token,
190            &self.credentials.base_url,
191            &self.account,
192            &self.workspace,
193            &self.socket,
194            self.tool_owner.as_deref(),
195            cancellation,
196            Arc::new(move |connected| health.contact(connected)),
197        )
198        .await
199    }
200}
201
202pub(crate) struct Components {
203    supervisor: Supervisor,
204    desired: BTreeMap<String, (Account, AccountSettings)>,
205    inactive: BTreeMap<String, ComponentHealth>,
206    socket: PathBuf,
207    workspace: PathBuf,
208}
209
210impl Components {
211    pub fn new(socket: PathBuf, workspace: PathBuf) -> Self {
212        Self {
213            supervisor: Supervisor::default(),
214            desired: BTreeMap::new(),
215            inactive: BTreeMap::new(),
216            socket,
217            workspace,
218        }
219    }
220
221    pub fn status(&self) -> DaemonStatus {
222        let mut components = self.supervisor.health();
223        components.extend(self.inactive.values().cloned());
224        components.sort_by(|a, b| a.id.cmp(&b.id));
225        DaemonStatus {
226            version: env!("CARGO_PKG_VERSION").into(),
227            pid: std::process::id(),
228            components,
229        }
230    }
231
232    pub async fn reconcile(&mut self) -> Result<()> {
233        let names = match state::account_names() {
234            Ok(names) => names,
235            Err(_) => {
236                self.supervisor.shutdown().await;
237                self.desired.clear();
238                self.inactive.clear();
239                let mut health = initial_health("discovery", None, false);
240                health.id = "clawbot:discovery-error".into();
241                health.state = ComponentState::Failed;
242                health.error = Some(
243                    "Account discovery failed; components stopped until configuration is readable"
244                        .into(),
245                );
246                self.inactive.insert("discovery-error".into(), health);
247                bail!("Account discovery failed");
248            }
249        };
250        for name in self
251            .desired
252            .keys()
253            .chain(self.inactive.keys())
254            .cloned()
255            .collect::<Vec<_>>()
256        {
257            if !names.contains(&name) {
258                self.supervisor.stop(&format!("clawbot:{name}")).await;
259                self.desired.remove(&name);
260                self.inactive.remove(&name);
261            }
262        }
263        for name in names {
264            let loaded = (|| -> Result<_> {
265                let (account, settings) = state::account_snapshot(&name)?;
266                Ok((
267                    account.ok_or_else(|| anyhow::anyhow!("missing account"))?,
268                    settings,
269                ))
270            })();
271            let (credentials, settings) = match loaded {
272                Ok(value) => value,
273                Err(error) => {
274                    self.account_error(name, error).await;
275                    continue;
276                }
277            };
278            if self.desired.get(&name) == Some(&(credentials.clone(), settings.clone())) {
279                continue;
280            }
281            self.supervisor.stop(&format!("clawbot:{name}")).await;
282            self.inactive.remove(&name);
283            let mut health = initial_health(&name, Some(&credentials), settings.enabled);
284            let tool_owner = tool_owner(&credentials, &settings);
285            if tool_owner.is_some() {
286                health.remote_tools = RemoteTools::Owner;
287            }
288            if settings.enabled {
289                let workspace = settings
290                    .workspace
291                    .clone()
292                    .unwrap_or_else(|| self.workspace.clone());
293                if !workspace.is_absolute() || !workspace.is_dir() {
294                    health.state = ComponentState::Failed;
295                    health.error =
296                        Some("Component workspace must be an existing absolute directory".into());
297                    self.inactive.insert(name.clone(), health);
298                    self.desired.remove(&name);
299                    continue;
300                }
301                self.supervisor.start(
302                    Arc::new(ClawBot {
303                        account: name.clone(),
304                        credentials: credentials.clone(),
305                        workspace,
306                        socket: self.socket.clone(),
307                        tool_owner,
308                    }),
309                    health,
310                );
311            } else {
312                health.state = ComponentState::Disabled;
313                self.inactive.insert(name.clone(), health);
314            }
315            self.desired.insert(name, (credentials, settings));
316        }
317        Ok(())
318    }
319
320    async fn account_error(&mut self, name: String, error: anyhow::Error) {
321        // A bridge state commit briefly holds this same lock. Retry next refresh
322        // rather than interrupting healthy work for ordinary lock contention.
323        if error
324            .downcast_ref::<std::io::Error>()
325            .is_some_and(|error| error.kind() == std::io::ErrorKind::WouldBlock)
326        {
327            return;
328        }
329        self.supervisor.stop(&format!("clawbot:{name}")).await;
330        self.desired.remove(&name);
331        let mut health = initial_health(&name, None, true);
332        health.state = ComponentState::Failed;
333        health.error = Some("Invalid or inaccessible account/settings".into());
334        self.inactive.insert(name, health);
335    }
336
337    pub async fn control(&mut self, command: DaemonCommand) -> Result<DaemonStatus> {
338        match command {
339            DaemonCommand::Status => return Ok(self.status()),
340            DaemonCommand::Reload => {}
341            DaemonCommand::ClawbotSet {
342                account,
343                enabled,
344                workspace,
345                remote_tools,
346            } => {
347                state::validate_name(&account)?;
348                if state::account(&account)?.is_none() {
349                    bail!("Account is not logged in");
350                }
351                let mut settings = state::settings(&account)?;
352                settings.enabled = enabled;
353                if let Some(path) = workspace {
354                    let path = PathBuf::from(path);
355                    if !path.is_absolute() || !path.is_dir() {
356                        bail!("Invalid component workspace");
357                    }
358                    settings.workspace = Some(std::fs::canonicalize(path)?);
359                }
360                if let Some(mode) = remote_tools {
361                    settings.remote_tools = mode;
362                }
363                state::save_settings(&account, &settings)?;
364            }
365            DaemonCommand::ClawbotLogout { account } => {
366                state::validate_name(&account)?;
367                // Persist disabled and tool-free first, so failed deletion can
368                // neither resurrect a live account nor hand a later login the grant.
369                let mut settings = state::settings(&account)?;
370                settings.enabled = false;
371                settings.remote_tools = RemoteTools::None;
372                state::save_settings(&account, &settings)?;
373                self.supervisor.stop(&format!("clawbot:{account}")).await;
374                self.desired.remove(&account);
375                self.inactive.remove(&account);
376                state::remove(&account)?;
377            }
378        }
379        self.reconcile().await?;
380        Ok(self.status())
381    }
382
383    pub async fn shutdown(&mut self) {
384        self.supervisor.shutdown().await;
385    }
386}
387
388/// Only the authenticated account owner may receive tools. Credentials without
389/// a known owner ID grant tools to nobody, even when the setting asks for it.
390fn tool_owner(credentials: &Account, settings: &AccountSettings) -> Option<String> {
391    (settings.remote_tools == RemoteTools::Owner)
392        .then(|| credentials.user_id.clone())
393        .flatten()
394        .filter(|owner| !owner.is_empty())
395}
396
397fn initial_health(account: &str, credentials: Option<&Account>, enabled: bool) -> ComponentHealth {
398    ComponentHealth {
399        id: format!("clawbot:{account}"),
400        account: account.into(),
401        bot_id: credentials.and_then(|a| a.bot_id.clone()),
402        user_id: credentials.and_then(|a| a.user_id.clone()),
403        enabled,
404        state: ComponentState::Starting,
405        last_success_unix_seconds: None,
406        error: None,
407        restarts: 0,
408        remote_tools: RemoteTools::None,
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use std::sync::atomic::{AtomicUsize, Ordering};
416
417    struct Fake {
418        starts: Arc<AtomicUsize>,
419        stops: Arc<AtomicUsize>,
420        fail_first: bool,
421    }
422    #[async_trait]
423    impl Component for Fake {
424        async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()> {
425            let attempt = self.starts.fetch_add(1, Ordering::SeqCst);
426            if self.fail_first && attempt == 0 {
427                bail!("secret error must never enter status");
428            }
429            health.contact(true);
430            cancellation.cancelled().await;
431            self.stops.fetch_add(1, Ordering::SeqCst);
432            Ok(())
433        }
434    }
435
436    #[tokio::test]
437    async fn starts_once_recovers_reports_contact_and_joins_before_restoration() {
438        let starts = Arc::new(AtomicUsize::new(0));
439        let stops = Arc::new(AtomicUsize::new(0));
440        let fake = Arc::new(Fake {
441            starts: starts.clone(),
442            stops: stops.clone(),
443            fail_first: true,
444        });
445        let mut supervisor = Supervisor {
446            initial_backoff: Duration::from_millis(10),
447            ..Supervisor::default()
448        };
449        supervisor.start(fake.clone(), initial_health("test", None, true));
450        supervisor.start(fake.clone(), initial_health("test", None, true));
451        tokio::time::timeout(Duration::from_secs(2), async {
452            loop {
453                if supervisor.health()[0].state == ComponentState::Connected {
454                    break;
455                }
456                tokio::time::sleep(Duration::from_millis(1)).await;
457            }
458        })
459        .await
460        .unwrap();
461        let health = &supervisor.health()[0];
462        assert_eq!(starts.load(Ordering::SeqCst), 2);
463        assert_eq!(health.restarts, 1);
464        assert!(health.last_success_unix_seconds.is_some());
465        assert!(health.error.is_none());
466        supervisor.shutdown().await;
467        assert_eq!(stops.load(Ordering::SeqCst), 1);
468        supervisor.start(fake, initial_health("test", None, true));
469        tokio::time::sleep(Duration::from_millis(20)).await;
470        supervisor.shutdown().await;
471        assert_eq!(starts.load(Ordering::SeqCst), 3);
472        assert_eq!(stops.load(Ordering::SeqCst), 2);
473    }
474
475    #[test]
476    fn credentials_are_not_connection_evidence() {
477        let health = initial_health("saved", None, true);
478        assert_eq!(health.state, ComponentState::Starting);
479        assert_eq!(health.last_success_unix_seconds, None);
480    }
481
482    #[tokio::test]
483    async fn busy_account_snapshot_preserves_live_work_but_invalid_settings_stop_it() {
484        let starts = Arc::new(AtomicUsize::new(0));
485        let stops = Arc::new(AtomicUsize::new(0));
486        let mut components = Components::new(PathBuf::from("/unused.sock"), PathBuf::from("/"));
487        components.supervisor.start(
488            Arc::new(Fake {
489                starts: starts.clone(),
490                stops: stops.clone(),
491                fail_first: false,
492            }),
493            initial_health("test", None, true),
494        );
495        tokio::time::timeout(Duration::from_secs(1), async {
496            while starts.load(Ordering::SeqCst) == 0 {
497                tokio::task::yield_now().await;
498            }
499        })
500        .await
501        .unwrap();
502        components
503            .account_error(
504                "test".into(),
505                std::io::Error::from(std::io::ErrorKind::WouldBlock).into(),
506            )
507            .await;
508        assert_eq!(
509            components.status().components[0].state,
510            ComponentState::Connected
511        );
512        assert_eq!(starts.load(Ordering::SeqCst), 1);
513        assert_eq!(stops.load(Ordering::SeqCst), 0);
514        components
515            .account_error("test".into(), anyhow::anyhow!("invalid settings"))
516            .await;
517        assert_eq!(stops.load(Ordering::SeqCst), 1);
518        assert_eq!(
519            components.status().components[0].state,
520            ComponentState::Failed
521        );
522    }
523
524    struct Stubborn;
525    #[async_trait]
526    impl Component for Stubborn {
527        async fn run(&self, _: CancellationToken, _: HealthReporter) -> Result<()> {
528            std::future::pending().await
529        }
530    }
531
532    #[tokio::test]
533    async fn bounded_stop_aborts_uncooperative_component_and_cancels_backoff() {
534        let mut supervisor = Supervisor {
535            grace: Duration::from_millis(20),
536            ..Supervisor::default()
537        };
538        supervisor.start(Arc::new(Stubborn), initial_health("stubborn", None, true));
539        tokio::task::yield_now().await;
540        tokio::time::timeout(Duration::from_secs(1), supervisor.shutdown())
541            .await
542            .unwrap();
543        assert!(supervisor.health().is_empty());
544        let fake = Arc::new(Fake {
545            starts: Arc::new(AtomicUsize::new(0)),
546            stops: Arc::new(AtomicUsize::new(0)),
547            fail_first: true,
548        });
549        supervisor.start(fake, initial_health("backoff", None, true));
550        tokio::time::sleep(Duration::from_millis(10)).await;
551        assert_eq!(supervisor.health()[0].state, ComponentState::Backoff);
552        assert_eq!(
553            supervisor.health()[0].error.as_deref(),
554            Some("Component stopped unexpectedly; retrying")
555        );
556        tokio::time::timeout(Duration::from_millis(100), supervisor.shutdown())
557            .await
558            .unwrap();
559    }
560
561    #[test]
562    fn remote_tools_require_owner_mode_and_known_owner() {
563        let account = |user_id: Option<&str>| Account {
564            token: "token".into(),
565            base_url: "https://example.invalid".into(),
566            bot_id: Some("bot".into()),
567            user_id: user_id.map(Into::into),
568        };
569        let owner = AccountSettings {
570            remote_tools: RemoteTools::Owner,
571            ..Default::default()
572        };
573        assert_eq!(
574            tool_owner(&account(Some("owner@im.wechat")), &owner).as_deref(),
575            Some("owner@im.wechat")
576        );
577        assert_eq!(tool_owner(&account(None), &owner), None);
578        assert_eq!(tool_owner(&account(Some("")), &owner), None);
579        assert_eq!(
580            tool_owner(
581                &account(Some("owner@im.wechat")),
582                &AccountSettings::default()
583            ),
584            None
585        );
586    }
587}