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