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_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);
22/// How long an operator command waits out a bridge's state commit.
23const BUSY_RETRY: Duration = Duration::from_secs(5);
24
25/// Components must observe cancellation and must not detach child tasks.
26/// Return on failure; the supervisor owns retries and bounded shutdown.
27#[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    /// Idempotent start: replacement must first stop and join the old instance.
104    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                // Catch task panics without letting them take down the daemon or skip retries.
124                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            // The runner owns abort/join of its child, so never abort the runner first.
182            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
197/// Account discovery from saved credentials.
198fn 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    /// The daemon's instance, whose socket the account's sessions use.
210    instance: Instance,
211    workspace: PathBuf,
212    /// The account owner holds remote tools.
213    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        // Cancellation drops the run's I/O and sessions; it spawns no tasks.
254        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    /// Running or disabled accounts by component ID, `<channel>:<account>`.
265    desired: BTreeMap<String, (ChannelCredentials, AccountSettings)>,
266    inactive: BTreeMap<String, ComponentHealth>,
267    /// The daemon's instance: where accounts are saved and the socket their
268    /// sessions use.
269    instance: Instance,
270    workspace: PathBuf,
271    /// What the daemon shares with the channel bridges it runs.
272    hub: Arc<scv_channels::hub::Hub>,
273    /// Plans the daemon's own restarts; set only in the socket daemon.
274    restarter: Option<Arc<crate::restart::Restarter>>,
275    /// Asks the owner yes/no questions; set only in the socket daemon.
276    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    /// One channel's saved accounts in the daemon's instance.
303    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    /// Match running components to every channel's saved accounts. A channel
338    /// whose accounts cannot be discovered stops its own components and
339    /// reports why; other channels keep running.
340    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    /// Component IDs of one channel, running or not.
367    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        // A bridge state commit briefly holds this same lock. Retry next refresh
457        // rather than interrupting healthy work for ordinary lock contention.
458        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            // Delegations, restarts, and questions belong to the connection
475            // handler, which adds them.
476            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                // Persist disabled, tool-free, and owner-only first, so failed
527                // deletion can neither resurrect a live account nor hand a
528                // later login the grant or other senders.
529                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
554/// The longest tool call an owner session in `workspace` may make, from the
555/// configuration its sessions load. Read at each (re)start of the component.
556fn 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
567/// Account transactions fail fast while their lock is held, and a running
568/// bridge holds it for every state commit. Operator commands retry through
569/// that contention instead of failing whenever they coincide with a commit.
570async 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
588/// Only the authenticated account owner may receive tools. Credentials without
589/// a known owner ID grant tools to nobody, even when the setting asks for it.
590fn 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;