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(&self, filter: RunFilter, page: u32, per_page: u32) -> StoreFuture<'_, Page<Run>> {
265 Box::pin(async move {
266 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 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 Box::pin(async move { Ok(Vec::new()) })
411 }
412
413 fn claim_due_approval_deadlines(&self, _limit: u32) -> StoreFuture<'_, Vec<Step>> {
414 Box::pin(async move { Ok(Vec::new()) })
417 }
418
419 fn claim_due_sleeping_runs(&self, _limit: u32) -> StoreFuture<'_, Vec<Run>> {
420 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 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 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 Box::pin(async move { Ok(Vec::new()) })
462 }
463
464 fn delete_run(&self, _id: Uuid) -> StoreFuture<'_, Vec<String>> {
465 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 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 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 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 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 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
1056fn 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
1184pub(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
1226fn 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 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 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 fn fast_backoff() -> Vec<Duration> {
1636 vec![Duration::from_millis(5); 3]
1637 }
1638
1639 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 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 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}