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