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::sync::Arc;
6#[cfg(feature = "prometheus")]
7use std::sync::OnceLock;
8
9use axum::extract::FromRef;
10#[cfg(feature = "prometheus")]
11use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
12use tokio::sync::broadcast;
13use uuid::Uuid;
14
15use tokio_util::sync::CancellationToken;
16use tracing::warn;
17
18use ironflow_artifacts::blob_store::BlobStore;
19use ironflow_auth::jwt::JwtConfig;
20use ironflow_engine::engine::Engine;
21use ironflow_engine::notify::{Event, WorkflowEventBus};
22use ironflow_store::entities::Run;
23use ironflow_store::store::Store;
24
25use crate::error::ApiError;
26use crate::reaper::Reaper;
27use crate::schedule_sync::sync_handler_schedules;
28use crate::schedule_ticker::ScheduleTicker;
29
30/// Global application state.
31///
32/// Holds the shared store (runs, users, API keys, secrets) and engine,
33/// extracted by handlers using Axum's state extraction mechanism.
34///
35/// # Examples
36///
37/// ```no_run
38/// use ironflow_api::state::AppState;
39/// use ironflow_auth::jwt::JwtConfig;
40/// use ironflow_store::prelude::*;
41/// use ironflow_store::store::Store;
42/// use ironflow_engine::engine::Engine;
43/// use ironflow_core::providers::claude::ClaudeCodeProvider;
44/// use std::sync::Arc;
45///
46/// # async fn example() {
47/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
48/// let provider = Arc::new(ClaudeCodeProvider::new());
49/// let engine = Arc::new(Engine::new(store.clone(), provider));
50/// let jwt_config = Arc::new(JwtConfig {
51///     secret: "secret".to_string(),
52///     access_token_ttl_secs: 900,
53///     refresh_token_ttl_secs: 604800,
54///     cookie_domain: None,
55///     cookie_secure: false,
56/// });
57/// let broadcaster = ironflow_api::sse::SseBroadcaster::new();
58/// let state = AppState::new(store, engine, jwt_config, "token".to_string(), broadcaster.sender());
59/// # }
60/// ```
61#[derive(Clone)]
62pub struct AppState {
63    /// The unified backing store for runs, steps, users, API keys, and secrets.
64    pub store: Arc<dyn Store>,
65    /// The workflow orchestration engine.
66    pub engine: Arc<Engine>,
67    /// JWT configuration for auth tokens.
68    pub jwt_config: Arc<JwtConfig>,
69    /// Static token for worker-to-API authentication.
70    pub worker_token: String,
71    /// Broadcast sender for SSE event streaming.
72    pub event_sender: broadcast::Sender<Event>,
73    /// Per-run event bus for real-time workflow monitoring.
74    ///
75    /// When set, the `GET /api/v1/runs/{id}/events` route subscribes to this
76    /// bus and streams [`WorkflowEvent`](ironflow_engine::notify::WorkflowEvent)s
77    /// via SSE. `None` when the engine was not configured with a bus.
78    pub event_bus: Option<WorkflowEventBus>,
79    /// Where artifact bytes live, when artifacts are enabled.
80    ///
81    /// `None` on a deployment that has not configured artifact storage: the
82    /// artifact routes answer `501` and every other endpoint is unaffected.
83    pub blob_store: Option<Arc<dyn BlobStore>>,
84    /// Prometheus metrics handle (only when `prometheus` feature is enabled).
85    #[cfg(feature = "prometheus")]
86    pub prometheus_handle: PrometheusHandle,
87}
88
89impl FromRef<AppState> for Arc<dyn Store> {
90    fn from_ref(state: &AppState) -> Self {
91        Arc::clone(&state.store)
92    }
93}
94
95impl FromRef<AppState> for Arc<JwtConfig> {
96    fn from_ref(state: &AppState) -> Self {
97        Arc::clone(&state.jwt_config)
98    }
99}
100
101#[cfg(feature = "prometheus")]
102impl FromRef<AppState> for PrometheusHandle {
103    fn from_ref(state: &AppState) -> Self {
104        state.prometheus_handle.clone()
105    }
106}
107
108impl AppState {
109    /// Create a new `AppState`.
110    ///
111    /// When the `prometheus` feature is enabled, a global Prometheus recorder
112    /// is installed (once) and its handle is stored in the state.
113    ///
114    /// # Panics
115    ///
116    /// Panics if a Prometheus recorder cannot be installed (should only
117    /// happen if another incompatible recorder was set elsewhere).
118    pub fn new(
119        store: Arc<dyn Store>,
120        engine: Arc<Engine>,
121        jwt_config: Arc<JwtConfig>,
122        worker_token: String,
123        event_sender: broadcast::Sender<Event>,
124    ) -> Self {
125        Self {
126            store,
127            engine,
128            jwt_config,
129            worker_token,
130            event_sender,
131            event_bus: None,
132            blob_store: None,
133            #[cfg(feature = "prometheus")]
134            prometheus_handle: Self::global_prometheus_handle(),
135        }
136    }
137
138    /// Enable artifacts by attaching the backend that holds their bytes.
139    ///
140    /// # Examples
141    ///
142    /// ```no_run
143    /// use std::sync::Arc;
144    ///
145    /// use ironflow_api::state::AppState;
146    /// use ironflow_artifacts::blob_store::BlobStore;
147    /// use ironflow_artifacts::local::LocalBlobStore;
148    ///
149    /// # fn example(state: AppState) -> AppState {
150    /// let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new("/var/lib/ironflow/artifacts"));
151    /// state.with_blob_store(blob)
152    /// # }
153    /// ```
154    pub fn with_blob_store(mut self, blob_store: Arc<dyn BlobStore>) -> Self {
155        self.blob_store = Some(blob_store);
156        self
157    }
158
159    /// Attach a [`WorkflowEventBus`] for per-run SSE streaming.
160    ///
161    /// When set, `GET /api/v1/runs/{id}/events` streams step-level events
162    /// for a specific workflow run. When absent the route returns an empty
163    /// SSE stream (with keep-alive).
164    ///
165    /// # Examples
166    ///
167    /// ```no_run
168    /// use ironflow_api::state::AppState;
169    /// use ironflow_engine::notify::WorkflowEventBus;
170    ///
171    /// # fn example(state: AppState) -> AppState {
172    /// state.with_event_bus(WorkflowEventBus::new())
173    /// # }
174    /// ```
175    pub fn with_event_bus(mut self, bus: WorkflowEventBus) -> Self {
176        self.event_bus = Some(bus);
177        self
178    }
179
180    /// The artifact backend, or a `501` error when artifacts are disabled.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`ApiError::ArtifactStorageUnavailable`] when no backend is attached.
185    pub fn blob_store_or_501(&self) -> Result<&Arc<dyn BlobStore>, ApiError> {
186        self.blob_store
187            .as_ref()
188            .ok_or(ApiError::ArtifactStorageUnavailable)
189    }
190
191    /// Install (or reuse) a global Prometheus recorder and return its handle.
192    #[cfg(feature = "prometheus")]
193    fn global_prometheus_handle() -> PrometheusHandle {
194        static HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
195        HANDLE
196            .get_or_init(|| {
197                PrometheusBuilder::new()
198                    .install_recorder()
199                    .expect("failed to install Prometheus recorder")
200            })
201            .clone()
202    }
203
204    /// Fetch a run by ID or return 404.
205    ///
206    /// # Errors
207    ///
208    /// Returns `ApiError::RunNotFound` if the run does not exist.
209    /// Returns `ApiError::Store` if there is a store error.
210    pub async fn get_run_or_404(&self, id: Uuid) -> Result<Run, ApiError> {
211        self.store
212            .get_run(id)
213            .await
214            .map_err(ApiError::from)?
215            .ok_or(ApiError::RunNotFound(id))
216    }
217
218    /// Spawn the built-in background tasks and return their shared shutdown token.
219    ///
220    /// This starts:
221    /// - **Schedule sync**: seeds DB rows for handler-declared schedules.
222    /// - **Schedule ticker**: polls due schedules and creates runs.
223    /// - **Reaper**: recovers runs abandoned by dead workers.
224    ///
225    /// Call this once after building the `AppState`, before serving requests.
226    /// Drop the returned [`CancellationToken`] (or call `.cancel()`) to stop
227    /// all tasks gracefully.
228    ///
229    /// # Examples
230    ///
231    /// ```no_run
232    /// use ironflow_api::state::AppState;
233    ///
234    /// # async fn example(state: AppState) {
235    /// let shutdown = state.spawn_background_tasks().await;
236    /// // ... serve requests ...
237    /// shutdown.cancel();
238    /// # }
239    /// ```
240    pub async fn spawn_background_tasks(&self) -> CancellationToken {
241        if let Err(err) = sync_handler_schedules(&self.engine, self.store.as_ref()).await {
242            warn!(error = %err, "failed to sync handler-declared schedules");
243        }
244
245        let shutdown = CancellationToken::new();
246        tokio::spawn(ScheduleTicker::new(self.store.clone()).run(shutdown.clone()));
247        tokio::spawn(Reaper::new(self.store.clone(), self.engine.clone()).run(shutdown.clone()));
248        shutdown
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use ironflow_core::providers::claude::ClaudeCodeProvider;
256    use ironflow_store::memory::InMemoryStore;
257    use ironflow_store::store::Store;
258
259    fn test_state() -> AppState {
260        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
261        let provider = Arc::new(ClaudeCodeProvider::new());
262        let engine = Arc::new(Engine::new(store.clone(), provider));
263        let jwt_config = Arc::new(JwtConfig {
264            secret: "test-secret".to_string(),
265            access_token_ttl_secs: 900,
266            refresh_token_ttl_secs: 604800,
267            cookie_domain: None,
268            cookie_secure: false,
269        });
270        let (event_sender, _) = broadcast::channel::<Event>(1);
271        AppState::new(
272            store,
273            engine,
274            jwt_config,
275            "test-worker-token".to_string(),
276            event_sender,
277        )
278    }
279
280    #[test]
281    fn app_state_cloneable() {
282        let state = test_state();
283        let _cloned = state.clone();
284    }
285
286    #[test]
287    fn app_state_from_ref() {
288        let state = test_state();
289        let extracted: Arc<dyn Store> = Arc::from_ref(&state);
290        assert!(Arc::ptr_eq(&extracted, &state.store));
291    }
292}