Skip to main content

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}