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