Skip to main content

ironflow_worker/
api_store.rs

1//! HTTP-based [`RunStore`] that talks to the ironflow API internal routes.
2
3use std::error::Error as _;
4use std::future::Future;
5use std::pin::Pin;
6
7use std::time::Duration;
8
9use bytes::Bytes;
10use chrono::{DateTime, Utc};
11use reqwest::header::CONTENT_TYPE;
12use reqwest::{Client, Error as HttpError, StatusCode};
13use serde::Serialize;
14use serde_json::{Value, from_str, json, to_vec};
15use tokio::time::sleep;
16use tracing::warn;
17use uuid::Uuid;
18
19use ironflow_engine::error::CONCURRENCY_CONFLICT_CODE;
20use ironflow_store::MAX_API_BODY_BYTES;
21use ironflow_store::api_key_store::ApiKeyStore;
22use ironflow_store::approval_delegation_store::ApprovalDelegationStore;
23use ironflow_store::artifact_store::ArtifactStore;
24use ironflow_store::audit_log_store::AuditLogStore;
25use ironflow_store::entities::{
26    ApiKey, ApiKeyUpdate, ApprovalDelegation, Artifact, ArtifactLookup, AuditLogEntry,
27    AuditLogFilter, ConcurrencyGroupBacklog, DelegationFilter, KeyVersionStatus, LeaseRequest,
28    LogEntry, LogFilter, NewApiKey, NewApprovalDelegation, NewArtifact, NewAuditLogEntry,
29    NewLogEntries, NewRun, NewSchedule, NewStep, NewStepDependency, NewUser, Page, PurgePolicy,
30    PurgeableRun, ReapedRun, RotationBatch, RotationRequest, Run, RunCreation, RunFilter, RunStats,
31    RunStatus, RunUpdate, Schedule, ScheduleFiring, ScheduleFiringPlan, ScheduleUpdate, Secret,
32    SecretMetadata, StatsHistoryBucket, StatsHistoryFilter, Step, StepApproval, StepDependency,
33    StepUpdate, User, WorkerCapabilities, WorkflowPause,
34};
35use ironflow_store::entities::{
36    NewProviderAccount, NewProviderAccountObservation, ProviderAccount, ProviderAccountCandidate,
37    ProviderAccountUpdate, ProviderAccountUsagePoint, ProviderAccountWindow,
38};
39use ironflow_store::entities::{
40    NewRefreshToken, NewSignal, Signal, SignalFilter, SignalInsert, SignalStepResolution,
41};
42use ironflow_store::error::StoreError;
43use ironflow_store::log_store::LogStore;
44use ironflow_store::provider_account_store::ProviderAccountStore;
45use ironflow_store::schedule_store::ScheduleStore;
46use ironflow_store::secret_store::SecretStore;
47use ironflow_store::signal_store::SignalStore;
48use ironflow_store::store::RunStore;
49use ironflow_store::user_store::UserStore;
50
51type StoreFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, StoreError>> + Send + 'a>>;
52
53/// API response envelope.
54#[derive(serde::Deserialize, serde::Serialize)]
55struct ApiResponse<T> {
56    data: T,
57}
58
59/// Delays between the attempts of an idempotent update: 1 s, 2 s, then 4 s,
60/// so a transient failure is tried four times in all.
61const RETRY_BACKOFF: [Duration; 3] = [
62    Duration::from_secs(1),
63    Duration::from_secs(2),
64    Duration::from_secs(4),
65];
66
67/// RunStore implementation that communicates with the API server via HTTP.
68#[derive(Debug, Clone)]
69pub struct ApiRunStore {
70    client: Client,
71    base_url: String,
72    token: String,
73    retry_backoff: Vec<Duration>,
74}
75
76impl ApiRunStore {
77    pub fn new(base_url: &str, token: &str) -> Self {
78        let client = Client::builder()
79            .timeout(Duration::from_secs(30))
80            .connect_timeout(Duration::from_secs(5))
81            .build()
82            .expect("failed to build HTTP client");
83
84        Self {
85            client,
86            base_url: base_url.trim_end_matches('/').to_string(),
87            token: token.to_string(),
88            retry_backoff: RETRY_BACKOFF.to_vec(),
89        }
90    }
91
92    /// Replace the delays between retries, so tests do not wait seconds.
93    #[cfg(test)]
94    fn with_retry_backoff(mut self, retry_backoff: Vec<Duration>) -> Self {
95        self.retry_backoff = retry_backoff;
96        self
97    }
98
99    fn internal(&self, path: &str) -> String {
100        format!("{}/api/v1/internal{}", self.base_url, path)
101    }
102
103    fn err(e: HttpError) -> StoreError {
104        StoreError::Database(format!("worker HTTP error: {}", error_chain(&e)))
105    }
106
107    fn status_err(body: &str) -> StoreError {
108        StoreError::Database(format!("worker API error: {body}"))
109    }
110
111    /// `PUT` an idempotent update to an internal route.
112    ///
113    /// The serialized body is checked against [`MAX_API_BODY_BYTES`] before
114    /// anything is sent: the API would refuse it, or the connection would be
115    /// cut mid-upload, and neither tells the size. A `413` is reported the
116    /// same way. A send failure (refused, cut or timed-out connection) or a
117    /// `5xx` is retried after each delay of `retry_backoff`; any other status
118    /// is returned at once.
119    async fn put_json<B: Serialize>(&self, path: &str, body: &B) -> Result<(), StoreError> {
120        let bytes = Bytes::from(to_vec(body).map_err(|e| {
121            StoreError::Database(format!("failed to serialize PUT {path} body: {e}"))
122        })?);
123        let len = bytes.len();
124        if len > MAX_API_BODY_BYTES {
125            return Err(StoreError::Database(format!(
126                "payload too large: PUT {path} is {len} bytes, API limit is {MAX_API_BODY_BYTES} bytes"
127            )));
128        }
129
130        let url = self.internal(path);
131        let mut delays = self.retry_backoff.iter();
132        let mut attempt = 0;
133        loop {
134            attempt += 1;
135            let sent = self
136                .client
137                .put(&url)
138                .bearer_auth(&self.token)
139                .header(CONTENT_TYPE, "application/json")
140                .body(bytes.clone())
141                .send()
142                .await;
143            let failure = match sent {
144                Ok(resp) if resp.status().is_success() => return Ok(()),
145                Ok(resp) if resp.status() == StatusCode::PAYLOAD_TOO_LARGE => {
146                    return Err(StoreError::Database(format!(
147                        "payload too large: API rejected PUT {path} of {len} bytes (HTTP 413)"
148                    )));
149                }
150                Ok(resp) if resp.status().is_server_error() => {
151                    let status = resp.status();
152                    let body = resp.text().await.unwrap_or_default();
153                    format!("HTTP {status}: {body}")
154                }
155                Ok(resp) => {
156                    let body = resp.text().await.unwrap_or_default();
157                    return Err(Self::status_err(&body));
158                }
159                Err(e) => error_chain(&e),
160            };
161            match delays.next() {
162                Some(delay) => {
163                    warn!(
164                        path,
165                        attempt,
166                        error = %failure,
167                        retry_in = ?delay,
168                        "worker API update failed, retrying"
169                    );
170                    sleep(*delay).await;
171                }
172                None => {
173                    return Err(StoreError::Database(format!(
174                        "worker HTTP error: PUT {path} failed after {attempt} attempts: {failure}"
175                    )));
176                }
177            }
178        }
179    }
180}
181
182/// Render an error with its whole `source()` chain: reqwest's own message is
183/// only "error sending request", the cause (connection refused, closed,
184/// timed out) sits underneath.
185fn error_chain(e: &HttpError) -> String {
186    let mut msg = e.to_string();
187    let mut source = e.source();
188    while let Some(cause) = source {
189        msg.push_str(": ");
190        msg.push_str(&cause.to_string());
191        source = cause.source();
192    }
193    msg
194}
195
196impl RunStore for ApiRunStore {
197    fn create_run(&self, req: NewRun) -> StoreFuture<'_, RunCreation> {
198        Box::pin(async move {
199            let resp = self
200                .client
201                .post(self.internal("/runs"))
202                .bearer_auth(&self.token)
203                .json(&req)
204                .send()
205                .await
206                .map_err(Self::err)?;
207
208            // 409 means another active run holds the concurrency key: the
209            // engine turns it into a recorded conflict, never a failure.
210            if resp.status() == StatusCode::CONFLICT {
211                let body = resp.text().await.unwrap_or_default();
212                return Err(match concurrency_conflict(&body) {
213                    Some(conflict) => conflict,
214                    None => Self::status_err(&body),
215                });
216            }
217            if !resp.status().is_success() {
218                let body = resp.text().await.unwrap_or_default();
219                return Err(Self::status_err(&body));
220            }
221
222            let api_resp: ApiResponse<Run> = resp.json().await.map_err(Self::err)?;
223            // The internal endpoint is not idempotent: a success is always a creation.
224            Ok(RunCreation::Created(api_resp.data))
225        })
226    }
227
228    fn find_run_by_idempotency_key(&self, _key: &str) -> StoreFuture<'_, Option<Run>> {
229        Box::pin(async move {
230            Err(StoreError::Database(
231                "find_run_by_idempotency_key not supported via worker API".to_string(),
232            ))
233        })
234    }
235
236    fn get_run(&self, id: Uuid) -> StoreFuture<'_, Option<Run>> {
237        Box::pin(async move {
238            let resp = self
239                .client
240                .get(self.internal(&format!("/runs/{id}")))
241                .bearer_auth(&self.token)
242                .send()
243                .await
244                .map_err(Self::err)?;
245
246            if resp.status() == reqwest::StatusCode::NOT_FOUND {
247                return Ok(None);
248            }
249            if !resp.status().is_success() {
250                let body = resp.text().await.unwrap_or_default();
251                return Err(Self::status_err(&body));
252            }
253
254            #[derive(serde::Deserialize)]
255            struct RunDetail {
256                run: Run,
257            }
258
259            let api_resp: ApiResponse<RunDetail> = resp.json().await.map_err(Self::err)?;
260            Ok(Some(api_resp.data.run))
261        })
262    }
263
264    fn list_runs(
265        &self,
266        _filter: RunFilter,
267        _page: u32,
268        _per_page: u32,
269    ) -> StoreFuture<'_, Page<Run>> {
270        Box::pin(async move {
271            Err(StoreError::Database(
272                "list_runs not supported via worker API".to_string(),
273            ))
274        })
275    }
276
277    fn update_run_status(&self, id: Uuid, new_status: RunStatus) -> StoreFuture<'_, ()> {
278        Box::pin(async move {
279            self.put_json(
280                &format!("/runs/{id}/status"),
281                &json!({ "status": new_status }),
282            )
283            .await
284        })
285    }
286
287    fn update_run(&self, id: Uuid, update: RunUpdate) -> StoreFuture<'_, ()> {
288        Box::pin(async move { self.put_json(&format!("/runs/{id}"), &update).await })
289    }
290
291    fn list_active_descendants(&self, run_id: Uuid) -> StoreFuture<'_, Vec<Run>> {
292        Box::pin(async move {
293            let resp = self
294                .client
295                .get(self.internal(&format!("/runs/{run_id}/descendants")))
296                .bearer_auth(&self.token)
297                .send()
298                .await
299                .map_err(Self::err)?;
300
301            if !resp.status().is_success() {
302                let body = resp.text().await.unwrap_or_default();
303                return Err(Self::status_err(&body));
304            }
305
306            let api_resp: ApiResponse<Vec<Run>> = resp.json().await.map_err(Self::err)?;
307            Ok(api_resp.data)
308        })
309    }
310
311    fn pick_next_pending_for(
312        &self,
313        lease: Option<LeaseRequest>,
314        capabilities: Option<WorkerCapabilities>,
315    ) -> StoreFuture<'_, Option<Run>> {
316        Box::pin(async move {
317            let mut request = self
318                .client
319                .get(self.internal("/runs/next"))
320                .bearer_auth(&self.token);
321
322            if let Some(lease) = lease {
323                request = request.query(&[
324                    ("worker_id", lease.worker_id),
325                    ("lease_ttl_secs", lease.ttl.as_secs().to_string()),
326                ]);
327            }
328            if let Some(capabilities) = capabilities {
329                request = request.query(&capability_query(&capabilities));
330            }
331
332            let resp = request.send().await.map_err(Self::err)?;
333
334            if !resp.status().is_success() {
335                let body = resp.text().await.unwrap_or_default();
336                return Err(Self::status_err(&body));
337            }
338
339            let api_resp: ApiResponse<Option<Run>> = resp.json().await.map_err(Self::err)?;
340            Ok(api_resp.data)
341        })
342    }
343
344    fn renew_lease(&self, id: Uuid, lease: LeaseRequest) -> StoreFuture<'_, DateTime<Utc>> {
345        Box::pin(async move {
346            #[derive(serde::Serialize)]
347            struct RenewLeaseBody {
348                worker_id: String,
349                lease_ttl_secs: u64,
350            }
351
352            #[derive(serde::Deserialize)]
353            struct RenewLeaseData {
354                lease_expires_at: DateTime<Utc>,
355            }
356
357            let resp = self
358                .client
359                .post(self.internal(&format!("/runs/{id}/lease")))
360                .bearer_auth(&self.token)
361                .json(&RenewLeaseBody {
362                    worker_id: lease.worker_id.clone(),
363                    lease_ttl_secs: lease.ttl.as_secs(),
364                })
365                .send()
366                .await
367                .map_err(Self::err)?;
368
369            // 409 means another worker owns the run now, or the run left the
370            // Running state: the caller must abandon it, not retry.
371            if resp.status() == StatusCode::CONFLICT {
372                return Err(StoreError::LeaseLost {
373                    run_id: id,
374                    held_by: None,
375                });
376            }
377            if resp.status() == StatusCode::NOT_FOUND {
378                return Err(StoreError::RunNotFound(id));
379            }
380            if !resp.status().is_success() {
381                let body = resp.text().await.unwrap_or_default();
382                return Err(Self::status_err(&body));
383            }
384
385            let api_resp: ApiResponse<RenewLeaseData> = resp.json().await.map_err(Self::err)?;
386            Ok(api_resp.data.lease_expires_at)
387        })
388    }
389
390    fn count_blocked_runs_by_group(&self) -> StoreFuture<'_, Vec<ConcurrencyGroupBacklog>> {
391        Box::pin(async move {
392            Err(StoreError::Database(
393                "count_blocked_runs_by_group not supported via worker API".to_string(),
394            ))
395        })
396    }
397
398    fn reap_expired_leases(&self, _limit: u32) -> StoreFuture<'_, Vec<ReapedRun>> {
399        // Recovery is an API-server responsibility: the worker has no route for
400        // it and must never requeue runs it does not own.
401        Box::pin(async move { Ok(Vec::new()) })
402    }
403
404    fn claim_due_approval_deadlines(&self, _limit: u32) -> StoreFuture<'_, Vec<Step>> {
405        // Escalation is an API-server responsibility: the worker has no route for
406        // it and must never resolve a gate it does not own.
407        Box::pin(async move { Ok(Vec::new()) })
408    }
409
410    fn claim_due_sleeping_runs(&self, _limit: u32) -> StoreFuture<'_, Vec<Run>> {
411        // Waking sleeping runs is an API-server responsibility: the worker has
412        // no route for it and picks the requeued runs up like any pending run.
413        Box::pin(async move { Ok(Vec::new()) })
414    }
415
416    fn pause_workflow(
417        &self,
418        _workflow_name: &str,
419        _paused_by: Option<Uuid>,
420    ) -> StoreFuture<'_, WorkflowPause> {
421        // Pausing is an operator action served by the API server.
422        Box::pin(async move {
423            Err(StoreError::Database(
424                "pause_workflow not supported via worker API".to_string(),
425            ))
426        })
427    }
428
429    fn resume_workflow(&self, _workflow_name: &str) -> StoreFuture<'_, bool> {
430        Box::pin(async move {
431            Err(StoreError::Database(
432                "resume_workflow not supported via worker API".to_string(),
433            ))
434        })
435    }
436
437    fn list_workflow_pauses(&self) -> StoreFuture<'_, Vec<WorkflowPause>> {
438        // The API server filters paused workflows out of the pick itself.
439        Box::pin(async move {
440            Err(StoreError::Database(
441                "list_workflow_pauses not supported via worker API".to_string(),
442            ))
443        })
444    }
445
446    fn list_purgeable_runs(
447        &self,
448        _policy: &PurgePolicy,
449        _batch_size: u32,
450    ) -> StoreFuture<'_, Vec<PurgeableRun>> {
451        // Purging is an API-server responsibility.
452        Box::pin(async move { Ok(Vec::new()) })
453    }
454
455    fn delete_run(&self, _id: Uuid) -> StoreFuture<'_, Vec<String>> {
456        // Purging is an API-server responsibility.
457        Box::pin(async move { Ok(Vec::new()) })
458    }
459
460    fn create_step(&self, req: NewStep) -> StoreFuture<'_, Step> {
461        Box::pin(async move {
462            let resp = self
463                .client
464                .post(self.internal("/steps"))
465                .bearer_auth(&self.token)
466                .json(&req)
467                .send()
468                .await
469                .map_err(Self::err)?;
470
471            if !resp.status().is_success() {
472                let body = resp.text().await.unwrap_or_default();
473                return Err(Self::status_err(&body));
474            }
475
476            let api_resp: ApiResponse<Step> = resp.json().await.map_err(Self::err)?;
477            Ok(api_resp.data)
478        })
479    }
480
481    fn update_step(&self, id: Uuid, update: StepUpdate) -> StoreFuture<'_, ()> {
482        Box::pin(async move { self.put_json(&format!("/steps/{id}"), &update).await })
483    }
484
485    fn get_step(&self, _id: Uuid) -> StoreFuture<'_, Option<Step>> {
486        // The worker never reads a step back through its store — step lookup
487        // lives on the API side. Return None so this trait method stays total
488        // without adding a dedicated HTTP route the worker doesn't use.
489        Box::pin(async move { Ok(None) })
490    }
491
492    fn record_step_approval(
493        &self,
494        _step_id: Uuid,
495        _approval: StepApproval,
496    ) -> StoreFuture<'_, Step> {
497        Box::pin(async move {
498            Err(StoreError::Database(
499                "record_step_approval not available in worker".to_string(),
500            ))
501        })
502    }
503
504    fn list_steps(&self, run_id: Uuid) -> StoreFuture<'_, Vec<Step>> {
505        Box::pin(async move {
506            let resp = self
507                .client
508                .get(self.internal(&format!("/runs/{run_id}")))
509                .bearer_auth(&self.token)
510                .send()
511                .await
512                .map_err(Self::err)?;
513
514            if !resp.status().is_success() {
515                let body = resp.text().await.unwrap_or_default();
516                return Err(Self::status_err(&body));
517            }
518
519            #[derive(serde::Deserialize)]
520            struct RunDetail {
521                steps: Vec<Step>,
522            }
523
524            let api_resp: ApiResponse<RunDetail> = resp.json().await.map_err(Self::err)?;
525            Ok(api_resp.data.steps)
526        })
527    }
528
529    fn get_stats(&self, _filter: RunFilter) -> StoreFuture<'_, RunStats> {
530        Box::pin(async move {
531            Err(StoreError::Database(
532                "get_stats not supported via worker API".to_string(),
533            ))
534        })
535    }
536
537    fn get_stats_history(
538        &self,
539        _filter: StatsHistoryFilter,
540    ) -> StoreFuture<'_, Vec<StatsHistoryBucket>> {
541        Box::pin(async move {
542            Err(StoreError::Database(
543                "get_stats_history not supported via worker API".to_string(),
544            ))
545        })
546    }
547
548    fn create_step_dependencies(&self, deps: Vec<NewStepDependency>) -> StoreFuture<'_, ()> {
549        Box::pin(async move {
550            if deps.is_empty() {
551                return Ok(());
552            }
553
554            let resp = self
555                .client
556                .post(self.internal("/step-dependencies"))
557                .bearer_auth(&self.token)
558                .json(&deps)
559                .send()
560                .await
561                .map_err(Self::err)?;
562
563            if !resp.status().is_success() {
564                let body = resp.text().await.unwrap_or_default();
565                return Err(Self::status_err(&body));
566            }
567
568            Ok(())
569        })
570    }
571
572    fn list_step_dependencies(&self, _run_id: Uuid) -> StoreFuture<'_, Vec<StepDependency>> {
573        Box::pin(async move {
574            Err(StoreError::Database(
575                "list_step_dependencies not supported via worker API".to_string(),
576            ))
577        })
578    }
579}
580
581impl UserStore for ApiRunStore {
582    fn create_user(&self, _req: NewUser) -> StoreFuture<'_, User> {
583        Box::pin(async move {
584            Err(StoreError::Database(
585                "UserStore not available in worker".to_string(),
586            ))
587        })
588    }
589
590    fn find_user_by_email(&self, _email: &str) -> StoreFuture<'_, Option<User>> {
591        Box::pin(async move { Ok(None) })
592    }
593
594    fn find_user_by_username(&self, _username: &str) -> StoreFuture<'_, Option<User>> {
595        Box::pin(async move { Ok(None) })
596    }
597
598    fn find_user_by_id(&self, _id: Uuid) -> StoreFuture<'_, Option<User>> {
599        Box::pin(async move { Ok(None) })
600    }
601
602    fn count_users(&self) -> StoreFuture<'_, u64> {
603        Box::pin(async move {
604            Err(StoreError::Database(
605                "UserStore not available in worker".to_string(),
606            ))
607        })
608    }
609
610    fn list_users(&self, _page: u32, _per_page: u32) -> StoreFuture<'_, Page<User>> {
611        Box::pin(async move {
612            Err(StoreError::Database(
613                "UserStore not available in worker".to_string(),
614            ))
615        })
616    }
617
618    fn delete_user(&self, _id: Uuid) -> StoreFuture<'_, ()> {
619        Box::pin(async move {
620            Err(StoreError::Database(
621                "UserStore not available in worker".to_string(),
622            ))
623        })
624    }
625
626    fn update_user_role(&self, _id: Uuid, _is_admin: bool) -> StoreFuture<'_, User> {
627        Box::pin(async move {
628            Err(StoreError::Database(
629                "UserStore not available in worker".to_string(),
630            ))
631        })
632    }
633
634    fn update_user_password(&self, _id: Uuid, _password_hash: String) -> StoreFuture<'_, ()> {
635        Box::pin(async move {
636            Err(StoreError::Database(
637                "UserStore not available in worker".to_string(),
638            ))
639        })
640    }
641
642    fn list_user_groups(&self, _user_id: Uuid) -> StoreFuture<'_, Vec<String>> {
643        Box::pin(async move {
644            Err(StoreError::Database(
645                "UserStore not available in worker".to_string(),
646            ))
647        })
648    }
649
650    fn set_user_groups(
651        &self,
652        _user_id: Uuid,
653        _groups: Vec<String>,
654    ) -> StoreFuture<'_, Vec<String>> {
655        Box::pin(async move {
656            Err(StoreError::Database(
657                "UserStore not available in worker".to_string(),
658            ))
659        })
660    }
661
662    fn revoke_user_sessions(&self, _id: Uuid) -> StoreFuture<'_, i64> {
663        Box::pin(async move {
664            Err(StoreError::Database(
665                "UserStore not available in worker".to_string(),
666            ))
667        })
668    }
669
670    fn store_refresh_token(&self, _token: NewRefreshToken) -> StoreFuture<'_, ()> {
671        Box::pin(async move {
672            Err(StoreError::Database(
673                "UserStore not available in worker".to_string(),
674            ))
675        })
676    }
677
678    fn consume_refresh_token(&self, _token_hash: &str) -> StoreFuture<'_, Option<Uuid>> {
679        Box::pin(async move {
680            Err(StoreError::Database(
681                "UserStore not available in worker".to_string(),
682            ))
683        })
684    }
685}
686
687impl ApiKeyStore for ApiRunStore {
688    fn create_api_key(&self, _req: NewApiKey) -> StoreFuture<'_, ApiKey> {
689        Box::pin(async move {
690            Err(StoreError::Database(
691                "ApiKeyStore not available in worker".to_string(),
692            ))
693        })
694    }
695
696    fn find_api_key_by_prefix(&self, _prefix: &str) -> StoreFuture<'_, Option<ApiKey>> {
697        Box::pin(async move { Ok(None) })
698    }
699
700    fn find_api_key_by_id(&self, _id: Uuid) -> StoreFuture<'_, Option<ApiKey>> {
701        Box::pin(async move { Ok(None) })
702    }
703
704    fn list_api_keys_by_user(&self, _user_id: Uuid) -> StoreFuture<'_, Vec<ApiKey>> {
705        Box::pin(async move {
706            Err(StoreError::Database(
707                "ApiKeyStore not available in worker".to_string(),
708            ))
709        })
710    }
711
712    fn update_api_key(&self, _id: Uuid, _update: ApiKeyUpdate) -> StoreFuture<'_, ()> {
713        Box::pin(async move {
714            Err(StoreError::Database(
715                "ApiKeyStore not available in worker".to_string(),
716            ))
717        })
718    }
719
720    fn touch_api_key(&self, _id: Uuid) -> StoreFuture<'_, ()> {
721        Box::pin(async move {
722            Err(StoreError::Database(
723                "ApiKeyStore not available in worker".to_string(),
724            ))
725        })
726    }
727
728    fn delete_api_key(&self, _id: Uuid) -> StoreFuture<'_, ()> {
729        Box::pin(async move {
730            Err(StoreError::Database(
731                "ApiKeyStore not available in worker".to_string(),
732            ))
733        })
734    }
735}
736
737impl AuditLogStore for ApiRunStore {
738    fn append_audit_log(&self, _entry: NewAuditLogEntry) -> StoreFuture<'_, AuditLogEntry> {
739        Box::pin(async move {
740            Err(StoreError::Database(
741                "AuditLogStore not available in worker".to_string(),
742            ))
743        })
744    }
745
746    fn list_audit_logs(
747        &self,
748        _filter: AuditLogFilter,
749        _page: u32,
750        _per_page: u32,
751    ) -> StoreFuture<'_, Page<AuditLogEntry>> {
752        Box::pin(async move {
753            Err(StoreError::Database(
754                "AuditLogStore not available in worker".to_string(),
755            ))
756        })
757    }
758}
759
760impl SecretStore for ApiRunStore {
761    fn get_secret(&self, key: &str) -> StoreFuture<'_, Option<Secret>> {
762        let key = key.to_string();
763        Box::pin(async move {
764            let resp = self
765                .client
766                .get(self.internal(&format!("/secrets/{key}")))
767                .bearer_auth(&self.token)
768                .send()
769                .await
770                .map_err(Self::err)?;
771
772            if resp.status() == reqwest::StatusCode::NOT_FOUND {
773                return Ok(None);
774            }
775
776            if !resp.status().is_success() {
777                let body = resp.text().await.unwrap_or_default();
778                return Err(Self::status_err(&body));
779            }
780
781            let api_resp: ApiResponse<Secret> = resp.json().await.map_err(Self::err)?;
782            Ok(Some(api_resp.data))
783        })
784    }
785
786    fn set_secret(&self, _key: &str, _value: &str) -> StoreFuture<'_, Secret> {
787        Box::pin(async move {
788            Err(StoreError::Database(
789                "SecretStore not available in worker".to_string(),
790            ))
791        })
792    }
793
794    fn delete_secret(&self, _key: &str) -> StoreFuture<'_, bool> {
795        Box::pin(async move {
796            Err(StoreError::Database(
797                "SecretStore not available in worker".to_string(),
798            ))
799        })
800    }
801
802    fn list_secret_keys(&self, _prefix: &str) -> StoreFuture<'_, Vec<String>> {
803        Box::pin(async move {
804            Err(StoreError::Database(
805                "SecretStore not available in worker".to_string(),
806            ))
807        })
808    }
809
810    fn list_secrets(
811        &self,
812        _prefix: &str,
813        _page: u32,
814        _per_page: u32,
815    ) -> StoreFuture<'_, Page<SecretMetadata>> {
816        Box::pin(async move {
817            Err(StoreError::Database(
818                "SecretStore not available in worker".to_string(),
819            ))
820        })
821    }
822
823    fn secret_key_status(&self) -> StoreFuture<'_, KeyVersionStatus> {
824        Box::pin(async move {
825            Err(StoreError::Database(
826                "SecretStore not available in worker".to_string(),
827            ))
828        })
829    }
830
831    fn rotate_secrets(&self, _request: RotationRequest) -> StoreFuture<'_, RotationBatch> {
832        Box::pin(async move {
833            Err(StoreError::Database(
834                "SecretStore not available in worker".to_string(),
835            ))
836        })
837    }
838}
839
840impl LogStore for ApiRunStore {
841    fn append_logs(&self, _entries: NewLogEntries) -> StoreFuture<'_, ()> {
842        // Log persistence is handled by the API server via push_logs.
843        Box::pin(async move { Ok(()) })
844    }
845
846    fn get_logs(
847        &self,
848        _run_id: Uuid,
849        _filter: LogFilter,
850        _cursor: Option<Uuid>,
851        _limit: u32,
852    ) -> StoreFuture<'_, Vec<LogEntry>> {
853        Box::pin(async move {
854            Err(StoreError::Database(
855                "LogStore not available in worker".to_string(),
856            ))
857        })
858    }
859}
860
861impl ArtifactStore for ApiRunStore {
862    fn create_artifact(&self, artifact: NewArtifact) -> StoreFuture<'_, Artifact> {
863        Box::pin(async move {
864            // The worker records an artifact by uploading its bytes, which the
865            // API writes and registers in one call. There is no metadata-only
866            // route, so this must never be reached from the worker.
867            let _ = artifact;
868            Err(StoreError::Database(
869                "create_artifact not supported via worker API — upload the bytes instead"
870                    .to_string(),
871            ))
872        })
873    }
874
875    fn get_artifact(&self, _step_id: Uuid, _name: &str) -> StoreFuture<'_, Option<Artifact>> {
876        // The worker resolves artifacts through find_artifact_for_input, which
877        // needs the run to scope the search. Keep this total rather than add a
878        // route the worker never calls.
879        Box::pin(async move { Ok(None) })
880    }
881
882    fn list_artifacts_for_run(&self, run_id: Uuid) -> StoreFuture<'_, Vec<Artifact>> {
883        Box::pin(async move {
884            let resp = self
885                .client
886                .get(self.internal(&format!("/runs/{run_id}/artifacts")))
887                .bearer_auth(&self.token)
888                .send()
889                .await
890                .map_err(Self::err)?;
891
892            if !resp.status().is_success() {
893                let body = resp.text().await.unwrap_or_default();
894                return Err(Self::status_err(&body));
895            }
896
897            let api_resp: ApiResponse<Vec<Artifact>> = resp.json().await.map_err(Self::err)?;
898            Ok(api_resp.data)
899        })
900    }
901
902    fn find_artifact_for_input(&self, lookup: ArtifactLookup) -> StoreFuture<'_, Option<Artifact>> {
903        Box::pin(async move {
904            // Resolved worker-side from the run's steps and artifacts, so the
905            // matching rule stays in one place instead of being duplicated in a
906            // dedicated route.
907            let steps = self.list_steps(lookup.run_id).await?;
908
909            let Some(producer) = steps
910                .iter()
911                .filter(|step| {
912                    step.attempt == lookup.attempt
913                        && step.name == lookup.step_name
914                        && step.position < lookup.before_position
915                })
916                .max_by_key(|step| step.position)
917            else {
918                return Ok(None);
919            };
920
921            let artifacts = self.list_artifacts_for_run(lookup.run_id).await?;
922            Ok(artifacts
923                .into_iter()
924                .find(|artifact| artifact.step_id == producer.id && artifact.name == lookup.name))
925        })
926    }
927
928    fn find_artifact_by_sha256(&self, _sha256: &str) -> StoreFuture<'_, Option<Artifact>> {
929        Box::pin(async move { Ok(None) })
930    }
931
932    fn count_artifacts_by_storage_key(&self, _storage_key: &str) -> StoreFuture<'_, u64> {
933        Box::pin(async move { Ok(0) })
934    }
935
936    fn list_all_storage_keys(&self) -> StoreFuture<'_, Vec<String>> {
937        Box::pin(async move { Ok(Vec::new()) })
938    }
939}
940
941impl ScheduleStore for ApiRunStore {
942    fn create_schedule(&self, _req: NewSchedule) -> StoreFuture<'_, Schedule> {
943        Box::pin(async move {
944            Err(StoreError::Database(
945                "ScheduleStore not available in worker".to_string(),
946            ))
947        })
948    }
949
950    fn find_schedule_by_id(&self, _id: Uuid) -> StoreFuture<'_, Option<Schedule>> {
951        Box::pin(async move { Ok(None) })
952    }
953
954    fn list_schedules(&self, _page: u32, _per_page: u32) -> StoreFuture<'_, Page<Schedule>> {
955        Box::pin(async move {
956            Err(StoreError::Database(
957                "ScheduleStore not available in worker".to_string(),
958            ))
959        })
960    }
961
962    fn update_schedule(&self, _id: Uuid, _update: ScheduleUpdate) -> StoreFuture<'_, Schedule> {
963        Box::pin(async move {
964            Err(StoreError::Database(
965                "ScheduleStore not available in worker".to_string(),
966            ))
967        })
968    }
969
970    fn delete_schedule(&self, _id: Uuid) -> StoreFuture<'_, ()> {
971        Box::pin(async move {
972            Err(StoreError::Database(
973                "ScheduleStore not available in worker".to_string(),
974            ))
975        })
976    }
977
978    fn list_due_schedules(&self) -> StoreFuture<'_, Vec<Schedule>> {
979        Box::pin(async move {
980            Err(StoreError::Database(
981                "ScheduleStore not available in worker".to_string(),
982            ))
983        })
984    }
985
986    fn fire_due_schedule(
987        &self,
988        _id: Uuid,
989        _due: DateTime<Utc>,
990        _plan: ScheduleFiringPlan,
991    ) -> StoreFuture<'_, Option<ScheduleFiring>> {
992        Box::pin(async move {
993            Err(StoreError::Database(
994                "ScheduleStore not available in worker".to_string(),
995            ))
996        })
997    }
998}
999
1000impl ApprovalDelegationStore for ApiRunStore {
1001    fn create_delegation(
1002        &self,
1003        _req: NewApprovalDelegation,
1004    ) -> StoreFuture<'_, ApprovalDelegation> {
1005        Box::pin(async move {
1006            Err(StoreError::Database(
1007                "ApprovalDelegationStore not available in worker".to_string(),
1008            ))
1009        })
1010    }
1011
1012    fn find_delegation_by_id(&self, _id: Uuid) -> StoreFuture<'_, Option<ApprovalDelegation>> {
1013        Box::pin(async move { Ok(None) })
1014    }
1015
1016    fn list_active_delegations(
1017        &self,
1018        _filter: DelegationFilter,
1019        _page: u32,
1020        _per_page: u32,
1021    ) -> StoreFuture<'_, Page<ApprovalDelegation>> {
1022        Box::pin(async move {
1023            Err(StoreError::Database(
1024                "ApprovalDelegationStore not available in worker".to_string(),
1025            ))
1026        })
1027    }
1028
1029    fn find_active_delegation(
1030        &self,
1031        _from_user_id: Uuid,
1032        _to_user_id: Uuid,
1033        _workflow_name: &str,
1034    ) -> StoreFuture<'_, Option<ApprovalDelegation>> {
1035        Box::pin(async move { Ok(None) })
1036    }
1037
1038    fn delete_delegation(&self, _id: Uuid) -> StoreFuture<'_, ()> {
1039        Box::pin(async move {
1040            Err(StoreError::Database(
1041                "ApprovalDelegationStore not available in worker".to_string(),
1042            ))
1043        })
1044    }
1045}
1046
1047/// Error for the account administration methods the worker never needs.
1048fn account_method_unavailable(method: &str) -> StoreError {
1049    StoreError::Database(format!(
1050        "ProviderAccountStore::{method} not available in worker"
1051    ))
1052}
1053
1054impl ProviderAccountStore for ApiRunStore {
1055    fn create_provider_account(
1056        &self,
1057        _req: NewProviderAccount,
1058    ) -> StoreFuture<'_, ProviderAccount> {
1059        Box::pin(async { Err(account_method_unavailable("create_provider_account")) })
1060    }
1061
1062    fn get_provider_account(&self, _id: Uuid) -> StoreFuture<'_, Option<ProviderAccount>> {
1063        Box::pin(async { Err(account_method_unavailable("get_provider_account")) })
1064    }
1065
1066    fn list_provider_accounts_by_ids(
1067        &self,
1068        _ids: Vec<Uuid>,
1069    ) -> StoreFuture<'_, Vec<ProviderAccount>> {
1070        Box::pin(async { Err(account_method_unavailable("list_provider_accounts_by_ids")) })
1071    }
1072
1073    fn find_provider_account_by_name(
1074        &self,
1075        _name: &str,
1076    ) -> StoreFuture<'_, Option<ProviderAccount>> {
1077        Box::pin(async { Err(account_method_unavailable("find_provider_account_by_name")) })
1078    }
1079
1080    fn list_provider_accounts(
1081        &self,
1082        _kind: Option<String>,
1083        _page: u32,
1084        _per_page: u32,
1085    ) -> StoreFuture<'_, Page<ProviderAccount>> {
1086        Box::pin(async { Err(account_method_unavailable("list_provider_accounts")) })
1087    }
1088
1089    fn update_provider_account(
1090        &self,
1091        _id: Uuid,
1092        _update: ProviderAccountUpdate,
1093    ) -> StoreFuture<'_, ProviderAccount> {
1094        Box::pin(async { Err(account_method_unavailable("update_provider_account")) })
1095    }
1096
1097    fn delete_provider_account(&self, _id: Uuid) -> StoreFuture<'_, bool> {
1098        Box::pin(async { Err(account_method_unavailable("delete_provider_account")) })
1099    }
1100
1101    fn list_provider_account_windows(
1102        &self,
1103        _ids: Vec<Uuid>,
1104    ) -> StoreFuture<'_, Vec<ProviderAccountWindow>> {
1105        Box::pin(async { Err(account_method_unavailable("list_provider_account_windows")) })
1106    }
1107
1108    fn list_provider_account_usage(
1109        &self,
1110        _id: Uuid,
1111        _since: DateTime<Utc>,
1112    ) -> StoreFuture<'_, Vec<ProviderAccountUsagePoint>> {
1113        Box::pin(async { Err(account_method_unavailable("list_provider_account_usage")) })
1114    }
1115
1116    fn record_provider_account_observation(
1117        &self,
1118        id: Uuid,
1119        observation: NewProviderAccountObservation,
1120    ) -> StoreFuture<'_, Vec<ProviderAccountWindow>> {
1121        Box::pin(async move {
1122            let resp = self
1123                .client
1124                .post(self.internal(&format!("/provider-accounts/{id}/observations")))
1125                .bearer_auth(&self.token)
1126                .json(&observation)
1127                .send()
1128                .await
1129                .map_err(Self::err)?;
1130
1131            if resp.status() == StatusCode::NOT_FOUND {
1132                return Err(StoreError::ProviderAccountNotFound(id));
1133            }
1134            if !resp.status().is_success() {
1135                let body = resp.text().await.unwrap_or_default();
1136                return Err(Self::status_err(&body));
1137            }
1138
1139            let api_resp: ApiResponse<Vec<ProviderAccountWindow>> =
1140                resp.json().await.map_err(Self::err)?;
1141            Ok(api_resp.data)
1142        })
1143    }
1144
1145    fn purge_provider_account_usage(&self, _before: DateTime<Utc>) -> StoreFuture<'_, u64> {
1146        Box::pin(async { Err(account_method_unavailable("purge_provider_account_usage")) })
1147    }
1148
1149    fn list_provider_account_candidates(
1150        &self,
1151        kind: String,
1152    ) -> StoreFuture<'_, Vec<ProviderAccountCandidate>> {
1153        Box::pin(async move {
1154            let resp = self
1155                .client
1156                .get(self.internal("/provider-accounts/candidates"))
1157                .bearer_auth(&self.token)
1158                .query(&[("kind", kind)])
1159                .send()
1160                .await
1161                .map_err(Self::err)?;
1162
1163            if !resp.status().is_success() {
1164                let body = resp.text().await.unwrap_or_default();
1165                return Err(Self::status_err(&body));
1166            }
1167
1168            let api_resp: ApiResponse<Vec<ProviderAccountCandidate>> =
1169                resp.json().await.map_err(Self::err)?;
1170            Ok(api_resp.data)
1171        })
1172    }
1173}
1174
1175/// The [`StoreError::ConcurrencyConflict`] carried by a 409 error body, if it
1176/// is one.
1177///
1178/// Any other 409 body yields `None`, so the caller reports it as is.
1179/// Query parameters carrying a worker's capabilities, as comma-separated
1180/// lists. `workflows` is only sent when the worker restricts them; `tags` is
1181/// always sent, even empty, so the API knows the worker routes by tags.
1182pub(crate) fn capability_query(capabilities: &WorkerCapabilities) -> Vec<(&'static str, String)> {
1183    let mut query = Vec::with_capacity(2);
1184    if let Some(ref workflows) = capabilities.workflows {
1185        query.push(("workflows", workflows.join(",")));
1186    }
1187    query.push(("tags", capabilities.tags.join(",")));
1188    query
1189}
1190
1191fn concurrency_conflict(body: &str) -> Option<StoreError> {
1192    #[derive(serde::Deserialize)]
1193    struct Details {
1194        key: String,
1195        run_id: Uuid,
1196    }
1197    #[derive(serde::Deserialize)]
1198    struct Error {
1199        code: String,
1200        details: Details,
1201    }
1202    #[derive(serde::Deserialize)]
1203    struct Envelope {
1204        error: Error,
1205    }
1206
1207    let envelope: Envelope = from_str(body).ok()?;
1208    if envelope.error.code != CONCURRENCY_CONFLICT_CODE {
1209        return None;
1210    }
1211    let Details { key, run_id } = envelope.error.details;
1212    Some(StoreError::ConcurrencyConflict { key, run_id })
1213}
1214
1215/// Error for the signal methods only the API server runs.
1216fn signal_method_unavailable(method: &str) -> StoreError {
1217    StoreError::Database(format!("SignalStore::{method} not available in worker"))
1218}
1219
1220impl SignalStore for ApiRunStore {
1221    fn insert_signal(&self, _signal: NewSignal) -> StoreFuture<'_, SignalInsert> {
1222        Box::pin(async { Err(signal_method_unavailable("insert_signal")) })
1223    }
1224
1225    fn list_signals(
1226        &self,
1227        _filter: SignalFilter,
1228        _page: u32,
1229        _per_page: u32,
1230    ) -> StoreFuture<'_, Page<Signal>> {
1231        Box::pin(async { Err(signal_method_unavailable("list_signals")) })
1232    }
1233
1234    fn list_signals_for_key(
1235        &self,
1236        name: &str,
1237        key: &str,
1238        since: DateTime<Utc>,
1239    ) -> StoreFuture<'_, Vec<Signal>> {
1240        let query = [
1241            ("name", name.to_string()),
1242            ("key", key.to_string()),
1243            ("since", since.to_rfc3339()),
1244        ];
1245        Box::pin(async move {
1246            let resp = self
1247                .client
1248                .get(self.internal("/signals"))
1249                .bearer_auth(&self.token)
1250                .query(&query)
1251                .send()
1252                .await
1253                .map_err(Self::err)?;
1254
1255            if !resp.status().is_success() {
1256                let body = resp.text().await.unwrap_or_default();
1257                return Err(Self::status_err(&body));
1258            }
1259
1260            let api_resp: ApiResponse<Vec<Signal>> = resp.json().await.map_err(Self::err)?;
1261            Ok(api_resp.data)
1262        })
1263    }
1264
1265    fn list_signal_waiters(&self, _name: &str, _key: &str) -> StoreFuture<'_, Vec<Step>> {
1266        Box::pin(async { Err(signal_method_unavailable("list_signal_waiters")) })
1267    }
1268
1269    fn resolve_signal_step(
1270        &self,
1271        step_id: Uuid,
1272        output: Value,
1273    ) -> StoreFuture<'_, SignalStepResolution> {
1274        Box::pin(async move {
1275            let resp = self
1276                .client
1277                .post(self.internal(&format!("/steps/{step_id}/signal-resolution")))
1278                .bearer_auth(&self.token)
1279                .json(&json!({ "output": output }))
1280                .send()
1281                .await
1282                .map_err(Self::err)?;
1283
1284            if resp.status() == StatusCode::NOT_FOUND {
1285                return Err(StoreError::StepNotFound(step_id));
1286            }
1287            if !resp.status().is_success() {
1288                let body = resp.text().await.unwrap_or_default();
1289                return Err(Self::status_err(&body));
1290            }
1291
1292            let api_resp: ApiResponse<SignalStepResolution> =
1293                resp.json().await.map_err(Self::err)?;
1294            Ok(api_resp.data)
1295        })
1296    }
1297
1298    fn suspend_run_on_signal(
1299        &self,
1300        run_id: Uuid,
1301        step_id: Uuid,
1302        deadline_at: DateTime<Utc>,
1303    ) -> StoreFuture<'_, bool> {
1304        Box::pin(async move {
1305            let resp = self
1306                .client
1307                .post(self.internal(&format!("/runs/{run_id}/signal-suspension")))
1308                .bearer_auth(&self.token)
1309                .json(&json!({ "step_id": step_id, "deadline_at": deadline_at }))
1310                .send()
1311                .await
1312                .map_err(Self::err)?;
1313
1314            if !resp.status().is_success() {
1315                let body = resp.text().await.unwrap_or_default();
1316                return Err(Self::status_err(&body));
1317            }
1318
1319            let api_resp: ApiResponse<bool> = resp.json().await.map_err(Self::err)?;
1320            Ok(api_resp.data)
1321        })
1322    }
1323
1324    fn purge_signals(&self, _before: DateTime<Utc>) -> StoreFuture<'_, u64> {
1325        Box::pin(async { Err(signal_method_unavailable("purge_signals")) })
1326    }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use std::collections::HashMap;
1332    use std::sync::Arc;
1333    use std::sync::atomic::{AtomicUsize, Ordering};
1334
1335    use super::*;
1336
1337    use axum::Router;
1338    use axum::routing::put;
1339    use axum::serve;
1340    use ironflow_api::routes::{RouterConfig, create_router};
1341    use ironflow_api::state::AppState;
1342    use ironflow_auth::jwt::JwtConfig;
1343    use ironflow_core::providers::claude::ClaudeCodeProvider;
1344    use ironflow_engine::engine::Engine;
1345    use ironflow_engine::notify::Event;
1346    use ironflow_store::entities::{PARENT_RUN_ID_LABEL, StepStatus, TriggerKind};
1347    use ironflow_store::memory::InMemoryStore;
1348    use tokio::io::AsyncReadExt;
1349    use tokio::net::TcpListener;
1350    use tokio::spawn;
1351    use tokio::sync::broadcast;
1352
1353    use serde_json::json;
1354
1355    #[tokio::test]
1356    async fn create_run_returns_error_on_unreachable_server() {
1357        let store = ApiRunStore::new("http://127.0.0.1:1", "token");
1358        let req = NewRun {
1359            created_by: None,
1360            workflow_name: "test".to_string(),
1361            trigger: TriggerKind::Manual,
1362            payload: json!({}),
1363            max_retries: 0,
1364            handler_version: None,
1365            labels: HashMap::new(),
1366            scheduled_at: None,
1367            idempotency_key: None,
1368            concurrency_key: None,
1369            priority: 0,
1370            concurrency_limits: Vec::new(),
1371            max_cost_usd: None,
1372            worker_tags: Vec::new(),
1373        };
1374        let result = store.create_run(req).await;
1375        assert!(result.is_err());
1376    }
1377
1378    #[tokio::test]
1379    async fn insert_signal_not_available_in_worker() {
1380        let store = ApiRunStore::new("http://localhost:3000", "token");
1381        let result = store
1382            .insert_signal(NewSignal {
1383                name: "demo.done".to_string(),
1384                key: "k1".to_string(),
1385                payload: json!({}),
1386                idempotency_id: None,
1387            })
1388            .await;
1389        match result {
1390            Err(StoreError::Database(msg)) => assert!(msg.contains("not available in worker")),
1391            other => panic!("expected an unavailable-method error, got {other:?}"),
1392        }
1393    }
1394
1395    #[tokio::test]
1396    async fn find_run_by_idempotency_key_not_supported() {
1397        let store = ApiRunStore::new("http://localhost:3000", "token");
1398        let result = store.find_run_by_idempotency_key("github:abc").await;
1399        match result {
1400            Err(StoreError::Database(msg)) => assert!(msg.contains("not supported")),
1401            other => panic!("expected an unsupported-operation error, got {other:?}"),
1402        }
1403    }
1404
1405    #[tokio::test]
1406    async fn list_runs_not_supported() {
1407        let store = ApiRunStore::new("http://localhost:3000", "token");
1408        let result = store
1409            .list_runs(ironflow_store::entities::RunFilter::default(), 0, 10)
1410            .await;
1411        assert!(result.is_err());
1412        match result {
1413            Err(StoreError::Database(msg)) => {
1414                assert!(msg.contains("not supported"));
1415            }
1416            _ => panic!("expected Database error"),
1417        }
1418    }
1419
1420    #[tokio::test]
1421    async fn get_stats_not_supported() {
1422        let store = ApiRunStore::new("http://localhost:3000", "token");
1423        let result = store.get_stats(RunFilter::default()).await;
1424        assert!(result.is_err());
1425        match result {
1426            Err(StoreError::Database(msg)) => {
1427                assert!(msg.contains("not supported"));
1428            }
1429            _ => panic!("expected Database error"),
1430        }
1431    }
1432
1433    #[tokio::test]
1434    async fn agent_role_queries_not_supported() {
1435        let store = ApiRunStore::new("http://localhost:3000", "token");
1436        match store.list_steps_by_agent_role("Reviewer", 1, 20).await {
1437            Err(StoreError::Database(msg)) => assert!(msg.contains("not supported")),
1438            _ => panic!("expected Database error"),
1439        }
1440        match store.get_agent_role_stats(Utc::now()).await {
1441            Err(StoreError::Database(msg)) => assert!(msg.contains("not supported")),
1442            _ => panic!("expected Database error"),
1443        }
1444    }
1445
1446    #[test]
1447    fn api_run_store_clone() {
1448        let store = ApiRunStore::new("http://localhost:3000", "token");
1449        let store2 = store.clone();
1450        assert_eq!(store.base_url, store2.base_url);
1451        assert_eq!(store.token, store2.token);
1452    }
1453
1454    #[test]
1455    fn api_run_store_with_trailing_slash() {
1456        let store = ApiRunStore::new("http://localhost:3000/", "token");
1457        assert_eq!(store.base_url, "http://localhost:3000");
1458    }
1459
1460    #[test]
1461    fn api_run_store_without_trailing_slash() {
1462        let store = ApiRunStore::new("http://localhost:3000", "token");
1463        assert_eq!(store.base_url, "http://localhost:3000");
1464    }
1465
1466    #[test]
1467    fn api_run_store_builds_internal_url() {
1468        let store = ApiRunStore::new("http://localhost:3000", "token");
1469        let url = store.internal("/runs/123");
1470        assert_eq!(url, "http://localhost:3000/api/v1/internal/runs/123");
1471    }
1472    /// Serve the real API router over TCP and return its base URL.
1473    async fn spawn_api() -> String {
1474        let store = Arc::new(InMemoryStore::new());
1475        let engine = Engine::new(store.clone(), Arc::new(ClaudeCodeProvider::new()));
1476        let jwt_config = Arc::new(JwtConfig {
1477            secret: "test-secret".to_string(),
1478            access_token_ttl_secs: 900,
1479            refresh_token_ttl_secs: 604800,
1480            cookie_domain: None,
1481            cookie_secure: false,
1482        });
1483        let (event_sender, _) = broadcast::channel::<Event>(1);
1484        let state = AppState::new(
1485            store,
1486            Arc::new(engine),
1487            jwt_config,
1488            "test-worker-token".to_string(),
1489            event_sender,
1490        );
1491        let config = RouterConfig {
1492            rate_limit_auth: None,
1493            rate_limit_general: None,
1494            ..RouterConfig::default()
1495        };
1496        let router = create_router(state, config);
1497        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1498        let addr = listener.local_addr().unwrap();
1499        spawn(async move {
1500            serve(listener, router).await.unwrap();
1501        });
1502        format!("http://{addr}")
1503    }
1504
1505    fn keyed_run(key: &str) -> NewRun {
1506        NewRun {
1507            created_by: None,
1508            workflow_name: "child".to_string(),
1509            trigger: TriggerKind::Workflow,
1510            payload: json!({}),
1511            max_retries: 0,
1512            handler_version: None,
1513            labels: HashMap::new(),
1514            scheduled_at: None,
1515            idempotency_key: None,
1516            concurrency_key: Some(key.to_string()),
1517            priority: 0,
1518            concurrency_limits: Vec::new(),
1519            max_cost_usd: None,
1520            worker_tags: Vec::new(),
1521        }
1522    }
1523
1524    #[tokio::test]
1525    async fn list_active_descendants_goes_through_the_internal_route() {
1526        let store = ApiRunStore::new(&spawn_api().await, "test-worker-token");
1527        let mut root = keyed_run("root");
1528        (root.trigger, root.concurrency_key) = (TriggerKind::Manual, None);
1529        let root = store.create_run(root).await.unwrap().into_run();
1530        let mut child = keyed_run("child");
1531        child.labels = HashMap::from([(PARENT_RUN_ID_LABEL.to_string(), root.id.to_string())]);
1532        let child = store.create_run(child).await.unwrap().into_run();
1533
1534        let found = store.list_active_descendants(root.id).await.unwrap();
1535        assert_eq!(found.iter().map(|r| r.id).collect::<Vec<_>>(), [child.id]);
1536        assert_eq!(found[0].status.state, RunStatus::Pending);
1537        assert!(
1538            store
1539                .list_active_descendants(Uuid::now_v7())
1540                .await
1541                .unwrap()
1542                .is_empty()
1543        );
1544    }
1545
1546    #[tokio::test]
1547    async fn create_run_maps_409_concurrency_conflict() {
1548        let store = ApiRunStore::new(&spawn_api().await, "test-worker-token");
1549
1550        let holder = store
1551            .create_run(keyed_run("issue:12"))
1552            .await
1553            .expect("the first run takes the key")
1554            .into_run();
1555        assert_eq!(holder.concurrency_key.as_deref(), Some("issue:12"));
1556
1557        match store.create_run(keyed_run("issue:12")).await {
1558            Err(StoreError::ConcurrencyConflict { key, run_id }) => {
1559                assert_eq!(key, "issue:12");
1560                assert_eq!(run_id, holder.id);
1561            }
1562            other => panic!("expected a concurrency conflict, got {other:?}"),
1563        }
1564    }
1565
1566    #[test]
1567    fn concurrency_conflict_reads_the_api_error_envelope() {
1568        let run_id = Uuid::now_v7();
1569        let body = json!({
1570            "error": {
1571                "code": "CONCURRENCY_CONFLICT",
1572                "message": "concurrency key \"issue:12\" is held by active run",
1573                "details": { "key": "issue:12", "run_id": run_id }
1574            }
1575        })
1576        .to_string();
1577
1578        match concurrency_conflict(&body) {
1579            Some(StoreError::ConcurrencyConflict { key, run_id: held }) => {
1580                assert_eq!(key, "issue:12");
1581                assert_eq!(held, run_id);
1582            }
1583            other => panic!("expected a concurrency conflict, got {other:?}"),
1584        }
1585    }
1586
1587    #[test]
1588    fn concurrency_conflict_ignores_other_409_bodies() {
1589        let other_code = json!({
1590            "error": {
1591                "code": "CONFLICT",
1592                "message": "something else",
1593                "details": { "key": "issue:12", "run_id": Uuid::now_v7() }
1594            }
1595        })
1596        .to_string();
1597        assert!(concurrency_conflict(&other_code).is_none());
1598        assert!(concurrency_conflict("not json").is_none());
1599        assert!(concurrency_conflict("").is_none());
1600    }
1601
1602    #[test]
1603    fn capability_query_sends_workflows_and_tags() {
1604        let caps = WorkerCapabilities::new(
1605            Some(vec!["build".to_string(), "deploy".to_string()]),
1606            vec!["gpu".to_string(), "region:eu".to_string()],
1607        );
1608        assert_eq!(
1609            capability_query(&caps),
1610            vec![
1611                ("workflows", "build,deploy".to_string()),
1612                ("tags", "gpu,region:eu".to_string()),
1613            ]
1614        );
1615    }
1616
1617    #[test]
1618    fn capability_query_omits_workflows_when_unrestricted() {
1619        let caps = WorkerCapabilities::new(None, vec!["gpu".to_string()]);
1620        assert_eq!(capability_query(&caps), vec![("tags", "gpu".to_string())]);
1621    }
1622
1623    #[test]
1624    fn capability_query_sends_empty_tags() {
1625        let caps = WorkerCapabilities::new(Some(Vec::new()), Vec::new());
1626        assert_eq!(
1627            capability_query(&caps),
1628            vec![("workflows", String::new()), ("tags", String::new())]
1629        );
1630    }
1631
1632    // -- update_step: size check, 413, retries --
1633
1634    const STEP_ROUTE: &str = "/api/v1/internal/steps/{id}";
1635    const RUN_ROUTE: &str = "/api/v1/internal/runs/{id}";
1636    const RUN_STATUS_ROUTE: &str = "/api/v1/internal/runs/{id}/status";
1637
1638    /// Delays short enough for the retry tests.
1639    fn fast_backoff() -> Vec<Duration> {
1640        vec![Duration::from_millis(5); 3]
1641    }
1642
1643    /// Serve `PUT route`, answering `statuses` in order (the last one repeats).
1644    /// Returns the base URL and the request counter.
1645    async fn scripted_put_api(
1646        route: &str,
1647        statuses: Vec<StatusCode>,
1648    ) -> (String, Arc<AtomicUsize>) {
1649        let hits = Arc::new(AtomicUsize::new(0));
1650        let counter = hits.clone();
1651        let statuses = Arc::new(statuses);
1652        let app = Router::new().route(
1653            route,
1654            put(move || {
1655                let counter = counter.clone();
1656                let statuses = statuses.clone();
1657                async move {
1658                    let i = counter.fetch_add(1, Ordering::SeqCst);
1659                    let status = statuses
1660                        .get(i)
1661                        .or_else(|| statuses.last())
1662                        .copied()
1663                        .unwrap();
1664                    (status, r#"{"data":null}"#)
1665                }
1666            }),
1667        );
1668        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1669        let addr = listener.local_addr().unwrap();
1670        spawn(async move { serve(listener, app).await.unwrap() });
1671        (format!("http://{addr}"), hits)
1672    }
1673
1674    fn completed_step() -> StepUpdate {
1675        StepUpdate {
1676            status: Some(StepStatus::Completed),
1677            output: Some(json!({ "logs": "ok" })),
1678            ..StepUpdate::default()
1679        }
1680    }
1681
1682    fn database_error(result: Result<(), StoreError>) -> String {
1683        match result {
1684            Err(StoreError::Database(msg)) => msg,
1685            other => panic!("expected a Database error, got {other:?}"),
1686        }
1687    }
1688
1689    #[tokio::test]
1690    async fn update_step_with_oversized_output_fails_with_payload_too_large_before_sending() {
1691        let (url, hits) = scripted_put_api(STEP_ROUTE, vec![StatusCode::OK]).await;
1692        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1693        let update = StepUpdate {
1694            status: Some(StepStatus::Completed),
1695            output: Some(json!({ "logs": "x".repeat(MAX_API_BODY_BYTES) })),
1696            ..StepUpdate::default()
1697        };
1698
1699        let msg = database_error(store.update_step(Uuid::now_v7(), update).await);
1700
1701        assert!(msg.contains("payload too large"), "got: {msg}");
1702        assert!(
1703            msg.contains(&format!("API limit is {MAX_API_BODY_BYTES} bytes")),
1704            "got: {msg}"
1705        );
1706        assert_eq!(hits.load(Ordering::SeqCst), 0, "nothing must be sent");
1707    }
1708
1709    #[tokio::test]
1710    async fn update_step_reports_http_413_as_payload_too_large_without_retry() {
1711        let (url, hits) = scripted_put_api(STEP_ROUTE, vec![StatusCode::PAYLOAD_TOO_LARGE]).await;
1712        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1713
1714        let msg = database_error(store.update_step(Uuid::now_v7(), completed_step()).await);
1715
1716        assert!(msg.contains("payload too large"), "got: {msg}");
1717        assert!(msg.contains("HTTP 413"), "got: {msg}");
1718        assert_eq!(hits.load(Ordering::SeqCst), 1);
1719    }
1720
1721    #[tokio::test]
1722    async fn update_step_retries_server_errors_until_success() {
1723        let (url, hits) = scripted_put_api(
1724            STEP_ROUTE,
1725            vec![
1726                StatusCode::SERVICE_UNAVAILABLE,
1727                StatusCode::BAD_GATEWAY,
1728                StatusCode::OK,
1729            ],
1730        )
1731        .await;
1732        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1733
1734        store
1735            .update_step(Uuid::now_v7(), completed_step())
1736            .await
1737            .unwrap();
1738
1739        assert_eq!(hits.load(Ordering::SeqCst), 3);
1740    }
1741
1742    #[tokio::test]
1743    async fn update_step_gives_up_after_four_attempts_on_server_errors() {
1744        let (url, hits) = scripted_put_api(STEP_ROUTE, vec![StatusCode::SERVICE_UNAVAILABLE]).await;
1745        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1746
1747        let msg = database_error(store.update_step(Uuid::now_v7(), completed_step()).await);
1748
1749        assert!(msg.contains("after 4 attempts"), "got: {msg}");
1750        assert!(msg.contains("503"), "got: {msg}");
1751        assert_eq!(hits.load(Ordering::SeqCst), 4);
1752    }
1753
1754    #[tokio::test]
1755    async fn update_step_does_not_retry_client_errors() {
1756        let (url, hits) =
1757            scripted_put_api(STEP_ROUTE, vec![StatusCode::BAD_REQUEST, StatusCode::OK]).await;
1758        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1759
1760        let msg = database_error(store.update_step(Uuid::now_v7(), completed_step()).await);
1761
1762        assert!(msg.contains("worker API error"), "got: {msg}");
1763        assert_eq!(hits.load(Ordering::SeqCst), 1);
1764    }
1765
1766    #[tokio::test]
1767    async fn update_step_on_unreachable_server_reports_cause_and_attempts() {
1768        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1769        let addr = listener.local_addr().unwrap();
1770        drop(listener);
1771        let store =
1772            ApiRunStore::new(&format!("http://{addr}"), "token").with_retry_backoff(fast_backoff());
1773
1774        let msg = database_error(store.update_step(Uuid::now_v7(), completed_step()).await);
1775
1776        assert!(msg.contains("after 4 attempts"), "got: {msg}");
1777        // The cause under reqwest's "error sending request" is kept.
1778        assert!(msg.contains("error sending request"), "got: {msg}");
1779        assert!(msg.to_lowercase().contains("connect"), "got: {msg}");
1780    }
1781
1782    #[tokio::test]
1783    async fn update_step_retries_a_connection_closed_mid_request() {
1784        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1785        let addr = listener.local_addr().unwrap();
1786        let accepted = Arc::new(AtomicUsize::new(0));
1787        let counter = accepted.clone();
1788        spawn(async move {
1789            loop {
1790                let (mut socket, _) = listener.accept().await.unwrap();
1791                counter.fetch_add(1, Ordering::SeqCst);
1792                let mut buf = [0u8; 64];
1793                let _ = socket.read(&mut buf).await;
1794                drop(socket);
1795            }
1796        });
1797        let store =
1798            ApiRunStore::new(&format!("http://{addr}"), "token").with_retry_backoff(fast_backoff());
1799
1800        let msg = database_error(store.update_step(Uuid::now_v7(), completed_step()).await);
1801
1802        assert!(msg.contains("after 4 attempts"), "got: {msg}");
1803        assert!(msg.contains("error sending request"), "got: {msg}");
1804        assert!(accepted.load(Ordering::SeqCst) >= 4);
1805    }
1806
1807    // -- update_run / update_run_status: same retry policy --
1808
1809    fn completed_run() -> RunUpdate {
1810        RunUpdate {
1811            status: Some(RunStatus::Completed),
1812            ..RunUpdate::default()
1813        }
1814    }
1815
1816    #[tokio::test]
1817    async fn update_run_retries_server_errors_until_success() {
1818        let (url, hits) =
1819            scripted_put_api(RUN_ROUTE, vec![StatusCode::BAD_GATEWAY, StatusCode::OK]).await;
1820        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1821
1822        store
1823            .update_run(Uuid::now_v7(), completed_run())
1824            .await
1825            .unwrap();
1826
1827        assert_eq!(hits.load(Ordering::SeqCst), 2);
1828    }
1829
1830    #[tokio::test]
1831    async fn update_run_gives_up_after_four_attempts_on_server_errors() {
1832        let (url, hits) = scripted_put_api(RUN_ROUTE, vec![StatusCode::BAD_GATEWAY]).await;
1833        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1834
1835        let msg = database_error(store.update_run(Uuid::now_v7(), completed_run()).await);
1836
1837        assert!(msg.contains("after 4 attempts"), "got: {msg}");
1838        assert!(msg.contains("502"), "got: {msg}");
1839        assert_eq!(hits.load(Ordering::SeqCst), 4);
1840    }
1841
1842    #[tokio::test]
1843    async fn update_run_does_not_retry_client_errors() {
1844        let (url, hits) =
1845            scripted_put_api(RUN_ROUTE, vec![StatusCode::CONFLICT, StatusCode::OK]).await;
1846        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1847
1848        let msg = database_error(store.update_run(Uuid::now_v7(), completed_run()).await);
1849
1850        assert!(msg.contains("worker API error"), "got: {msg}");
1851        assert_eq!(hits.load(Ordering::SeqCst), 1);
1852    }
1853
1854    #[tokio::test]
1855    async fn update_run_with_oversized_error_fails_with_payload_too_large_before_sending() {
1856        let (url, hits) = scripted_put_api(RUN_ROUTE, vec![StatusCode::OK]).await;
1857        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1858        let update = RunUpdate {
1859            status: Some(RunStatus::Failed),
1860            error: Some("x".repeat(MAX_API_BODY_BYTES)),
1861            ..RunUpdate::default()
1862        };
1863
1864        let msg = database_error(store.update_run(Uuid::now_v7(), update).await);
1865
1866        assert!(msg.contains("payload too large"), "got: {msg}");
1867        assert_eq!(hits.load(Ordering::SeqCst), 0, "nothing must be sent");
1868    }
1869
1870    #[tokio::test]
1871    async fn update_run_status_retries_server_errors_until_success() {
1872        let (url, hits) = scripted_put_api(
1873            RUN_STATUS_ROUTE,
1874            vec![
1875                StatusCode::SERVICE_UNAVAILABLE,
1876                StatusCode::INTERNAL_SERVER_ERROR,
1877                StatusCode::OK,
1878            ],
1879        )
1880        .await;
1881        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1882
1883        store
1884            .update_run_status(Uuid::now_v7(), RunStatus::Running)
1885            .await
1886            .unwrap();
1887
1888        assert_eq!(hits.load(Ordering::SeqCst), 3);
1889    }
1890
1891    #[tokio::test]
1892    async fn update_run_status_does_not_retry_client_errors() {
1893        let (url, hits) = scripted_put_api(
1894            RUN_STATUS_ROUTE,
1895            vec![StatusCode::BAD_REQUEST, StatusCode::OK],
1896        )
1897        .await;
1898        let store = ApiRunStore::new(&url, "token").with_retry_backoff(fast_backoff());
1899
1900        let msg = database_error(
1901            store
1902                .update_run_status(Uuid::now_v7(), RunStatus::Running)
1903                .await,
1904        );
1905
1906        assert!(msg.contains("worker API error"), "got: {msg}");
1907        assert_eq!(hits.load(Ordering::SeqCst), 1);
1908    }
1909
1910    #[tokio::test]
1911    async fn update_run_status_on_unreachable_server_reports_cause_and_attempts() {
1912        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1913        let addr = listener.local_addr().unwrap();
1914        drop(listener);
1915        let store =
1916            ApiRunStore::new(&format!("http://{addr}"), "token").with_retry_backoff(fast_backoff());
1917
1918        let msg = database_error(
1919            store
1920                .update_run_status(Uuid::now_v7(), RunStatus::Running)
1921                .await,
1922        );
1923
1924        assert!(msg.contains("after 4 attempts"), "got: {msg}");
1925        assert!(msg.contains("error sending request"), "got: {msg}");
1926    }
1927
1928    #[test]
1929    fn retry_backoff_is_one_two_four_seconds() {
1930        let store = ApiRunStore::new("http://localhost:3000", "token");
1931        assert_eq!(
1932            store.retry_backoff,
1933            vec![
1934                Duration::from_secs(1),
1935                Duration::from_secs(2),
1936                Duration::from_secs(4)
1937            ]
1938        );
1939    }
1940}