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