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}