1use 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#[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 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 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 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 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 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}