1use anyhow::{Result, bail};
4use async_trait::async_trait;
5use scv_channels::state::{self, AccountSettings};
6use scv_channels::{Accounts, ChannelCredentials, ChannelKind};
7use scv_protocol::{
8 ComponentHealth, ComponentState, DaemonCommand, DaemonStatus, RemoteTools, Senders,
9};
10use std::{
11 collections::BTreeMap,
12 path::PathBuf,
13 sync::{Arc, Mutex},
14 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
15};
16use tokio::task::JoinHandle;
17use tokio_util::sync::CancellationToken;
18
19use crate::config::Instance;
20
21const STOP_GRACE: Duration = Duration::from_secs(5);
22const BUSY_RETRY: Duration = Duration::from_secs(5);
24
25#[async_trait]
28pub(crate) trait Component: Send + Sync + 'static {
29 async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()>;
30}
31
32#[derive(Clone)]
33pub(crate) struct HealthReporter(Arc<Mutex<ComponentHealth>>);
34
35impl HealthReporter {
36 pub(crate) fn contact(&self, connected: bool) {
37 let mut health = self
38 .0
39 .lock()
40 .unwrap_or_else(std::sync::PoisonError::into_inner);
41 if matches!(
42 health.state,
43 ComponentState::Stopping | ComponentState::Stopped
44 ) {
45 return;
46 }
47 health.state = if connected {
48 ComponentState::Connected
49 } else {
50 ComponentState::Disconnected
51 };
52 health.error = (!connected).then(|| "Component contact failed".into());
53 if connected {
54 health.last_success_unix_seconds = Some(
55 SystemTime::now()
56 .duration_since(UNIX_EPOCH)
57 .unwrap_or_default()
58 .as_secs(),
59 );
60 }
61 }
62
63 fn transition(&self, state: ComponentState, error: Option<&str>) {
64 let mut health = self
65 .0
66 .lock()
67 .unwrap_or_else(std::sync::PoisonError::into_inner);
68 health.state = state;
69 health.error = error.map(str::to_owned);
70 }
71
72 fn snapshot(&self) -> ComponentHealth {
73 self.0
74 .lock()
75 .unwrap_or_else(std::sync::PoisonError::into_inner)
76 .clone()
77 }
78}
79
80pub(crate) struct Supervisor {
81 tasks: BTreeMap<String, RunningComponent>,
82 grace: Duration,
83 initial_backoff: Duration,
84}
85
86struct RunningComponent {
87 cancellation: CancellationToken,
88 task: JoinHandle<()>,
89 health: HealthReporter,
90}
91
92impl Default for Supervisor {
93 fn default() -> Self {
94 Self {
95 tasks: BTreeMap::new(),
96 grace: STOP_GRACE,
97 initial_backoff: Duration::from_secs(1),
98 }
99 }
100}
101
102impl Supervisor {
103 pub(crate) fn start(&mut self, component: Arc<dyn Component>, health: ComponentHealth) {
105 if self.tasks.contains_key(&health.id) {
106 return;
107 }
108 let id = health.id.clone();
109 let health = HealthReporter(Arc::new(Mutex::new(health)));
110 let cancellation = CancellationToken::new();
111 let cancel = cancellation.clone();
112 let report = health.clone();
113 let initial_backoff = self.initial_backoff;
114 let grace = self.grace;
115 let task = tokio::spawn(async move {
116 let mut delay = initial_backoff;
117 loop {
118 if cancel.is_cancelled() {
119 break;
120 }
121 report.transition(ComponentState::Starting, None);
122 let started = Instant::now();
123 let instance = component.clone();
125 let child_cancel = cancel.clone();
126 let child_report = report.clone();
127 let mut child =
128 tokio::spawn(async move { instance.run(child_cancel, child_report).await });
129 tokio::select! {
130 biased;
131 () = cancel.cancelled() => {
132 report.transition(ComponentState::Stopping, None);
133 if tokio::time::timeout(grace, &mut child).await.is_err() {
134 child.abort();
135 let _ = child.await;
136 }
137 break;
138 }
139 _ = &mut child => {}
140 }
141 report.transition(
142 ComponentState::Backoff,
143 Some("Component stopped unexpectedly; retrying"),
144 );
145 report
146 .0
147 .lock()
148 .unwrap_or_else(std::sync::PoisonError::into_inner)
149 .restarts += 1;
150 if started.elapsed() >= Duration::from_secs(60) {
151 delay = initial_backoff;
152 }
153 tokio::select! {
154 () = cancel.cancelled() => break,
155 () = tokio::time::sleep(delay) => {}
156 }
157 delay = (delay * 2).min(Duration::from_secs(60));
158 }
159 report.transition(ComponentState::Stopped, None);
160 });
161 self.tasks.insert(
162 id,
163 RunningComponent {
164 cancellation,
165 task,
166 health,
167 },
168 );
169 }
170
171 pub(crate) fn health(&self) -> Vec<ComponentHealth> {
172 self.tasks
173 .values()
174 .map(|task| task.health.snapshot())
175 .collect()
176 }
177
178 pub(crate) async fn stop(&mut self, id: &str) {
179 if let Some(running) = self.tasks.get_mut(id) {
180 running.cancellation.cancel();
181 let _ = (&mut running.task).await;
183 }
184 self.tasks.remove(id);
185 }
186
187 pub(crate) async fn shutdown(&mut self) {
188 for task in self.tasks.values() {
189 task.cancellation.cancel();
190 }
191 for id in self.tasks.keys().cloned().collect::<Vec<_>>() {
192 self.stop(&id).await;
193 }
194 }
195}
196
197fn account_names(accounts: &Accounts) -> std::result::Result<Vec<String>, &'static str> {
199 const DISCOVERY: &str =
200 "Account discovery failed; components stopped until configuration is readable";
201 accounts.names().map_err(|_| DISCOVERY)
202}
203
204struct ChannelAccount {
205 kind: ChannelKind,
206 account: String,
207 credentials: ChannelCredentials,
208 settings: AccountSettings,
209 instance: Instance,
211 workspace: PathBuf,
212 tools: bool,
214 link: scv_channels::hub::Link,
215}
216
217#[async_trait]
218impl Component for ChannelAccount {
219 async fn run(&self, cancellation: CancellationToken, health: HealthReporter) -> Result<()> {
220 let tool_turn_timeout = self.tools.then(|| {
221 let turn_timeout =
222 scv_channels::owner_turn_timeout(max_tool_timeout(&self.instance, &self.workspace));
223 tracing::info!(
224 "{} {} owner turns may run up to {} seconds",
225 self.kind.title(),
226 self.account,
227 turn_timeout.as_secs()
228 );
229 turn_timeout
230 });
231 let socket = self.instance.layout.socket();
232 let report = move |connected| health.contact(connected);
233 let episode_gap = self.instance.load(&self.workspace).map_or_else(
234 |error| {
235 tracing::warn!("The chat log uses the default episode gap: {error:#}");
236 crate::config::Config::default().episode_gap()
237 },
238 |config| config.episode_gap(),
239 );
240 let run = scv_channels::AccountRun {
241 layout: &self.instance.layout,
242 account: &self.account,
243 credentials: &self.credentials,
244 settings: &self.settings,
245 owner: self.credentials.owner().filter(|owner| !owner.is_empty()),
246 tool_turn_timeout,
247 episode_gap,
248 workspace: &self.workspace,
249 socket: &socket,
250 link: &self.link,
251 health: &report,
252 };
253 tokio::select! {
255 biased;
256 () = cancellation.cancelled() => Ok(()),
257 result = scv_channels::run(run) => result,
258 }
259 }
260}
261
262pub(crate) struct Components {
263 supervisor: Supervisor,
264 desired: BTreeMap<String, (ChannelCredentials, AccountSettings)>,
266 inactive: BTreeMap<String, ComponentHealth>,
267 instance: Instance,
270 workspace: PathBuf,
271 hub: Arc<scv_channels::hub::Hub>,
273 restarter: Option<Arc<crate::restart::Restarter>>,
275 confirmer: Option<Arc<crate::confirm::Confirmer>>,
277}
278
279impl Components {
280 #[cfg(test)]
281 pub(crate) fn new(instance: Instance, workspace: PathBuf) -> Self {
282 Self::with_hub(instance, workspace, scv_channels::hub::Hub::new(None))
283 }
284
285 pub(crate) fn with_hub(
286 instance: Instance,
287 workspace: PathBuf,
288 hub: Arc<scv_channels::hub::Hub>,
289 ) -> Self {
290 Self {
291 supervisor: Supervisor::default(),
292 desired: BTreeMap::new(),
293 inactive: BTreeMap::new(),
294 instance,
295 workspace,
296 hub,
297 restarter: None,
298 confirmer: None,
299 }
300 }
301
302 fn accounts(&self, kind: ChannelKind) -> Accounts {
304 kind.accounts(&self.instance.layout)
305 }
306
307 pub(crate) fn set_restarter(&mut self, restarter: Arc<crate::restart::Restarter>) {
308 self.restarter = Some(restarter);
309 }
310
311 pub(crate) fn restarter(&self) -> Option<Arc<crate::restart::Restarter>> {
312 self.restarter.clone()
313 }
314
315 pub(crate) fn set_confirmer(&mut self, confirmer: Arc<crate::confirm::Confirmer>) {
316 self.confirmer = Some(confirmer);
317 }
318
319 pub(crate) fn confirmer(&self) -> Option<Arc<crate::confirm::Confirmer>> {
320 self.confirmer.clone()
321 }
322
323 pub(crate) fn status(&self) -> DaemonStatus {
324 let mut components = self.supervisor.health();
325 components.extend(self.inactive.values().cloned());
326 components.sort_by(|a, b| a.id.cmp(&b.id));
327 DaemonStatus {
328 version: env!("CARGO_PKG_VERSION").into(),
329 pid: std::process::id(),
330 components,
331 delegations: scv_protocol::DelegationSummary::default(),
332 restart: None,
333 confirm: None,
334 }
335 }
336
337 pub(crate) async fn reconcile(&mut self) -> Result<()> {
341 let mut failed = false;
342 for &channel in ChannelKind::ALL {
343 match account_names(&self.accounts(channel)) {
344 Ok(names) => self.reconcile_channel(channel, names).await,
345 Err(message) => {
346 failed = true;
347 for id in self.ids(channel) {
348 self.supervisor.stop(&id).await;
349 self.desired.remove(&id);
350 self.inactive.remove(&id);
351 }
352 let mut health = initial_health(channel, "discovery", None, false);
353 health.id = component_id(channel, "discovery-error");
354 health.state = ComponentState::Failed;
355 health.error = Some(message.into());
356 self.inactive.insert(health.id.clone(), health);
357 }
358 }
359 }
360 if failed {
361 bail!("Account discovery failed");
362 }
363 Ok(())
364 }
365
366 fn ids(&self, channel: ChannelKind) -> Vec<String> {
368 let prefix = format!("{}:", channel.name());
369 self.desired
370 .keys()
371 .chain(self.inactive.keys())
372 .filter(|id| id.starts_with(&prefix))
373 .cloned()
374 .collect()
375 }
376
377 async fn reconcile_channel(&mut self, channel: ChannelKind, names: Vec<String>) {
378 let wanted: Vec<String> = names
379 .iter()
380 .map(|name| component_id(channel, name))
381 .collect();
382 for id in self.ids(channel) {
383 if !wanted.contains(&id) {
384 self.supervisor.stop(&id).await;
385 self.desired.remove(&id);
386 self.inactive.remove(&id);
387 }
388 }
389 for name in names {
390 let id = component_id(channel, &name);
391 let loaded = (|| -> Result<_> {
392 let (account, settings) = self.accounts(channel).snapshot(&name)?;
393 Ok((
394 account.ok_or_else(|| anyhow::anyhow!("missing account"))?,
395 settings,
396 ))
397 })();
398 let (credentials, settings) = match loaded {
399 Ok(value) => value,
400 Err(error) => {
401 self.account_error(channel, &name, error).await;
402 continue;
403 }
404 };
405 if self.desired.get(&id) == Some(&(credentials.clone(), settings.clone())) {
406 continue;
407 }
408 self.supervisor.stop(&id).await;
409 self.inactive.remove(&id);
410 let mut health = initial_health(channel, &name, Some(&credentials), settings.enabled);
411 let tools = tool_owner(&credentials, &settings).is_some();
412 if tools {
413 health.remote_tools = RemoteTools::Owner;
414 }
415 health.senders = Some(settings.senders);
416 if settings.enabled {
417 let workspace = settings
418 .workspace
419 .clone()
420 .unwrap_or_else(|| self.workspace.clone());
421 if !workspace.is_absolute() || !workspace.is_dir() {
422 health.state = ComponentState::Failed;
423 health.error =
424 Some("Component workspace must be an existing absolute directory".into());
425 self.inactive.insert(id.clone(), health);
426 self.desired.remove(&id);
427 continue;
428 }
429 let link = scv_channels::hub::Link::new(
430 Arc::clone(&self.hub),
431 id.clone(),
432 credentials.owner().map(str::to_owned),
433 );
434 self.supervisor.start(
435 Arc::new(ChannelAccount {
436 kind: channel,
437 account: name.clone(),
438 credentials: credentials.clone(),
439 settings: settings.clone(),
440 instance: self.instance.clone(),
441 workspace,
442 tools,
443 link,
444 }),
445 health,
446 );
447 } else {
448 health.state = ComponentState::Disabled;
449 self.inactive.insert(id.clone(), health);
450 }
451 self.desired.insert(id, (credentials, settings));
452 }
453 }
454
455 async fn account_error(&mut self, channel: ChannelKind, name: &str, error: anyhow::Error) {
456 if is_busy(&error) {
459 return;
460 }
461 let id = component_id(channel, name);
462 self.supervisor.stop(&id).await;
463 self.desired.remove(&id);
464 let mut health = initial_health(channel, name, None, true);
465 health.state = ComponentState::Failed;
466 health.error = Some(
467 "Invalid or inaccessible account or settings; `scv config show` says which".into(),
468 );
469 self.inactive.insert(id, health);
470 }
471
472 pub(crate) async fn control(&mut self, command: DaemonCommand) -> Result<DaemonStatus> {
473 match command {
474 DaemonCommand::Status
477 | DaemonCommand::Delegations { .. }
478 | DaemonCommand::DelegationKill { .. }
479 | DaemonCommand::RestartWhenIdle { .. }
480 | DaemonCommand::ConfirmAsk { .. }
481 | DaemonCommand::ConfirmStatus { .. } => return Ok(self.status()),
482 DaemonCommand::Reload => {}
483 DaemonCommand::ChannelSet {
484 channel,
485 account,
486 enabled,
487 workspace,
488 remote_tools,
489 senders,
490 } => {
491 let channel = ChannelKind::parse(&channel)?;
492 state::validate_name(&account)?;
493 let workspace = match workspace {
494 Some(path) => {
495 let path = PathBuf::from(path);
496 if !path.is_absolute() || !path.is_dir() {
497 bail!("Invalid component workspace");
498 }
499 Some(std::fs::canonicalize(path)?)
500 }
501 None => None,
502 };
503 let accounts = self.accounts(channel);
504 retry_while_busy(|| {
505 if !accounts.signed_in(&account)? {
506 bail!("Account is not logged in");
507 }
508 let mut settings = accounts.settings(&account)?;
509 settings.enabled = enabled;
510 if let Some(path) = &workspace {
511 settings.workspace = Some(path.clone());
512 }
513 if let Some(mode) = remote_tools {
514 settings.remote_tools = mode;
515 }
516 if let Some(senders) = senders {
517 settings.senders = senders;
518 }
519 accounts.save_settings(&account, &settings)
520 })
521 .await?;
522 }
523 DaemonCommand::ChannelLogout { channel, account } => {
524 let channel = ChannelKind::parse(&channel)?;
525 state::validate_name(&account)?;
526 let accounts = self.accounts(channel);
530 retry_while_busy(|| {
531 let mut settings = accounts.settings(&account)?;
532 settings.enabled = false;
533 settings.remote_tools = RemoteTools::None;
534 settings.senders = Senders::Owner;
535 accounts.save_settings(&account, &settings)
536 })
537 .await?;
538 let id = component_id(channel, &account);
539 self.supervisor.stop(&id).await;
540 self.desired.remove(&id);
541 self.inactive.remove(&id);
542 retry_while_busy(|| accounts.remove(&account)).await?;
543 }
544 }
545 self.reconcile().await?;
546 Ok(self.status())
547 }
548
549 pub(crate) async fn shutdown(&mut self) {
550 self.supervisor.shutdown().await;
551 }
552}
553
554fn max_tool_timeout(instance: &Instance, workspace: &std::path::Path) -> std::time::Duration {
557 let seconds = instance.load(workspace).map_or_else(
558 |error| {
559 tracing::warn!("Channel owner turns use the default tool timeout ceiling: {error:#}");
560 crate::config::ToolConfig::default().max_timeout_seconds
561 },
562 |config| config.tools.max_timeout_seconds,
563 );
564 std::time::Duration::from_secs(seconds)
565}
566
567async fn retry_while_busy<T>(mut operation: impl FnMut() -> Result<T>) -> Result<T> {
571 let deadline = tokio::time::Instant::now() + BUSY_RETRY;
572 loop {
573 match operation() {
574 Err(error) if is_busy(&error) && tokio::time::Instant::now() < deadline => {
575 tokio::time::sleep(Duration::from_millis(10)).await;
576 }
577 result => return result,
578 }
579 }
580}
581
582fn is_busy(error: &anyhow::Error) -> bool {
583 error
584 .downcast_ref::<std::io::Error>()
585 .is_some_and(|error| error.kind() == std::io::ErrorKind::WouldBlock)
586}
587
588fn tool_owner(credentials: &ChannelCredentials, settings: &AccountSettings) -> Option<String> {
591 (settings.remote_tools == RemoteTools::Owner)
592 .then(|| credentials.owner().map(str::to_owned))
593 .flatten()
594 .filter(|owner| !owner.is_empty())
595}
596
597fn component_id(channel: ChannelKind, account: &str) -> String {
598 format!("{}:{account}", channel.name())
599}
600
601fn initial_health(
602 channel: ChannelKind,
603 account: &str,
604 credentials: Option<&ChannelCredentials>,
605 enabled: bool,
606) -> ComponentHealth {
607 ComponentHealth {
608 id: component_id(channel, account),
609 channel: channel.name().into(),
610 account: account.into(),
611 bot_id: credentials.and_then(ChannelCredentials::bot_id),
612 user_id: credentials.and_then(|c| c.owner().map(str::to_owned)),
613 enabled,
614 state: ComponentState::Starting,
615 last_success_unix_seconds: None,
616 error: None,
617 restarts: 0,
618 remote_tools: RemoteTools::None,
619 senders: None,
620 }
621}
622
623#[cfg(test)]
624mod tests;