tail-fin-core 0.6.2

Public session-lifecycle abstractions for tail-fin: Site trait, SessionManager, Credentials, SessionStatus, auth errors. The stable API surface that downstream agents (Flock A2A etc.) consume.
Documentation
//! Minimal single-account session coordinator.
//!
//! Phase 1 scope: register one [`Site`] + one [`BrowserSession`], provide
//! `refresh` / `refresh_if_stale` / `validate` / `reload_cookies` entry
//! points with per-call debouncing.
//!
//! Phase 3+ extends this to multi-account pools, credential vaults,
//! background schedulers, and quarantine / failure-recovery.

use std::sync::Arc;
use std::time::Instant;

use night_fury_core::BrowserSession;
use serde_json::Value;
use tokio::sync::Mutex;

use crate::error::SiteError;
use crate::site::{SessionStatus, Site};

/// A coordinator pairing one [`Site`] implementation with one [`BrowserSession`].
///
/// The manager:
/// - tracks the last refresh timestamp and respects [`Site::refresh_interval_min`]
///   in [`Self::refresh_if_stale`]
/// - exposes the current cookie snapshot for downstream agents
///
/// # Concurrency
///
/// The internal `Mutex<SessionState>` guards only the cookie snapshot and
/// `last_refresh` timestamp — it is **not** held across `Site::refresh` /
/// `Site::validate` calls. Concurrent callers of `refresh` /
/// `refresh_with_seed` / `validate` will therefore drive the underlying
/// browser simultaneously, which is generally unsafe (interleaved
/// navigations, seed cookies overwritten before the refresh navigation
/// reads them). Callers that share a `SessionManager` across tasks must
/// externally serialise these calls. A future release may move the lock
/// to cover the full call; until then, Phase 3+ multi-account pools /
/// schedulers are expected to own the serialisation boundary.
///
/// # Example
///
/// ```ignore
/// use std::sync::Arc;
/// use tail_fin_common::{BrowserSession, SessionManager};
/// use tail_fin_twitter::TwitterSite;
///
/// let session = BrowserSession::builder().build().await?;
/// let manager = SessionManager::new(Arc::new(TwitterSite), session);
///
/// // Force refresh:
/// manager.refresh().await?;
///
/// // Check validity:
/// let status = manager.validate().await?;
/// ```
pub struct SessionManager {
    site: Arc<dyn Site>,
    browser: Arc<BrowserSession>,
    state: Mutex<SessionState>,
}

struct SessionState {
    cookies: Vec<Value>,
    last_refresh: Option<Instant>,
}

impl SessionManager {
    /// Create a manager wrapping a site + browser session. Initial cookie
    /// snapshot is empty — call `refresh()` or `reload_cookies()` to populate.
    pub fn new(site: Arc<dyn Site>, browser: BrowserSession) -> Self {
        Self {
            site,
            browser: Arc::new(browser),
            state: Mutex::new(SessionState {
                cookies: Vec::new(),
                last_refresh: None,
            }),
        }
    }

    /// Return the site this manager is scoped to.
    pub fn site(&self) -> &Arc<dyn Site> {
        &self.site
    }

    /// Return the browser session.
    pub fn browser(&self) -> &Arc<BrowserSession> {
        &self.browser
    }

    /// Force a server-side refresh. Updates internal cookie snapshot.
    ///
    /// Does NOT respect `refresh_interval_min` — for debounced refresh,
    /// use `refresh_if_stale`.
    pub async fn refresh(&self) -> Result<Vec<Value>, SiteError> {
        let cookies = self.site.refresh(&self.browser).await?;
        let mut state = self.state.lock().await;
        state.cookies = cookies.clone();
        state.last_refresh = Some(Instant::now());
        Ok(cookies)
    }

    /// Inject `seed` cookies into the browser, then call [`Site::refresh`].
    ///
    /// Use when the caller already has a (possibly stale) cookie set the
    /// site accepts as "proof of prior session" — the refresh navigation
    /// uses them to obtain fresh server-issued cookies.
    ///
    /// An empty `seed` slice skips the injection and is equivalent to
    /// calling [`SessionManager::refresh`] directly.
    ///
    /// Like `refresh`, this does NOT respect `refresh_interval_min`.
    pub async fn refresh_with_seed(&self, seed: &[Value]) -> Result<Vec<Value>, SiteError> {
        if !seed.is_empty() {
            self.browser.set_cookies(seed.to_vec()).await.map_err(|e| {
                SiteError::RefreshFailed {
                    site: self.site.id(),
                    reason: format!("set_cookies: {e}"),
                }
            })?;
        }
        self.refresh().await
    }

    /// Refresh only if the last refresh is older than `site.refresh_interval_min()`.
    /// Returns `Some(cookies)` on actual refresh, `None` if debounced.
    pub async fn refresh_if_stale(&self) -> Result<Option<Vec<Value>>, SiteError> {
        let should_refresh = {
            let state = self.state.lock().await;
            match state.last_refresh {
                None => true,
                Some(t) => t.elapsed() >= self.site.refresh_interval_min(),
            }
        };

        if should_refresh {
            self.refresh().await.map(Some)
        } else {
            Ok(None)
        }
    }

    /// Validate session liveness via the site's `validate` hook.
    pub async fn validate(&self) -> Result<SessionStatus, SiteError> {
        self.site.validate(&self.browser).await
    }

    /// Snapshot of the current cookies held by this manager.
    /// Initially empty until first `refresh()` or `reload_cookies()`.
    pub async fn cookies(&self) -> Vec<Value> {
        self.state.lock().await.cookies.clone()
    }

    /// Reload cookies from the browser without triggering server-side refresh.
    /// Useful if cookies were updated via some path outside the manager.
    pub async fn reload_cookies(&self) -> Result<Vec<Value>, SiteError> {
        let pattern = self
            .site
            .cookie_domain_patterns()
            .first()
            .copied()
            .unwrap_or("*");
        let cookies = self
            .browser
            .get_cookies_for_domain(pattern)
            .await
            .map_err(|e| SiteError::RefreshFailed {
                site: self.site.id(),
                reason: format!("reload_cookies: {e}"),
            })?;
        let mut state = self.state.lock().await;
        state.cookies = cookies.clone();
        Ok(cookies)
    }
}