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::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}