1use 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#[derive(serde::Deserialize, serde::Serialize)]
55struct ApiResponse<T> {
56 data: T,
57}
58
59const RETRY_BACKOFF: [Duration; 3] = [
62 Duration::from_secs(1),
63 Duration::from_secs(2),
64 Duration::from_secs(4),
65];
66
67#[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 #[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 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
182fn 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 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 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 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 Box::pin(async move { Ok(Vec::new()) })
402 }
403
404 fn claim_due_approval_deadlines(&self, _limit: u32) -> StoreFuture<'_, Vec<Step>> {
405 Box::pin(async move { Ok(Vec::new()) })
408 }
409
410 fn claim_due_sleeping_runs(&self, _limit: u32) -> StoreFuture<'_, Vec<Run>> {
411 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 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 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 Box::pin(async move { Ok(Vec::new()) })
453 }
454
455 fn delete_run(&self, _id: Uuid) -> StoreFuture<'_, Vec<String>> {
456 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 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 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 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 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 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
1047fn 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
1175pub(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
1215fn 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 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 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 fn fast_backoff() -> Vec<Duration> {
1640 vec![Duration::from_millis(5); 3]
1641 }
1642
1643 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 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 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}