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};
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}
183
184#[async_trait]
185impl Component for ClawBot {
186    async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()> {
187        scv_clawbot::run_supervised(
188            &self.credentials.token,
189            &self.credentials.base_url,
190            &self.account,
191            &self.workspace,
192            &self.socket,
193            cancellation,
194            Arc::new(move |connected| health.contact(connected)),
195        )
196        .await
197    }
198}
199
200pub(crate) struct Components {
201    supervisor: Supervisor,
202    desired: BTreeMap<String, (Account, AccountSettings)>,
203    inactive: BTreeMap<String, ComponentHealth>,
204    socket: PathBuf,
205    workspace: PathBuf,
206}
207
208impl Components {
209    pub fn new(socket: PathBuf, workspace: PathBuf) -> Self {
210        Self {
211            supervisor: Supervisor::default(),
212            desired: BTreeMap::new(),
213            inactive: BTreeMap::new(),
214            socket,
215            workspace,
216        }
217    }
218
219    pub fn status(&self) -> DaemonStatus {
220        let mut components = self.supervisor.health();
221        components.extend(self.inactive.values().cloned());
222        components.sort_by(|a, b| a.id.cmp(&b.id));
223        DaemonStatus {
224            version: env!("CARGO_PKG_VERSION").into(),
225            pid: std::process::id(),
226            components,
227        }
228    }
229
230    pub async fn reconcile(&mut self) -> Result<()> {
231        let names = match state::account_names() {
232            Ok(names) => names,
233            Err(_) => {
234                self.supervisor.shutdown().await;
235                self.desired.clear();
236                self.inactive.clear();
237                let mut health = initial_health("discovery", None, false);
238                health.id = "clawbot:discovery-error".into();
239                health.state = ComponentState::Failed;
240                health.error = Some(
241                    "Account discovery failed; components stopped until configuration is readable"
242                        .into(),
243                );
244                self.inactive.insert("discovery-error".into(), health);
245                bail!("Account discovery failed");
246            }
247        };
248        for name in self
249            .desired
250            .keys()
251            .chain(self.inactive.keys())
252            .cloned()
253            .collect::<Vec<_>>()
254        {
255            if !names.contains(&name) {
256                self.supervisor.stop(&format!("clawbot:{name}")).await;
257                self.desired.remove(&name);
258                self.inactive.remove(&name);
259            }
260        }
261        for name in names {
262            let loaded = (|| -> Result<_> {
263                let (account, settings) = state::account_snapshot(&name)?;
264                Ok((
265                    account.ok_or_else(|| anyhow::anyhow!("missing account"))?,
266                    settings,
267                ))
268            })();
269            let (credentials, settings) = match loaded {
270                Ok(value) => value,
271                Err(error) => {
272                    self.account_error(name, error).await;
273                    continue;
274                }
275            };
276            if self.desired.get(&name) == Some(&(credentials.clone(), settings.clone())) {
277                continue;
278            }
279            self.supervisor.stop(&format!("clawbot:{name}")).await;
280            self.inactive.remove(&name);
281            let mut health = initial_health(&name, Some(&credentials), settings.enabled);
282            if settings.enabled {
283                let workspace = settings
284                    .workspace
285                    .clone()
286                    .unwrap_or_else(|| self.workspace.clone());
287                if !workspace.is_absolute() || !workspace.is_dir() {
288                    health.state = ComponentState::Failed;
289                    health.error =
290                        Some("Component workspace must be an existing absolute directory".into());
291                    self.inactive.insert(name.clone(), health);
292                    self.desired.remove(&name);
293                    continue;
294                }
295                self.supervisor.start(
296                    Arc::new(ClawBot {
297                        account: name.clone(),
298                        credentials: credentials.clone(),
299                        workspace,
300                        socket: self.socket.clone(),
301                    }),
302                    health,
303                );
304            } else {
305                health.state = ComponentState::Disabled;
306                self.inactive.insert(name.clone(), health);
307            }
308            self.desired.insert(name, (credentials, settings));
309        }
310        Ok(())
311    }
312
313    async fn account_error(&mut self, name: String, error: anyhow::Error) {
314        // A bridge state commit briefly holds this same lock. Retry next refresh
315        // rather than interrupting healthy work for ordinary lock contention.
316        if error
317            .downcast_ref::<std::io::Error>()
318            .is_some_and(|error| error.kind() == std::io::ErrorKind::WouldBlock)
319        {
320            return;
321        }
322        self.supervisor.stop(&format!("clawbot:{name}")).await;
323        self.desired.remove(&name);
324        let mut health = initial_health(&name, None, true);
325        health.state = ComponentState::Failed;
326        health.error = Some("Invalid or inaccessible account/settings".into());
327        self.inactive.insert(name, health);
328    }
329
330    pub async fn control(&mut self, command: DaemonCommand) -> Result<DaemonStatus> {
331        match command {
332            DaemonCommand::Status => return Ok(self.status()),
333            DaemonCommand::Reload => {}
334            DaemonCommand::ClawbotSet {
335                account,
336                enabled,
337                workspace,
338            } => {
339                state::validate_name(&account)?;
340                if state::account(&account)?.is_none() {
341                    bail!("Account is not logged in");
342                }
343                let mut settings = state::settings(&account)?;
344                settings.enabled = enabled;
345                if let Some(path) = workspace {
346                    let path = PathBuf::from(path);
347                    if !path.is_absolute() || !path.is_dir() {
348                        bail!("Invalid component workspace");
349                    }
350                    settings.workspace = Some(std::fs::canonicalize(path)?);
351                }
352                state::save_settings(&account, &settings)?;
353            }
354            DaemonCommand::ClawbotLogout { account } => {
355                state::validate_name(&account)?;
356                // Persist disabled first, so failed deletion cannot resurrect a live account.
357                let mut settings = state::settings(&account)?;
358                settings.enabled = false;
359                state::save_settings(&account, &settings)?;
360                self.supervisor.stop(&format!("clawbot:{account}")).await;
361                self.desired.remove(&account);
362                self.inactive.remove(&account);
363                state::remove(&account)?;
364            }
365        }
366        self.reconcile().await?;
367        Ok(self.status())
368    }
369
370    pub async fn shutdown(&mut self) {
371        self.supervisor.shutdown().await;
372    }
373}
374
375fn initial_health(account: &str, credentials: Option<&Account>, enabled: bool) -> ComponentHealth {
376    ComponentHealth {
377        id: format!("clawbot:{account}"),
378        account: account.into(),
379        bot_id: credentials.and_then(|a| a.bot_id.clone()),
380        user_id: credentials.and_then(|a| a.user_id.clone()),
381        enabled,
382        state: ComponentState::Starting,
383        last_success_unix_seconds: None,
384        error: None,
385        restarts: 0,
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use std::sync::atomic::{AtomicUsize, Ordering};
393
394    struct Fake {
395        starts: Arc<AtomicUsize>,
396        stops: Arc<AtomicUsize>,
397        fail_first: bool,
398    }
399    #[async_trait]
400    impl Component for Fake {
401        async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()> {
402            let attempt = self.starts.fetch_add(1, Ordering::SeqCst);
403            if self.fail_first && attempt == 0 {
404                bail!("secret error must never enter status");
405            }
406            health.contact(true);
407            cancellation.cancelled().await;
408            self.stops.fetch_add(1, Ordering::SeqCst);
409            Ok(())
410        }
411    }
412
413    #[tokio::test]
414    async fn starts_once_recovers_reports_contact_and_joins_before_restoration() {
415        let starts = Arc::new(AtomicUsize::new(0));
416        let stops = Arc::new(AtomicUsize::new(0));
417        let fake = Arc::new(Fake {
418            starts: starts.clone(),
419            stops: stops.clone(),
420            fail_first: true,
421        });
422        let mut supervisor = Supervisor {
423            initial_backoff: Duration::from_millis(10),
424            ..Supervisor::default()
425        };
426        supervisor.start(fake.clone(), initial_health("test", None, true));
427        supervisor.start(fake.clone(), initial_health("test", None, true));
428        tokio::time::timeout(Duration::from_secs(2), async {
429            loop {
430                if supervisor.health()[0].state == ComponentState::Connected {
431                    break;
432                }
433                tokio::time::sleep(Duration::from_millis(1)).await;
434            }
435        })
436        .await
437        .unwrap();
438        let health = &supervisor.health()[0];
439        assert_eq!(starts.load(Ordering::SeqCst), 2);
440        assert_eq!(health.restarts, 1);
441        assert!(health.last_success_unix_seconds.is_some());
442        assert!(health.error.is_none());
443        supervisor.shutdown().await;
444        assert_eq!(stops.load(Ordering::SeqCst), 1);
445        supervisor.start(fake, initial_health("test", None, true));
446        tokio::time::sleep(Duration::from_millis(20)).await;
447        supervisor.shutdown().await;
448        assert_eq!(starts.load(Ordering::SeqCst), 3);
449        assert_eq!(stops.load(Ordering::SeqCst), 2);
450    }
451
452    #[test]
453    fn credentials_are_not_connection_evidence() {
454        let health = initial_health("saved", None, true);
455        assert_eq!(health.state, ComponentState::Starting);
456        assert_eq!(health.last_success_unix_seconds, None);
457    }
458
459    #[tokio::test]
460    async fn busy_account_snapshot_preserves_live_work_but_invalid_settings_stop_it() {
461        let starts = Arc::new(AtomicUsize::new(0));
462        let stops = Arc::new(AtomicUsize::new(0));
463        let mut components = Components::new(PathBuf::from("/unused.sock"), PathBuf::from("/"));
464        components.supervisor.start(
465            Arc::new(Fake {
466                starts: starts.clone(),
467                stops: stops.clone(),
468                fail_first: false,
469            }),
470            initial_health("test", None, true),
471        );
472        tokio::time::timeout(Duration::from_secs(1), async {
473            while starts.load(Ordering::SeqCst) == 0 {
474                tokio::task::yield_now().await;
475            }
476        })
477        .await
478        .unwrap();
479        components
480            .account_error(
481                "test".into(),
482                std::io::Error::from(std::io::ErrorKind::WouldBlock).into(),
483            )
484            .await;
485        assert_eq!(
486            components.status().components[0].state,
487            ComponentState::Connected
488        );
489        assert_eq!(starts.load(Ordering::SeqCst), 1);
490        assert_eq!(stops.load(Ordering::SeqCst), 0);
491        components
492            .account_error("test".into(), anyhow::anyhow!("invalid settings"))
493            .await;
494        assert_eq!(stops.load(Ordering::SeqCst), 1);
495        assert_eq!(
496            components.status().components[0].state,
497            ComponentState::Failed
498        );
499    }
500
501    struct Stubborn;
502    #[async_trait]
503    impl Component for Stubborn {
504        async fn run(&self, _: CancellationToken, _: HealthReporter) -> Result<()> {
505            std::future::pending().await
506        }
507    }
508
509    #[tokio::test]
510    async fn bounded_stop_aborts_uncooperative_component_and_cancels_backoff() {
511        let mut supervisor = Supervisor {
512            grace: Duration::from_millis(20),
513            ..Supervisor::default()
514        };
515        supervisor.start(Arc::new(Stubborn), initial_health("stubborn", None, true));
516        tokio::task::yield_now().await;
517        tokio::time::timeout(Duration::from_secs(1), supervisor.shutdown())
518            .await
519            .unwrap();
520        assert!(supervisor.health().is_empty());
521        let fake = Arc::new(Fake {
522            starts: Arc::new(AtomicUsize::new(0)),
523            stops: Arc::new(AtomicUsize::new(0)),
524            fail_first: true,
525        });
526        supervisor.start(fake, initial_health("backoff", None, true));
527        tokio::time::sleep(Duration::from_millis(10)).await;
528        assert_eq!(supervisor.health()[0].state, ComponentState::Backoff);
529        assert_eq!(
530            supervisor.health()[0].error.as_deref(),
531            Some("Component stopped unexpectedly; retrying")
532        );
533        tokio::time::timeout(Duration::from_millis(100), supervisor.shutdown())
534            .await
535            .unwrap();
536    }
537}