Skip to main content

ironflow_api/
state.rs

1//! Application state and dependency injection.
2//!
3//! [`AppState`] holds the shared [`Store`] and [`Engine`] used by all handlers.
4
5use std::collections::HashMap;
6use std::sync::Arc;
7#[cfg(feature = "prometheus")]
8use std::sync::OnceLock;
9
10use axum::extract::FromRef;
11#[cfg(feature = "prometheus")]
12use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
13use tokio::sync::broadcast;
14use uuid::Uuid;
15
16use tokio_util::sync::CancellationToken;
17use tracing::warn;
18
19use ironflow_artifacts::blob_store::BlobStore;
20use ironflow_auth::jwt::JwtConfig;
21use ironflow_core::account::{AccountKind, ClaudeSubscriptionKind};
22use ironflow_engine::engine::Engine;
23use ironflow_engine::notify::{Event, WorkflowEventBus};
24use ironflow_store::entities::Run;
25use ironflow_store::store::Store;
26
27use crate::error::ApiError;
28use crate::escalator::Escalator;
29use crate::reaper::Reaper;
30use crate::schedule_sync::{repair_unscheduled_schedules, sync_handler_schedules};
31use crate::schedule_ticker::ScheduleTicker;
32use crate::sse_limiter::{SseLimiter, SseLimits};
33use crate::waker::Waker;
34use crate::worker_registry::WorkerRegistry;
35
36/// Global application state.
37///
38/// Holds the shared store (runs, users, API keys, secrets) and engine,
39/// extracted by handlers using Axum's state extraction mechanism.
40///
41/// # Examples
42///
43/// ```no_run
44/// use ironflow_api::state::AppState;
45/// use ironflow_auth::jwt::JwtConfig;
46/// use ironflow_store::prelude::*;
47/// use ironflow_store::store::Store;
48/// use ironflow_engine::engine::Engine;
49/// use ironflow_core::providers::claude::ClaudeCodeProvider;
50/// use std::sync::Arc;
51///
52/// # async fn example() {
53/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
54/// let provider = Arc::new(ClaudeCodeProvider::new());
55/// let engine = Arc::new(Engine::new(store.clone(), provider));
56/// let jwt_config = Arc::new(JwtConfig {
57///     secret: "secret".to_string(),
58///     access_token_ttl_secs: 900,
59///     refresh_token_ttl_secs: 604800,
60///     cookie_domain: None,
61///     cookie_secure: false,
62/// });
63/// let broadcaster = ironflow_api::sse::SseBroadcaster::new();
64/// let state = AppState::new(store, engine, jwt_config, "token".to_string(), broadcaster.sender());
65/// # }
66/// ```
67#[derive(Clone)]
68pub struct AppState {
69    /// The unified backing store for runs, steps, users, API keys, and secrets.
70    pub store: Arc<dyn Store>,
71    /// The workflow orchestration engine.
72    pub engine: Arc<Engine>,
73    /// JWT configuration for auth tokens.
74    pub jwt_config: Arc<JwtConfig>,
75    /// Static token for worker-to-API authentication.
76    pub worker_token: String,
77    /// Broadcast sender for SSE event streaming.
78    pub event_sender: broadcast::Sender<Event>,
79    /// Per-run event bus for real-time workflow monitoring.
80    ///
81    /// When set, the `GET /api/v1/runs/{id}/events` route subscribes to this
82    /// bus and streams [`WorkflowEvent`](ironflow_engine::notify::WorkflowEvent)s
83    /// via SSE. `None` when the engine was not configured with a bus.
84    pub event_bus: Option<WorkflowEventBus>,
85    /// Where artifact bytes live, when artifacts are enabled.
86    ///
87    /// `None` on a deployment that has not configured artifact storage: the
88    /// artifact routes answer `501` and every other endpoint is unaffected.
89    pub blob_store: Option<Arc<dyn BlobStore>>,
90    /// Registered Provider Account kinds, by id.
91    pub account_kinds: Arc<HashMap<&'static str, Arc<dyn AccountKind>>>,
92    /// Workers that recently asked for a run, with what they can take. Read
93    /// by the run detail route to flag a queued run no worker can take.
94    pub worker_registry: WorkerRegistry,
95    /// Caps on simultaneous SSE streams, per principal and for the server.
96    ///
97    /// Defaults to [`SseLimits::default`]; set with
98    /// [`with_sse_limits`](Self::with_sse_limits).
99    pub sse_limiter: SseLimiter,
100    /// Prometheus metrics handle (only when `prometheus` feature is enabled).
101    #[cfg(feature = "prometheus")]
102    pub prometheus_handle: PrometheusHandle,
103}
104
105/// The kinds every server supports.
106fn default_account_kinds() -> HashMap<&'static str, Arc<dyn AccountKind>> {
107    let claude: Arc<dyn AccountKind> = Arc::new(ClaudeSubscriptionKind::new());
108    HashMap::from([(claude.id(), claude)])
109}
110
111impl FromRef<AppState> for Arc<dyn Store> {
112    fn from_ref(state: &AppState) -> Self {
113        Arc::clone(&state.store)
114    }
115}
116
117impl FromRef<AppState> for Arc<JwtConfig> {
118    fn from_ref(state: &AppState) -> Self {
119        Arc::clone(&state.jwt_config)
120    }
121}
122
123#[cfg(feature = "prometheus")]
124impl FromRef<AppState> for PrometheusHandle {
125    fn from_ref(state: &AppState) -> Self {
126        state.prometheus_handle.clone()
127    }
128}
129
130impl AppState {
131    /// Create a new `AppState`.
132    ///
133    /// When the `prometheus` feature is enabled, a global Prometheus recorder
134    /// is installed (once) and its handle is stored in the state.
135    ///
136    /// # Panics
137    ///
138    /// Panics if a Prometheus recorder cannot be installed (should only
139    /// happen if another incompatible recorder was set elsewhere).
140    pub fn new(
141        store: Arc<dyn Store>,
142        engine: Arc<Engine>,
143        jwt_config: Arc<JwtConfig>,
144        worker_token: String,
145        event_sender: broadcast::Sender<Event>,
146    ) -> Self {
147        Self {
148            store,
149            engine,
150            jwt_config,
151            worker_token,
152            event_sender,
153            event_bus: None,
154            blob_store: None,
155            account_kinds: Arc::new(default_account_kinds()),
156            worker_registry: WorkerRegistry::new(),
157            sse_limiter: SseLimiter::default(),
158            #[cfg(feature = "prometheus")]
159            prometheus_handle: Self::global_prometheus_handle(),
160        }
161    }
162
163    /// Enable artifacts by attaching the backend that holds their bytes.
164    ///
165    /// # Examples
166    ///
167    /// ```no_run
168    /// use std::sync::Arc;
169    ///
170    /// use ironflow_api::state::AppState;
171    /// use ironflow_artifacts::blob_store::BlobStore;
172    /// use ironflow_artifacts::local::LocalBlobStore;
173    ///
174    /// # fn example(state: AppState) -> AppState {
175    /// let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new("/var/lib/ironflow/artifacts"));
176    /// state.with_blob_store(blob)
177    /// # }
178    /// ```
179    pub fn with_blob_store(mut self, blob_store: Arc<dyn BlobStore>) -> Self {
180        self.blob_store = Some(blob_store);
181        self
182    }
183
184    /// Register a Provider Account kind, replacing one with the same id.
185    ///
186    /// Tests use it to point the Claude kind at a local stub server.
187    ///
188    /// # Examples
189    ///
190    /// ```no_run
191    /// use std::sync::Arc;
192    ///
193    /// use ironflow_api::state::AppState;
194    /// use ironflow_core::account::ClaudeSubscriptionKind;
195    ///
196    /// # fn example(state: AppState) -> AppState {
197    /// state.with_account_kind(Arc::new(ClaudeSubscriptionKind::with_api_base("http://127.0.0.1:9999")))
198    /// # }
199    /// ```
200    pub fn with_account_kind(mut self, kind: Arc<dyn AccountKind>) -> Self {
201        let mut kinds = (*self.account_kinds).clone();
202        kinds.insert(kind.id(), kind);
203        self.account_kinds = Arc::new(kinds);
204        self
205    }
206
207    /// Attach a [`WorkflowEventBus`] for per-run SSE streaming.
208    ///
209    /// When set, `GET /api/v1/runs/{id}/events` streams step-level events
210    /// for a specific workflow run. When absent the route returns an empty
211    /// SSE stream (with keep-alive).
212    ///
213    /// # Examples
214    ///
215    /// ```no_run
216    /// use ironflow_api::state::AppState;
217    /// use ironflow_engine::notify::WorkflowEventBus;
218    ///
219    /// # fn example(state: AppState) -> AppState {
220    /// state.with_event_bus(WorkflowEventBus::new())
221    /// # }
222    /// ```
223    pub fn with_event_bus(mut self, bus: WorkflowEventBus) -> Self {
224        self.event_bus = Some(bus);
225        self
226    }
227
228    /// Set the caps on simultaneous SSE streams (`GET /api/v1/events`,
229    /// `GET /api/v1/runs/{id}/events`).
230    ///
231    /// A stream past either cap is refused with `429 SSE_CONNECTION_LIMIT`.
232    /// Without this call the [`SseLimits::default`] caps apply.
233    ///
234    /// # Examples
235    ///
236    /// ```no_run
237    /// use ironflow_api::sse_limiter::SseLimits;
238    /// use ironflow_api::state::AppState;
239    ///
240    /// # fn example(state: AppState) -> AppState {
241    /// state.with_sse_limits(SseLimits {
242    ///     per_principal: Some(10),
243    ///     global: None,
244    /// })
245    /// # }
246    /// ```
247    pub fn with_sse_limits(mut self, limits: SseLimits) -> Self {
248        self.sse_limiter = SseLimiter::new(limits);
249        self
250    }
251
252    /// The artifact backend, or a `501` error when artifacts are disabled.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`ApiError::ArtifactStorageUnavailable`] when no backend is attached.
257    pub fn blob_store_or_501(&self) -> Result<&Arc<dyn BlobStore>, ApiError> {
258        self.blob_store
259            .as_ref()
260            .ok_or(ApiError::ArtifactStorageUnavailable)
261    }
262
263    /// Install (or reuse) a global Prometheus recorder and return its handle.
264    #[cfg(feature = "prometheus")]
265    fn global_prometheus_handle() -> PrometheusHandle {
266        static HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
267        HANDLE
268            .get_or_init(|| {
269                PrometheusBuilder::new()
270                    .install_recorder()
271                    .expect("failed to install Prometheus recorder")
272            })
273            .clone()
274    }
275
276    /// Fetch a run by ID or return 404.
277    ///
278    /// # Errors
279    ///
280    /// Returns `ApiError::RunNotFound` if the run does not exist.
281    /// Returns `ApiError::Store` if there is a store error.
282    pub async fn get_run_or_404(&self, id: Uuid) -> Result<Run, ApiError> {
283        self.store
284            .get_run(id)
285            .await
286            .map_err(ApiError::from)?
287            .ok_or(ApiError::RunNotFound(id))
288    }
289
290    /// Spawn the built-in background tasks and return their shared shutdown token.
291    ///
292    /// This starts:
293    /// - **Schedule sync**: seeds DB rows for handler-declared schedules.
294    /// - **Schedule ticker**: polls due schedules and creates runs.
295    /// - **Reaper**: recovers runs abandoned by dead workers.
296    /// - **Escalator**: resolves approval gates that missed their SLA deadline.
297    /// - **Waker**: wakes `Sleeping` runs whose delay elapsed or whose signal
298    ///   wait timed out.
299    ///
300    /// Call this once after building the `AppState`, before serving requests.
301    /// Drop the returned [`CancellationToken`] (or call `.cancel()`) to stop
302    /// all tasks gracefully.
303    ///
304    /// # Examples
305    ///
306    /// ```no_run
307    /// use ironflow_api::state::AppState;
308    ///
309    /// # async fn example(state: AppState) {
310    /// let shutdown = state.spawn_background_tasks().await;
311    /// // ... serve requests ...
312    /// shutdown.cancel();
313    /// # }
314    /// ```
315    pub async fn spawn_background_tasks(&self) -> CancellationToken {
316        if let Err(err) = sync_handler_schedules(&self.engine, self.store.as_ref()).await {
317            warn!(error = %err, "failed to sync handler-declared schedules");
318        }
319        if let Err(err) = repair_unscheduled_schedules(self.store.as_ref()).await {
320            warn!(error = %err, "failed to repair schedules without next trigger");
321        }
322
323        let shutdown = CancellationToken::new();
324        let ticker = ScheduleTicker::new(self.store.clone()).engine(self.engine.clone());
325        tokio::spawn(ticker.run(shutdown.clone()));
326        tokio::spawn(Reaper::new(self.store.clone(), self.engine.clone()).run(shutdown.clone()));
327        tokio::spawn(Escalator::new(self.engine.clone()).run(shutdown.clone()));
328        tokio::spawn(Waker::new(self.engine.clone()).run(shutdown.clone()));
329        shutdown
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use ironflow_core::providers::claude::ClaudeCodeProvider;
337    use ironflow_store::memory::InMemoryStore;
338    use ironflow_store::store::Store;
339
340    fn test_state() -> AppState {
341        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
342        let provider = Arc::new(ClaudeCodeProvider::new());
343        let engine = Arc::new(Engine::new(store.clone(), provider));
344        let jwt_config = Arc::new(JwtConfig {
345            secret: "test-secret".to_string(),
346            access_token_ttl_secs: 900,
347            refresh_token_ttl_secs: 604800,
348            cookie_domain: None,
349            cookie_secure: false,
350        });
351        let (event_sender, _) = broadcast::channel::<Event>(1);
352        AppState::new(
353            store,
354            engine,
355            jwt_config,
356            "test-worker-token".to_string(),
357            event_sender,
358        )
359    }
360
361    #[test]
362    fn app_state_cloneable() {
363        let state = test_state();
364        let _cloned = state.clone();
365    }
366
367    #[test]
368    fn app_state_from_ref() {
369        let state = test_state();
370        let extracted: Arc<dyn Store> = Arc::from_ref(&state);
371        assert!(Arc::ptr_eq(&extracted, &state.store));
372    }
373}