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}