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