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