1use crate::error::{Result, SdkError};
2use crate::pb::{
3 engine_service_client::EngineServiceClient,
4 execution_engine_service_client::ExecutionEngineServiceClient,
5 worker_coordinator_service_client::WorkerCoordinatorServiceClient, AppendBatchRequest,
6 AppendBatchResponse, AppendRequest, BeginActivationRequest, BeginActivationResponse,
7 CheckpointRequest, CheckpointType, CompleteActivationRequest, CompleteActivationResponse,
8 CompleteJobRequest, CompleteJobResponse, DurableStepCheckpoint, EventStreamMessage,
9 FailActivationRequest, FailActivationResponse, FindByStepKeyRequest, GetEntityStateRequest,
10 GetEntityStateResponse, PollJobRequest, PollJobResponse, PutEntityStateRequest,
11 PutEntityStateResponse, Record, RegisterService, RegisterWorkerSessionRequest,
12 RegisterWorkerSessionResponse, RenewJobLeaseRequest, RenewJobLeaseResponse,
13 ReportWorkerCapacityRequest, ReportWorkerCapacityResponse, RuntimeMessage, ServiceMessage,
14 SuspendActivationRequest, SuspendActivationResponse,
15};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::sync::{Arc, RwLock};
19use std::time::Duration;
20use tonic::metadata::MetadataValue;
21use tonic::service::interceptor::InterceptedService;
22use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint};
23use tonic::Code;
24use tonic::{Request, Status};
25use tracing::{debug, error};
26
27const EXTERNAL_DISCOVERY_PATH: &str = "/api/v1/worker-discovery";
28const EXTERNAL_TOKEN_PATH: &str = "/api/v1/worker-token";
29const AUTH_PROFILE_BOOTSTRAP_MTLS: &str = "bootstrap-mtls";
30const AUTH_PROFILE_TOKEN_AUTH: &str = "token-auth";
31
32#[derive(Clone, Debug)]
33struct BearerInterceptor {
34 token: Arc<RwLock<Option<MetadataValue<tonic::metadata::Ascii>>>>,
35}
36
37impl tonic::service::Interceptor for BearerInterceptor {
38 fn call(&mut self, mut request: Request<()>) -> std::result::Result<Request<()>, Status> {
39 if let Some(token) = self.token.read().unwrap_or_else(|p| p.into_inner()).clone() {
40 request.metadata_mut().insert("authorization", token);
41 }
42 Ok(request)
43 }
44}
45
46type AuthenticatedChannel = InterceptedService<Channel, BearerInterceptor>;
47
48#[derive(Clone, Debug, Deserialize)]
49struct ExternalWorkerAuthority {
50 project_id: String,
51 environment_id: String,
52 deployment_id: String,
53 worker_pool_id: String,
54 #[serde(default)]
55 runtime_endpoint: String,
56 #[serde(default)]
57 protocol: String,
58 #[serde(default = "default_external_worker_auth_profile")]
59 auth_profile: String,
60 #[serde(default)]
61 identity_endpoint: String,
62}
63
64fn default_external_worker_auth_profile() -> String {
65 AUTH_PROFILE_TOKEN_AUTH.to_string()
66}
67
68fn external_worker_ca_bundle() -> Result<Option<Vec<u8>>> {
69 crate::external_worker_identity::server_ca_pem()
70}
71
72fn configure_channel_tls(
73 endpoint: Endpoint,
74 tls: Option<ClientTlsConfig>,
75) -> std::result::Result<Endpoint, tonic::transport::Error> {
76 let tls = match tls {
77 Some(tls) => Some(tls),
78 None if endpoint.uri().scheme_str() == Some("https") => {
79 Some(ClientTlsConfig::new().with_webpki_roots())
80 }
81 None => None,
82 };
83 match tls {
84 Some(tls) => endpoint.tls_config(tls),
85 None => Ok(endpoint),
86 }
87}
88
89impl ExternalWorkerAuthority {
90 fn apply_to_metadata(&self, metadata: &mut HashMap<String, String>) {
91 metadata.insert("project_id".to_string(), self.project_id.clone());
92 metadata.insert("environment_id".to_string(), self.environment_id.clone());
93 metadata.insert("deployment_id".to_string(), self.deployment_id.clone());
94 metadata.insert("worker_pool_id".to_string(), self.worker_pool_id.clone());
95 }
96}
97
98#[derive(Debug, Serialize)]
99struct ExternalWorkerTokenRequest<'a> {
100 project_id: &'a str,
101 environment_id: &'a str,
102 deployment_id: &'a str,
103 worker_pool_id: &'a str,
104}
105
106impl<'a> From<&'a ExternalWorkerAuthority> for ExternalWorkerTokenRequest<'a> {
107 fn from(authority: &'a ExternalWorkerAuthority) -> Self {
108 Self {
109 project_id: &authority.project_id,
110 environment_id: &authority.environment_id,
111 deployment_id: &authority.deployment_id,
112 worker_pool_id: &authority.worker_pool_id,
113 }
114 }
115}
116
117#[derive(Clone, Debug, Deserialize)]
118struct ExternalWorkerTokenResponse {
119 workload_token: String,
120 expires_at: chrono::DateTime<chrono::Utc>,
121}
122
123#[derive(Clone, Debug)]
124struct ExternalWorkerBootstrap {
125 control_plane_url: String,
126 credential: String,
127 authority: ExternalWorkerAuthority,
128}
129
130pub(crate) fn remote_worker_bootstrap_enabled() -> bool {
131 std::env::var("AGNT5_API_KEY_FILE").is_ok_and(|value| !value.trim().is_empty())
132}
133
134async fn external_worker_bootstrap() -> Result<(
135 String,
136 BearerInterceptor,
137 Option<ClientTlsConfig>,
138 Option<String>,
139 ExternalWorkerAuthority,
140)> {
141 let control_plane_url = std::env::var("AGNT5_CONTROL_PLANE_URL")
142 .unwrap_or_else(|_| "https://api.agnt5.com".to_string())
143 .trim_end_matches('/')
144 .to_string();
145 crate::external_worker_identity::validate_identity_endpoint(&control_plane_url)?;
146 let key_path = std::env::var("AGNT5_API_KEY_FILE").map_err(|_| SdkError::Connection {
147 message: "AGNT5_API_KEY_FILE is required for external workers".to_string(),
148 code: crate::error::ErrorCode::ConnectionFailed,
149 source: None,
150 })?;
151 let credential = std::fs::read_to_string(&key_path)
152 .map_err(|error| SdkError::Connection {
153 message: format!("read external worker credential {}: {}", key_path, error),
154 code: crate::error::ErrorCode::ConnectionFailed,
155 source: None,
156 })?
157 .trim()
158 .to_string();
159 if credential.is_empty() {
160 return Err(SdkError::Connection {
161 message: "external worker credential file is empty".to_string(),
162 code: crate::error::ErrorCode::ConnectionFailed,
163 source: None,
164 });
165 }
166 let environment = std::env::var("AGNT5_ENVIRONMENT").unwrap_or_default();
167 let ca_bundle = external_worker_ca_bundle()?;
168 let http = crate::external_worker_identity::bootstrap_http_client()?;
169 let rollout = crate::external_worker_identity::RolloutReadiness::from_env()?;
170 let response = http
171 .post(format!("{}{}", control_plane_url, EXTERNAL_DISCOVERY_PATH))
172 .header("X-API-KEY", &credential)
173 .json(&serde_json::json!({
174 "environment": environment,
175 "supported_auth_profiles": rollout.profiles(),
176 "mtls_ready": rollout.ready,
177 "current_auth_profile": rollout.current_profile()
178 }))
179 .send()
180 .await
181 .map_err(|error| SdkError::Connection {
182 message: format!("external worker discovery failed: {}", error),
183 code: crate::error::ErrorCode::ConnectionFailed,
184 source: None,
185 })?;
186 if !response.status().is_success() {
187 return Err(SdkError::Connection {
188 message: format!("external worker discovery returned {}", response.status()),
189 code: crate::error::ErrorCode::ConnectionFailed,
190 source: None,
191 });
192 }
193 let authority: ExternalWorkerAuthority =
194 response
195 .json()
196 .await
197 .map_err(|error| SdkError::Connection {
198 message: format!("decode external worker discovery response: {}", error),
199 code: crate::error::ErrorCode::ConnectionFailed,
200 source: None,
201 })?;
202 if authority.protocol != "pull.v1" || authority.runtime_endpoint.trim().is_empty() {
203 return Err(SdkError::Connection {
204 message: "external worker discovery returned an unsupported target".to_string(),
205 code: crate::error::ErrorCode::ConnectionFailed,
206 source: None,
207 });
208 }
209 rollout.accept(&authority.auth_profile)?;
210 match authority.auth_profile.as_str() {
211 AUTH_PROFILE_BOOTSTRAP_MTLS => {
212 if authority.identity_endpoint.trim().is_empty() {
213 return Err(SdkError::Connection {
214 message:
215 "worker discovery selected bootstrap-mtls without an identity endpoint"
216 .to_string(),
217 code: crate::error::ErrorCode::ConnectionFailed,
218 source: None,
219 });
220 }
221 let identity_authority = crate::external_worker_identity::Authority {
222 project_id: authority.project_id.clone(),
223 environment_id: authority.environment_id.clone(),
224 deployment_id: authority.deployment_id.clone(),
225 worker_pool_id: authority.worker_pool_id.clone(),
226 runtime_endpoint: authority.runtime_endpoint.clone(),
227 protocol: authority.protocol.clone(),
228 };
229 let identity = crate::external_worker_identity::connection_identity(
230 control_plane_url,
231 authority.identity_endpoint.clone(),
232 credential,
233 identity_authority,
234 )
235 .await?;
236 eprintln!("[INFO] worker authenticated with bootstrap mTLS");
237 return Ok((
238 identity.endpoint,
239 BearerInterceptor {
240 token: identity.authorization,
241 },
242 Some(identity.tls),
243 Some(identity.worker_id),
244 authority,
245 ));
246 }
247 AUTH_PROFILE_TOKEN_AUTH => {}
248 profile => {
249 return Err(SdkError::Connection {
250 message: format!(
251 "worker discovery selected unsupported authentication profile {profile:?}"
252 ),
253 code: crate::error::ErrorCode::ConnectionFailed,
254 source: None,
255 });
256 }
257 }
258 let bootstrap = ExternalWorkerBootstrap {
259 control_plane_url,
260 credential,
261 authority,
262 };
263 let initial = exchange_external_worker_token(&http, &bootstrap).await?;
264 let token = bearer_metadata(&initial.workload_token)?;
265 let shared = Arc::new(RwLock::new(Some(token)));
266 spawn_external_worker_token_refresh(
267 http,
268 bootstrap.clone(),
269 shared.clone(),
270 initial.expires_at,
271 );
272
273 eprintln!("[INFO] worker authenticated with token auth");
274 eprintln!(
275 "[INFO] discovered runtime endpoint ({})",
276 bootstrap.authority.runtime_endpoint
277 );
278 let mut runtime_tls = ClientTlsConfig::new().with_webpki_roots();
279 if let Some(pem) = ca_bundle {
280 runtime_tls = runtime_tls.ca_certificate(Certificate::from_pem(pem));
281 }
282 Ok((
283 bootstrap.authority.runtime_endpoint.clone(),
284 BearerInterceptor { token: shared },
285 Some(runtime_tls),
286 None,
287 bootstrap.authority,
288 ))
289}
290
291async fn exchange_external_worker_token(
292 http: &reqwest::Client,
293 bootstrap: &ExternalWorkerBootstrap,
294) -> Result<ExternalWorkerTokenResponse> {
295 let response = http
296 .post(format!(
297 "{}{}",
298 bootstrap.control_plane_url, EXTERNAL_TOKEN_PATH
299 ))
300 .header("X-API-KEY", &bootstrap.credential)
301 .json(&ExternalWorkerTokenRequest::from(&bootstrap.authority))
302 .send()
303 .await
304 .map_err(|error| SdkError::Connection {
305 message: format!("external worker token exchange failed: {}", error),
306 code: crate::error::ErrorCode::ConnectionFailed,
307 source: None,
308 })?;
309 if !response.status().is_success() {
310 return Err(SdkError::Connection {
311 message: format!(
312 "external worker token exchange returned {}",
313 response.status()
314 ),
315 code: crate::error::ErrorCode::ConnectionFailed,
316 source: None,
317 });
318 }
319 response.json().await.map_err(|error| SdkError::Connection {
320 message: format!("decode external worker token response: {}", error),
321 code: crate::error::ErrorCode::ConnectionFailed,
322 source: None,
323 })
324}
325
326fn bearer_metadata(token: &str) -> Result<MetadataValue<tonic::metadata::Ascii>> {
327 format!("Bearer {}", token)
328 .parse()
329 .map_err(|error| SdkError::Connection {
330 message: format!("invalid external worker token metadata: {}", error),
331 code: crate::error::ErrorCode::ConnectionFailed,
332 source: None,
333 })
334}
335
336fn spawn_external_worker_token_refresh(
337 http: reqwest::Client,
338 bootstrap: ExternalWorkerBootstrap,
339 token: Arc<RwLock<Option<MetadataValue<tonic::metadata::Ascii>>>>,
340 mut expires_at: chrono::DateTime<chrono::Utc>,
341) {
342 tokio::spawn(async move {
343 loop {
344 let delay = (expires_at - chrono::Utc::now() - chrono::Duration::seconds(60))
345 .to_std()
346 .unwrap_or(Duration::from_secs(1));
347 tokio::time::sleep(delay).await;
348 match exchange_external_worker_token(&http, &bootstrap).await {
349 Ok(refreshed) => match bearer_metadata(&refreshed.workload_token) {
350 Ok(value) => {
351 *token.write().unwrap_or_else(|p| p.into_inner()) = Some(value);
352 expires_at = refreshed.expires_at;
353 }
354 Err(error) => error!("External worker token refresh rejected: {}", error),
355 },
356 Err(error) => {
357 error!("External worker token refresh failed: {}; retrying", error);
358 tokio::time::sleep(Duration::from_secs(5)).await;
359 }
360 }
361 }
362 });
363}
364
365pub const DURABLE_ACTIVATION_V1_CAPABILITY: &str = "durable_activation_v1";
366pub const DURABLE_SUSPENSION_V1_CAPABILITY: &str = "durable_suspension_v1";
367pub const PULL_COMPLETION_LIFECYCLE_V1_CAPABILITY: &str = "pull_completion_lifecycle_v1";
371pub const SERVER_SLOT_SCALING_V1_CAPABILITY: &str = "server_slot_scaling_v1";
374
375fn pull_completion_lifecycle_enabled() -> bool {
376 !std::env::var("AGNT5_PULL_COMPLETION_LIFECYCLE")
377 .map(|value| value.trim().eq_ignore_ascii_case("disabled"))
378 .unwrap_or(false)
379}
380
381pub fn worker_protocol_capabilities() -> (Vec<String>, Vec<String>) {
382 let (mut supported, required) = match std::env::var("AGNT5_DURABLE_ACTIVATION_MODE")
383 .unwrap_or_else(|_| "preferred".to_string())
384 .to_ascii_lowercase()
385 .as_str()
386 {
387 "disabled" => (Vec::new(), Vec::new()),
388 "required" => (
389 vec![
390 DURABLE_ACTIVATION_V1_CAPABILITY.to_string(),
391 DURABLE_SUSPENSION_V1_CAPABILITY.to_string(),
392 ],
393 vec![DURABLE_ACTIVATION_V1_CAPABILITY.to_string()],
394 ),
395 _ => (
396 vec![
397 DURABLE_ACTIVATION_V1_CAPABILITY.to_string(),
398 DURABLE_SUSPENSION_V1_CAPABILITY.to_string(),
399 ],
400 Vec::new(),
401 ),
402 };
403 if pull_completion_lifecycle_enabled() {
404 supported.push(PULL_COMPLETION_LIFECYCLE_V1_CAPABILITY.to_string());
405 }
406 supported.push(SERVER_SLOT_SCALING_V1_CAPABILITY.to_string());
407 (supported, required)
408}
409
410pub(crate) fn validate_protocol_capabilities(
411 worker_supported: &[String],
412 worker_required: &[String],
413 runtime_supported: &[String],
414 runtime_required: &[String],
415) -> Result<()> {
416 if let Some(required) = worker_required
417 .iter()
418 .find(|required| !runtime_supported.contains(required))
419 {
420 return Err(SdkError::Activation {
421 message: format!("runtime did not negotiate required protocol capability: {required}"),
422 code: crate::error::ErrorCode::DurabilityUnavailable,
423 activation_id: None,
424 attempt: None,
425 });
426 }
427 if let Some(required) = runtime_required
428 .iter()
429 .find(|required| !worker_supported.contains(required))
430 {
431 return Err(SdkError::Activation {
432 message: format!("runtime requires unsupported worker protocol capability: {required}"),
433 code: crate::error::ErrorCode::DurabilityUnavailable,
434 activation_id: None,
435 attempt: None,
436 });
437 }
438 if worker_supported
439 .iter()
440 .any(|capability| capability == DURABLE_ACTIVATION_V1_CAPABILITY)
441 && !worker_required
442 .iter()
443 .any(|capability| capability == DURABLE_ACTIVATION_V1_CAPABILITY)
444 && !runtime_supported
445 .iter()
446 .any(|capability| capability == DURABLE_ACTIVATION_V1_CAPABILITY)
447 {
448 eprintln!(
449 "[WARN] agnt5 durable activation degraded: runtime did not advertise durable_activation_v1; legacy checkpoints remain enabled"
450 );
451 }
452 Ok(())
453}
454
455fn negotiated_protocol_capabilities(
456 worker_supported: &[String],
457 runtime_supported: &[String],
458) -> Vec<String> {
459 worker_supported
460 .iter()
461 .filter(|capability| runtime_supported.contains(capability))
462 .fold(Vec::new(), |mut negotiated, capability| {
463 if !negotiated.contains(capability) {
464 negotiated.push(capability.clone());
465 }
466 negotiated
467 })
468}
469
470fn activation_status(operation: &str, status: tonic::Status) -> SdkError {
471 crate::runtime_adapter::activation_status_error(operation, status)
472}
473
474#[derive(Debug, Clone)]
486pub struct WorkerCoordinatorClient {
487 client: WorkerCoordinatorServiceClient<AuthenticatedChannel>,
488 engine_client: EngineServiceClient<AuthenticatedChannel>,
489 negotiated_protocol_capabilities: Arc<RwLock<Vec<String>>>,
490 authoritative_worker_id: Option<String>,
491 external_worker_authority: Option<ExternalWorkerAuthority>,
492 identity_authorization: Option<Arc<RwLock<Option<MetadataValue<tonic::metadata::Ascii>>>>>,
493}
494
495async fn wait_for_identity_rotation(
496 authorization: Option<&Arc<RwLock<Option<MetadataValue<tonic::metadata::Ascii>>>>>,
497) {
498 let Some(authorization) = authorization else {
499 return std::future::pending().await;
500 };
501 loop {
502 if authorization
503 .read()
504 .unwrap_or_else(|p| p.into_inner())
505 .is_none()
506 {
507 return;
508 }
509 tokio::time::sleep(Duration::from_millis(100)).await;
510 }
511}
512
513const WORKER_COORDINATOR_RPC_TIMEOUT: Duration = Duration::from_secs(45);
514const PARKED_POLL_CLIENT_GRACE: Duration = Duration::from_secs(5);
515const MAX_PARKED_POLL_WAIT_MS: i64 = 30_000;
516
517fn poll_job_deadline(req: &PollJobRequest) -> Duration {
518 let wait_ms = req.wait_ms.clamp(0, MAX_PARKED_POLL_WAIT_MS) as u64;
519 Duration::from_millis(wait_ms).saturating_add(PARKED_POLL_CLIENT_GRACE)
520}
521
522fn is_idle_poll_timeout(status: &tonic::Status) -> bool {
523 matches!(status.code(), Code::Cancelled | Code::DeadlineExceeded)
524 && status.message().to_ascii_lowercase().contains("timeout")
525}
526
527impl WorkerCoordinatorClient {
528 pub async fn connect(endpoint: String) -> Result<Self> {
530 let (endpoint, interceptor, tls, authoritative_worker_id, external_worker_authority) =
531 if remote_worker_bootstrap_enabled() {
532 let (endpoint, interceptor, tls, worker_id, authority) =
533 external_worker_bootstrap().await?;
534 (endpoint, interceptor, tls, worker_id, Some(authority))
535 } else {
536 (
537 endpoint,
538 BearerInterceptor {
539 token: Arc::new(RwLock::new(None)),
540 },
541 None,
542 None,
543 None,
544 )
545 };
546 debug!("Connecting to Worker Coordinator at {}", endpoint);
547
548 let channel = Channel::from_shared(endpoint.clone()).map_err(|e| SdkError::Connection {
549 message: format!("Invalid endpoint {}: {}", endpoint, e),
550 code: crate::error::ErrorCode::ConnectionFailed,
551 source: None,
552 })?;
553 let channel = configure_channel_tls(channel, tls).map_err(|e| SdkError::Connection {
554 message: format!("Invalid worker TLS configuration: {}", e),
555 code: crate::error::ErrorCode::ConnectionFailed,
556 source: None,
557 })?;
558 let channel = channel
559 .connect_timeout(Duration::from_secs(10))
560 .timeout(WORKER_COORDINATOR_RPC_TIMEOUT)
561 .http2_adaptive_window(true)
562 .connect()
563 .await
564 .map_err(|e| {
565 debug!("Connection to {} failed: {:?}", endpoint, e);
567 e
568 })?;
569
570 let client =
571 WorkerCoordinatorServiceClient::with_interceptor(channel.clone(), interceptor.clone());
572 let identity_authorization = authoritative_worker_id
573 .as_ref()
574 .map(|_| interceptor.token.clone());
575 let engine_client = EngineServiceClient::with_interceptor(channel, interceptor);
576
577 Ok(Self {
578 client,
579 engine_client,
580 negotiated_protocol_capabilities: Arc::new(RwLock::new(Vec::new())),
581 authoritative_worker_id,
582 external_worker_authority,
583 identity_authorization,
584 })
585 }
586
587 pub(crate) async fn wait_for_identity_rotation(&self) {
590 wait_for_identity_rotation(self.identity_authorization.as_ref()).await;
591 }
592
593 pub(crate) fn apply_external_worker_authority(&self, metadata: &mut HashMap<String, String>) {
597 let Some(authority) = &self.external_worker_authority else {
598 return;
599 };
600 authority.apply_to_metadata(metadata);
601 }
602
603 pub(crate) fn is_external_worker(&self) -> bool {
604 self.external_worker_authority.is_some()
605 }
606
607 pub(crate) fn effective_worker_id<'a>(&'a self, configured: &'a str) -> &'a str {
609 self.authoritative_worker_id
610 .as_deref()
611 .unwrap_or(configured)
612 }
613
614 pub(crate) fn retain_negotiated_protocol_capabilities(
615 &self,
616 worker_supported: &[String],
617 runtime_supported: &[String],
618 ) {
619 let negotiated = negotiated_protocol_capabilities(worker_supported, runtime_supported);
620 let mut capabilities = self
621 .negotiated_protocol_capabilities
622 .write()
623 .unwrap_or_else(|poisoned| poisoned.into_inner());
624 *capabilities = negotiated;
625 }
626
627 pub fn negotiated_protocol_capability(&self, capability: &str) -> bool {
630 self.negotiated_protocol_capabilities
631 .read()
632 .unwrap_or_else(|poisoned| poisoned.into_inner())
633 .iter()
634 .any(|negotiated| negotiated == capability)
635 }
636
637 pub async fn create_worker_stream_with_registration(
639 &mut self,
640 worker_id: String,
641 registration: RegisterService,
642 ) -> Result<(
643 flume::Sender<ServiceMessage>,
644 flume::Receiver<RuntimeMessage>,
645 )> {
646 let worker_id = self.effective_worker_id(&worker_id).to_string();
647 let worker_supported_protocols = registration.supported_protocol_capabilities.clone();
648 let worker_required_protocols = registration.required_protocol_capabilities.clone();
649 let registration_message = ServiceMessage {
651 worker_id: worker_id.clone(),
652 metadata: HashMap::new(),
653 message_type: Some(crate::pb::service_message::MessageType::RegisterService(
654 registration,
655 )),
656 };
657
658 let (outgoing_tx, outgoing_rx) = flume::bounded::<ServiceMessage>(1000);
660 let (runtime_msg_tx, runtime_msg_rx) = flume::bounded::<RuntimeMessage>(1000);
661
662 let outgoing_stream = async_stream::stream! {
664 yield registration_message;
666
667 loop {
669 match outgoing_rx.recv_async().await {
670 Ok(msg) => {
671 yield msg;
672 },
673 Err(_) => {
674 break;
675 }
676 }
677 }
678 };
679
680 let worker_id_header = tonic::metadata::MetadataValue::try_from(worker_id.as_str())
683 .map_err(|e| SdkError::Connection {
684 message: format!("Invalid worker_id for routing metadata: {}", e),
685 code: crate::error::ErrorCode::ConnectionFailed,
686 source: None,
687 })?;
688 let mut request = tonic::Request::new(outgoing_stream);
689 request
690 .metadata_mut()
691 .insert("x-agnt5-worker-id", worker_id_header);
692
693 let mut response_stream = self
695 .client
696 .worker_stream(request)
697 .await
698 .map_err(|e| {
699 debug!("Failed to create gRPC worker stream: {}", e);
700 SdkError::Connection {
701 message: format!("gRPC stream failed: {}", e),
702 code: crate::error::ErrorCode::ConnectionFailed,
703 source: None,
704 }
705 })?
706 .into_inner();
707
708 let registration_response =
709 tokio::time::timeout(Duration::from_secs(10), response_stream.message())
710 .await
711 .map_err(|_| {
712 error!("Timeout waiting for registration response");
713 SdkError::Connection {
714 message: "Registration timeout - no response from runtime".to_string(),
715 code: crate::error::ErrorCode::ConnectionTimeout,
716 source: None,
717 }
718 })?
719 .map_err(|e| {
720 debug!("Failed to receive registration response: {}", e);
721 SdkError::Connection {
722 message: format!("Stream error: {}", e),
723 code: crate::error::ErrorCode::ConnectionFailed,
724 source: None,
725 }
726 })?;
727
728 if let Some(runtime_message) = registration_response {
733 match &runtime_message.message_data {
734 Some(crate::pb::runtime_message::MessageData::RegisterServiceResponse(resp)) => {
735 if !resp.ack {
736 error!("Registration failed: {}", resp.error);
737 return Err(SdkError::Connection {
738 message: format!("Registration failed: {}", resp.error),
739 code: crate::error::ErrorCode::ConnectionFailed,
740 source: None,
741 });
742 }
743 validate_protocol_capabilities(
744 &worker_supported_protocols,
745 &worker_required_protocols,
746 &resp.supported_protocol_capabilities,
747 &resp.required_protocol_capabilities,
748 )?;
749 self.retain_negotiated_protocol_capabilities(
750 &worker_supported_protocols,
751 &resp.supported_protocol_capabilities,
752 );
753 }
754 _ => {
755 error!("Unexpected response type to registration");
756 return Err(SdkError::Connection {
757 message: "Unexpected response to registration".to_string(),
758 code: crate::error::ErrorCode::InvalidMessage,
759 source: None,
760 });
761 }
762 }
763 } else {
764 error!("No registration response received");
765 return Err(SdkError::Connection {
766 message: "No registration response received".to_string(),
767 code: crate::error::ErrorCode::ConnectionFailed,
768 source: None,
769 });
770 }
771
772 tokio::spawn(async move {
774 while let Some(message_result) =
775 tokio_stream::StreamExt::next(&mut response_stream).await
776 {
777 match message_result {
778 Ok(runtime_message) => {
779 if runtime_msg_tx.send_async(runtime_message).await.is_err() {
780 break;
781 }
782 }
783 Err(e) => {
784 debug!("Stream closed: {}", e);
785 break;
786 }
787 }
788 }
789 });
790
791 Ok((outgoing_tx, runtime_msg_rx))
792 }
793
794 pub async fn create_event_stream(
800 &mut self,
801 worker_id: String,
802 ) -> Result<flume::Sender<EventStreamMessage>> {
803 let (tx, rx) = flume::bounded::<EventStreamMessage>(1000);
804
805 let stream = async_stream::stream! {
806 loop {
807 match rx.recv_async().await {
808 Ok(msg) => yield msg,
809 Err(_) => break, }
811 }
812 };
813
814 let mut client = self.engine_client.clone();
816 tokio::spawn(async move {
817 match client.event_stream(stream).await {
818 Ok(response) => {
819 let ack = response.into_inner();
820 debug!(
821 "EventStream closed: success={} events_received={}",
822 ack.success, ack.events_received
823 );
824 }
825 Err(e) => {
826 debug!("EventStream error: {}", e);
827 }
828 }
829 });
830
831 debug!("EventStream opened for worker {}", worker_id);
832 Ok(tx)
833 }
834
835 pub async fn checkpoint(
857 &mut self,
858 tenant_id: String,
859 run_id: String,
860 step_key: String,
861 step_name: String,
862 step_type: String,
863 checkpoint_type: CheckpointType,
864 payload: Option<Vec<u8>>,
865 error_message: Option<String>,
866 error_type: Option<String>,
867 latency_ms: Option<i64>,
868 ) -> Result<CheckpointResult> {
869 debug!(
870 "Sending checkpoint: tenant_id={}, run_id={}, step_key={}, type={:?}",
871 tenant_id, run_id, step_key, checkpoint_type
872 );
873
874 let checkpoint = DurableStepCheckpoint {
875 run_id,
876 step_key,
877 step_name,
878 step_type,
879 r#type: checkpoint_type.into(),
880 payload: payload.unwrap_or_default(),
881 error_message: error_message.unwrap_or_default(),
882 error_type: error_type.unwrap_or_default(),
883 tokens_in: 0,
884 tokens_out: 0,
885 cost_usd: 0.0,
886 latency_ms: latency_ms.unwrap_or(0),
887 model_provider: String::new(),
888 model_version: String::new(),
889 };
890
891 let request = CheckpointRequest {
892 checkpoint: Some(checkpoint),
893 project_id: tenant_id,
894 };
895
896 let response = self
898 .engine_client
899 .checkpoint(request)
900 .await
901 .map_err(|e| {
902 debug!("Checkpoint RPC failed: {}", e);
903 SdkError::Connection {
904 message: format!("Checkpoint failed: {}", e),
905 code: crate::error::ErrorCode::ConnectionFailed,
906 source: None,
907 }
908 })?
909 .into_inner();
910
911 Ok(CheckpointResult {
912 success: response.success,
913 error_message: if response.error_message.is_empty() {
914 None
915 } else {
916 Some(response.error_message)
917 },
918 memoized: response.memoized,
919 cached_output: if response.cached_output.is_empty() {
920 None
921 } else {
922 Some(response.cached_output)
923 },
924 })
925 }
926
927 pub async fn get_memoized_step(
945 &mut self,
946 tenant_id: String,
947 run_id: String,
948 step_key: String,
949 ) -> Result<Option<Vec<u8>>> {
950 debug!(
951 "Checking memoization: tenant_id={}, run_id={}, step_key={}",
952 tenant_id, run_id, step_key
953 );
954
955 let request = FindByStepKeyRequest {
956 project_id: tenant_id,
957 run_id,
958 step_key,
959 };
960
961 let response = self
962 .engine_client
963 .find_by_step_key(request)
964 .await
965 .map_err(|e| {
966 debug!("FindByStepKey RPC failed: {}", e);
967 SdkError::Connection {
968 message: format!("FindByStepKey failed: {}", e),
969 code: crate::error::ErrorCode::ConnectionFailed,
970 source: None,
971 }
972 })?
973 .into_inner();
974
975 if response.found {
976 if let Some(record) = response.record {
977 if !record.data.is_empty() {
978 return Ok(Some(record.data));
979 }
980 }
981 }
982 Ok(None)
983 }
984
985 pub async fn register_worker_session(
987 &mut self,
988 mut req: RegisterWorkerSessionRequest,
989 ) -> Result<RegisterWorkerSessionResponse> {
990 req.worker_id = self.effective_worker_id(&req.worker_id).to_string();
991 let response = self
992 .engine_client
993 .register_worker_session(req)
994 .await
995 .map_err(|e| {
996 debug!("RegisterWorkerSession RPC failed: {}", e);
997 SdkError::Connection {
998 message: format!("RegisterWorkerSession failed: {}", e),
999 code: crate::error::ErrorCode::ConnectionFailed,
1000 source: None,
1001 }
1002 })?
1003 .into_inner();
1004
1005 Ok(response)
1006 }
1007
1008 pub async fn poll_job(&mut self, mut req: PollJobRequest) -> Result<PollJobResponse> {
1010 req.worker_id = self.effective_worker_id(&req.worker_id).to_string();
1011 let timeout = poll_job_deadline(&req);
1012 let mut request = tonic::Request::new(req);
1013 request.set_timeout(timeout);
1014 let response = match self.engine_client.poll_job(request).await {
1015 Ok(response) => response,
1016 Err(e) => {
1017 if is_idle_poll_timeout(&e) {
1018 debug!("PollJob idle timeout: {}", e);
1019 return Ok(PollJobResponse {
1020 job: None,
1021 slot_scaling: None,
1022 });
1023 }
1024 debug!("PollJob RPC failed: {}", e);
1025 return Err(SdkError::Connection {
1026 message: format!("PollJob failed: {}", e),
1027 code: crate::error::ErrorCode::ConnectionFailed,
1028 source: None,
1029 });
1030 }
1031 }
1032 .into_inner();
1033
1034 Ok(response)
1035 }
1036
1037 pub async fn get_entity_state(
1040 &mut self,
1041 req: GetEntityStateRequest,
1042 ) -> Result<GetEntityStateResponse> {
1043 self.engine_client
1044 .get_entity_state(req)
1045 .await
1046 .map(|response| response.into_inner())
1047 .map_err(|error| SdkError::Connection {
1048 message: format!("GetEntityState failed: {error}"),
1049 code: crate::error::ErrorCode::ConnectionFailed,
1050 source: None,
1051 })
1052 }
1053
1054 pub async fn put_entity_state(
1056 &mut self,
1057 req: PutEntityStateRequest,
1058 ) -> Result<PutEntityStateResponse> {
1059 self.engine_client
1060 .put_entity_state(req)
1061 .await
1062 .map(|response| response.into_inner())
1063 .map_err(|error| SdkError::Connection {
1064 message: format!("PutEntityState failed: {error}"),
1065 code: crate::error::ErrorCode::ConnectionFailed,
1066 source: None,
1067 })
1068 }
1069
1070 pub async fn renew_job_lease(
1072 &mut self,
1073 mut req: RenewJobLeaseRequest,
1074 ) -> Result<RenewJobLeaseResponse> {
1075 req.worker_id = self.effective_worker_id(&req.worker_id).to_string();
1076 let response = self
1077 .engine_client
1078 .renew_job_lease(req)
1079 .await
1080 .map_err(|e| {
1081 debug!("RenewJobLease RPC failed: {}", e);
1082 SdkError::Connection {
1083 message: format!("RenewJobLease failed: {}", e),
1084 code: crate::error::ErrorCode::ConnectionFailed,
1085 source: None,
1086 }
1087 })?
1088 .into_inner();
1089
1090 Ok(response)
1091 }
1092
1093 pub async fn report_worker_capacity(
1095 &mut self,
1096 mut req: ReportWorkerCapacityRequest,
1097 ) -> Result<ReportWorkerCapacityResponse> {
1098 req.worker_id = self.effective_worker_id(&req.worker_id).to_string();
1099 let response = self
1100 .engine_client
1101 .report_worker_capacity(req)
1102 .await
1103 .map_err(|e| {
1104 debug!("ReportWorkerCapacity RPC failed: {}", e);
1105 SdkError::Connection {
1106 message: format!("ReportWorkerCapacity failed: {}", e),
1107 code: crate::error::ErrorCode::ConnectionFailed,
1108 source: None,
1109 }
1110 })?
1111 .into_inner();
1112
1113 Ok(response)
1114 }
1115
1116 pub async fn complete_job(
1121 &mut self,
1122 mut req: CompleteJobRequest,
1123 ) -> Result<CompleteJobResponse> {
1124 req.worker_id = self.effective_worker_id(&req.worker_id).to_string();
1125 let response = self
1126 .engine_client
1127 .complete_job(req)
1128 .await
1129 .map_err(|e| {
1130 error!("CompleteJob RPC failed: {}", e);
1131 SdkError::Connection {
1132 message: format!("CompleteJob failed: {}", e),
1133 code: crate::error::ErrorCode::ConnectionFailed,
1134 source: None,
1135 }
1136 })?
1137 .into_inner();
1138
1139 Ok(response)
1140 }
1141
1142 pub async fn append_records(&mut self, records: Vec<Record>) -> Result<()> {
1146 if records.is_empty() {
1147 return Ok(());
1148 }
1149 let expected = records.len();
1150 let response = self
1151 .engine_client
1152 .append_batch(AppendBatchRequest { records })
1153 .await
1154 .map_err(|status| engine_append_status_error("Engine AppendBatch", status))?
1155 .into_inner();
1156 validate_append_batch_response(&response, expected).map(|_| ())
1157 }
1158
1159 pub async fn suspend_activation(
1163 &mut self,
1164 req: SuspendActivationRequest,
1165 ) -> Result<SuspendActivationResponse> {
1166 self.engine_client
1167 .suspend_activation(req)
1168 .await
1169 .map(|response| response.into_inner())
1170 .map_err(|status| activation_status("SuspendActivation", status))
1171 }
1172}
1173
1174pub async fn create_ee_event_stream(
1179 ee_client: &mut ExecutionEngineServiceClient<Channel>,
1180 worker_id: String,
1181) -> Result<flume::Sender<EventStreamMessage>> {
1182 let (tx, rx) = flume::bounded::<EventStreamMessage>(1000);
1183
1184 let stream = async_stream::stream! {
1185 loop {
1186 match rx.recv_async().await {
1187 Ok(msg) => yield msg,
1188 Err(_) => break, }
1190 }
1191 };
1192
1193 let mut client = ee_client.clone();
1194 tokio::spawn(async move {
1195 match client.event_stream(stream).await {
1196 Ok(response) => {
1197 let ack = response.into_inner();
1198 debug!(
1199 "EE EventStream closed: success={} events_received={}",
1200 ack.success, ack.events_received
1201 );
1202 }
1203 Err(e) => {
1204 debug!("EE EventStream error: {}", e);
1205 }
1206 }
1207 });
1208
1209 debug!("EE EventStream opened for worker {}", worker_id);
1210 Ok(tx)
1211}
1212
1213const ENGINE_POOL_SIZE: usize = 8;
1221const ENGINE_RPC_RETRY_ATTEMPTS: usize = 20;
1222const ENGINE_ACTIVATION_RPC_ATTEMPTS: usize = 6;
1223const ENGINE_RPC_RETRY_DELAY: Duration = Duration::from_millis(100);
1224
1225pub(crate) fn is_retryable_engine_status(status: &tonic::Status) -> bool {
1226 let message = status.message().to_ascii_lowercase();
1227 matches!(
1228 status.code(),
1229 tonic::Code::Unavailable
1230 | tonic::Code::DeadlineExceeded
1231 | tonic::Code::Cancelled
1232 | tonic::Code::Unknown
1233 ) || is_retryable_engine_message(&message)
1234}
1235
1236pub(crate) fn engine_append_status_error(operation: &str, status: tonic::Status) -> SdkError {
1237 SdkError::Connection {
1238 message: format!("{operation} failed: {status}"),
1239 code: crate::error::ErrorCode::ConnectionFailed,
1240 source: Some(Box::new(status)),
1241 }
1242}
1243
1244fn is_retryable_engine_message(message: &str) -> bool {
1245 message.contains("upstream connect error")
1246 || message.contains("disconnect/reset before headers")
1247 || message.contains("connection termination")
1248 || message.contains("connection refused")
1249 || message.contains("broken pipe")
1250 || message.contains("h2 protocol error")
1251 || message.contains("timeout expired")
1252 || message.contains("no sequencer available")
1253 || message.contains("this node is not the sequencer")
1254 || message.contains("stale epoch")
1255 || message.contains("future epoch")
1256 || message.contains("epoch ahead")
1257 || message.contains("not partition owner")
1258 || message.contains("partition not writable")
1259 || message.contains("quorum not reached")
1260 || message.contains("no connected peers for quorum replication")
1261 || message.contains("not connected for catch-up")
1262 || message.contains("catch-up failed")
1263}
1264
1265fn validate_append_batch_records(records: &[Record]) -> Result<()> {
1266 let Some(first) = records.first() else {
1267 return Ok(());
1268 };
1269 if records
1270 .iter()
1271 .skip(1)
1272 .any(|record| record.run_id != first.run_id)
1273 {
1274 return Err(SdkError::InvalidArgument {
1275 message: "AppendBatch records must share one run_id so the request cannot span journal partitions".to_string(),
1276 argument: Some("records".to_string()),
1277 });
1278 }
1279 Ok(())
1280}
1281
1282fn validate_append_batch_response(response: &AppendBatchResponse, expected: usize) -> Result<i32> {
1283 if response.offsets.len() != expected {
1284 return Err(SdkError::Internal(format!(
1285 "Engine AppendBatch response cardinality mismatch: expected {expected}, offsets={}",
1286 response.offsets.len()
1287 )));
1288 }
1289 if response.written_count < 0 || response.written_count as usize > expected {
1290 return Err(SdkError::Internal(format!(
1291 "Engine AppendBatch written_count out of range: expected 0..={expected}, got {}",
1292 response.written_count
1293 )));
1294 }
1295 Ok(response.written_count)
1296}
1297
1298async fn sleep_engine_retry(attempt: usize) {
1299 let multiplier = (attempt + 1) as u32;
1300 tokio::time::sleep(ENGINE_RPC_RETRY_DELAY * multiplier).await;
1301}
1302
1303fn should_retry_activation_status(status: &tonic::Status, attempt: usize) -> bool {
1304 attempt + 1 < ENGINE_ACTIVATION_RPC_ATTEMPTS
1305 && status.details().is_empty()
1306 && is_retryable_engine_status(status)
1307}
1308
1309#[derive(Debug, Clone)]
1315pub struct EngineClient {
1316 clients: Vec<EngineServiceClient<AuthenticatedChannel>>,
1317 next: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1318 authoritative_worker_id: Option<String>,
1319 identity_authorization: Option<Arc<RwLock<Option<MetadataValue<tonic::metadata::Ascii>>>>>,
1320 endpoint: String,
1321}
1322
1323impl EngineClient {
1324 pub async fn connect(endpoint: &str) -> Result<Self> {
1326 let (endpoint, interceptor, tls, authoritative_worker_id) =
1327 if remote_worker_bootstrap_enabled() {
1328 let (endpoint, interceptor, tls, worker_id, _authority) =
1329 external_worker_bootstrap().await?;
1330 (endpoint, interceptor, tls, worker_id)
1331 } else {
1332 (
1333 endpoint.to_string(),
1334 BearerInterceptor {
1335 token: Arc::new(RwLock::new(None)),
1336 },
1337 None,
1338 None,
1339 )
1340 };
1341 debug!(
1342 "Connecting to Engine at {} (pool_size={})",
1343 endpoint, ENGINE_POOL_SIZE
1344 );
1345
1346 let uri = if endpoint.contains("://") {
1347 endpoint.clone()
1348 } else {
1349 format!("http://{}", endpoint)
1350 };
1351
1352 let mut clients = Vec::with_capacity(ENGINE_POOL_SIZE);
1353 for i in 0..ENGINE_POOL_SIZE {
1354 let channel = Channel::from_shared(uri.clone()).map_err(|e| SdkError::Connection {
1355 message: format!("Invalid engine endpoint {}: {}", endpoint, e),
1356 code: crate::error::ErrorCode::ConnectionFailed,
1357 source: None,
1358 })?;
1359 let channel =
1360 configure_channel_tls(channel, tls.clone()).map_err(|e| SdkError::Connection {
1361 message: format!("Invalid worker TLS configuration: {}", e),
1362 code: crate::error::ErrorCode::ConnectionFailed,
1363 source: None,
1364 })?;
1365 let channel = channel
1366 .connect_timeout(Duration::from_secs(10))
1367 .timeout(Duration::from_secs(30))
1368 .http2_adaptive_window(true)
1369 .connect()
1370 .await
1371 .map_err(|e| {
1372 debug!("Engine connection {} to {} failed: {:?}", i, endpoint, e);
1373 SdkError::Connection {
1374 message: format!("Engine connection failed: {}", e),
1375 code: crate::error::ErrorCode::ConnectionFailed,
1376 source: None,
1377 }
1378 })?;
1379 clients.push(EngineServiceClient::with_interceptor(
1380 channel,
1381 interceptor.clone(),
1382 ));
1383 }
1384
1385 debug!(
1386 "Engine client pool connected ({} connections)",
1387 ENGINE_POOL_SIZE
1388 );
1389 Ok(Self {
1390 clients,
1391 next: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1392 identity_authorization: authoritative_worker_id
1393 .as_ref()
1394 .map(|_| interceptor.token.clone()),
1395 authoritative_worker_id,
1396 endpoint,
1397 })
1398 }
1399
1400 async fn next_client(&mut self) -> Result<&mut EngineServiceClient<AuthenticatedChannel>> {
1402 if self
1406 .identity_authorization
1407 .as_ref()
1408 .is_some_and(|token| token.read().unwrap_or_else(|p| p.into_inner()).is_none())
1409 {
1410 *self = Self::connect(&self.endpoint).await?;
1411 }
1412 let idx = self.next.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % self.clients.len();
1413 Ok(&mut self.clients[idx])
1414 }
1415
1416 pub async fn begin_activation(
1418 &mut self,
1419 request: BeginActivationRequest,
1420 ) -> Result<BeginActivationResponse> {
1421 let mut timer = crate::core_metrics::RpcTimer::new(&request.run_id, "begin");
1422 for attempt in 0..ENGINE_ACTIVATION_RPC_ATTEMPTS {
1423 match self
1424 .next_client()
1425 .await?
1426 .begin_activation(request.clone())
1427 .await
1428 {
1429 Ok(response) => {
1430 let response = response.into_inner();
1431 timer.outcome(match response.outcome {
1432 1 => "execute",
1433 2 => "replay",
1434 3 => "wait",
1435 4 => "conflict",
1436 5 => "cancelled",
1437 _ => "unknown",
1438 });
1439 return Ok(response);
1440 }
1441 Err(status) if should_retry_activation_status(&status, attempt) => {
1442 debug!(
1443 attempt = attempt + 1,
1444 status = %status,
1445 "BeginActivation hit a transient gRPC status; retrying the exact command"
1446 );
1447 sleep_engine_retry(attempt).await;
1448 }
1449 Err(status) => {
1450 timer.outcome("error");
1451 return Err(activation_status("BeginActivation", status));
1452 }
1453 }
1454 }
1455 unreachable!("activation retry loop always returns")
1456 }
1457
1458 pub async fn complete_activation(
1460 &mut self,
1461 request: CompleteActivationRequest,
1462 ) -> Result<CompleteActivationResponse> {
1463 let mut timer = crate::core_metrics::RpcTimer::new(&request.run_id, "complete");
1464 for attempt in 0..ENGINE_ACTIVATION_RPC_ATTEMPTS {
1465 match self
1466 .next_client()
1467 .await?
1468 .complete_activation(request.clone())
1469 .await
1470 {
1471 Ok(response) => {
1472 let response = response.into_inner();
1473 timer.outcome(if response.accepted {
1474 if response.replayed {
1475 "replay"
1476 } else {
1477 "success"
1478 }
1479 } else {
1480 "unknown"
1481 });
1482 return Ok(response);
1483 }
1484 Err(status) if should_retry_activation_status(&status, attempt) => {
1485 debug!(
1486 attempt = attempt + 1,
1487 status = %status,
1488 "CompleteActivation hit a transient gRPC status; retrying the exact command"
1489 );
1490 sleep_engine_retry(attempt).await;
1491 }
1492 Err(status) => {
1493 timer.outcome("error");
1494 return Err(activation_status("CompleteActivation", status));
1495 }
1496 }
1497 }
1498 unreachable!("activation retry loop always returns")
1499 }
1500
1501 pub async fn fail_activation(
1503 &mut self,
1504 request: FailActivationRequest,
1505 ) -> Result<FailActivationResponse> {
1506 let mut timer = crate::core_metrics::RpcTimer::new(&request.run_id, "fail");
1507 for attempt in 0..ENGINE_ACTIVATION_RPC_ATTEMPTS {
1508 match self
1509 .next_client()
1510 .await?
1511 .fail_activation(request.clone())
1512 .await
1513 {
1514 Ok(response) => {
1515 let response = response.into_inner();
1516 timer.outcome(if response.accepted {
1517 "success"
1518 } else {
1519 "unknown"
1520 });
1521 return Ok(response);
1522 }
1523 Err(status) if should_retry_activation_status(&status, attempt) => {
1524 debug!(
1525 attempt = attempt + 1,
1526 status = %status,
1527 "FailActivation hit a transient gRPC status; retrying the exact command"
1528 );
1529 sleep_engine_retry(attempt).await;
1530 }
1531 Err(status) => {
1532 timer.outcome("error");
1533 return Err(activation_status("FailActivation", status));
1534 }
1535 }
1536 }
1537 unreachable!("activation retry loop always returns")
1538 }
1539
1540 pub async fn suspend_activation(
1543 &mut self,
1544 request: SuspendActivationRequest,
1545 ) -> Result<SuspendActivationResponse> {
1546 for attempt in 0..ENGINE_ACTIVATION_RPC_ATTEMPTS {
1547 match self
1548 .next_client()
1549 .await?
1550 .suspend_activation(request.clone())
1551 .await
1552 {
1553 Ok(response) => return Ok(response.into_inner()),
1554 Err(status) if should_retry_activation_status(&status, attempt) => {
1555 debug!(
1556 attempt = attempt + 1,
1557 status = %status,
1558 "SuspendActivation hit a transient gRPC status; retrying the exact command"
1559 );
1560 sleep_engine_retry(attempt).await;
1561 }
1562 Err(status) => return Err(activation_status("SuspendActivation", status)),
1563 }
1564 }
1565 unreachable!("activation retry loop always returns")
1566 }
1567
1568 pub async fn append(&mut self, record: Record) -> Result<(u64, i64)> {
1570 for attempt in 0..ENGINE_RPC_RETRY_ATTEMPTS {
1571 match self
1572 .next_client()
1573 .await?
1574 .append(AppendRequest {
1575 record: Some(record.clone()),
1576 })
1577 .await
1578 {
1579 Ok(response) => {
1580 let response = response.into_inner();
1581 return Ok((response.offset, response.timestamp_ns));
1582 }
1583 Err(status)
1584 if attempt + 1 < ENGINE_RPC_RETRY_ATTEMPTS
1585 && is_retryable_engine_status(&status) =>
1586 {
1587 debug!(
1588 attempt = attempt + 1,
1589 max = ENGINE_RPC_RETRY_ATTEMPTS,
1590 "Engine Append hit retryable gRPC status: {}",
1591 status
1592 );
1593 sleep_engine_retry(attempt).await;
1594 }
1595 Err(status) => {
1596 debug!("Engine Append failed: {}", status);
1597 return Err(engine_append_status_error("Engine Append", status));
1598 }
1599 }
1600 }
1601
1602 unreachable!("engine append retry loop always returns")
1603 }
1604
1605 pub async fn append_batch(&mut self, records: Vec<Record>) -> Result<i32> {
1607 validate_append_batch_records(&records)?;
1608 let expected = records.len();
1609 for attempt in 0..ENGINE_RPC_RETRY_ATTEMPTS {
1610 match self
1611 .next_client()
1612 .await?
1613 .append_batch(AppendBatchRequest {
1614 records: records.clone(),
1615 })
1616 .await
1617 {
1618 Ok(response) => {
1619 let response = response.into_inner();
1620 return validate_append_batch_response(&response, expected);
1621 }
1622 Err(status)
1623 if attempt + 1 < ENGINE_RPC_RETRY_ATTEMPTS
1624 && is_retryable_engine_status(&status) =>
1625 {
1626 debug!(
1627 attempt = attempt + 1,
1628 max = ENGINE_RPC_RETRY_ATTEMPTS,
1629 "Engine AppendBatch hit retryable gRPC status: {}",
1630 status
1631 );
1632 sleep_engine_retry(attempt).await;
1633 }
1634 Err(status) => {
1635 debug!("Engine AppendBatch failed: {}", status);
1636 return Err(engine_append_status_error("Engine AppendBatch", status));
1637 }
1638 }
1639 }
1640
1641 unreachable!("engine append batch retry loop always returns")
1642 }
1643
1644 pub async fn stream_events(&mut self, mut events: Vec<EventStreamMessage>) -> Result<i64> {
1648 if events.is_empty() {
1649 return Ok(0);
1650 }
1651 if let Some(worker_id) = &self.authoritative_worker_id {
1652 for event in &mut events {
1653 event.worker_id.clone_from(worker_id);
1654 }
1655 }
1656 let expected = events.len() as i64;
1657 let response = self
1658 .next_client()
1659 .await?
1660 .event_stream(tokio_stream::iter(events))
1661 .await
1662 .map_err(|status| SdkError::Connection {
1663 message: format!("Engine EventStream failed: {status}"),
1664 code: crate::error::ErrorCode::ConnectionFailed,
1665 source: None,
1666 })?
1667 .into_inner();
1668 if !response.success || response.events_received != expected {
1669 return Err(SdkError::Connection {
1670 message: format!(
1671 "Engine EventStream acknowledged {} events, want {}",
1672 response.events_received, expected
1673 ),
1674 code: crate::error::ErrorCode::ConnectionFailed,
1675 source: None,
1676 });
1677 }
1678 Ok(response.events_received)
1679 }
1680}
1681
1682pub fn build_engine_record(
1684 tenant_id: String,
1685 run_id: String,
1686 event_type: String,
1687 data: Vec<u8>,
1688 timestamp_ns: i64,
1689 step_key: String,
1690 correlation_id: String,
1691 parent_event_id: String,
1692 metadata: HashMap<String, String>,
1693) -> Record {
1694 Record {
1695 offset: 0, project_id: tenant_id,
1697 run_id,
1698 event_type,
1699 data,
1700 timestamp_ns,
1701 step_key,
1702 correlation_id,
1703 parent_event_id,
1704 metadata,
1705 data_type: "json".to_string(),
1706 data_checksum: vec![],
1707 data_compressed: false,
1708 }
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713 use super::*;
1714
1715 #[tokio::test]
1716 async fn identity_rotation_wakes_connection_loop_only_after_invalidation() {
1717 let token = Arc::new(RwLock::new(Some(bearer_metadata("first").unwrap())));
1718 assert!(
1719 tokio::time::timeout(Duration::from_millis(10), wait_for_identity_rotation(None))
1720 .await
1721 .is_err()
1722 );
1723 assert!(tokio::time::timeout(
1724 Duration::from_millis(10),
1725 wait_for_identity_rotation(Some(&token))
1726 )
1727 .await
1728 .is_err());
1729 *token.write().unwrap() = Some(bearer_metadata("refreshed").unwrap());
1731 assert!(tokio::time::timeout(
1732 Duration::from_millis(10),
1733 wait_for_identity_rotation(Some(&token))
1734 )
1735 .await
1736 .is_err());
1737 let rotated = wait_for_identity_rotation(Some(&token));
1738 tokio::pin!(rotated);
1739 assert!(
1740 tokio::time::timeout(Duration::from_millis(10), &mut rotated)
1741 .await
1742 .is_err()
1743 );
1744 *token.write().unwrap() = None;
1745 tokio::time::timeout(Duration::from_secs(1), rotated)
1746 .await
1747 .unwrap();
1748 }
1749
1750 #[tokio::test]
1751 async fn https_channel_enables_tls_without_an_explicit_client_config() {
1752 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1753 let address = listener.local_addr().unwrap();
1754 let accept = tokio::spawn(async move {
1755 let (socket, _) = listener.accept().await.unwrap();
1756 drop(socket);
1757 });
1758
1759 let endpoint = Channel::from_shared(format!("https://{address}"))
1760 .expect("test endpoint should be valid");
1761 let error = configure_channel_tls(endpoint, None)
1762 .expect("default HTTPS TLS configuration should be valid")
1763 .connect_timeout(Duration::from_secs(1))
1764 .connect()
1765 .await
1766 .expect_err("the test listener intentionally does not speak TLS");
1767
1768 accept.await.unwrap();
1769 let error = format!("{error:?}");
1770 assert!(
1771 !error.contains("HttpsUriWithoutTlsSupport"),
1772 "HTTPS channel attempted to connect without TLS: {error}"
1773 );
1774 }
1775
1776 #[test]
1777 fn external_worker_token_request_contains_only_immutable_authority() {
1778 let authority = ExternalWorkerAuthority {
1779 project_id: "project-1".into(),
1780 environment_id: "environment-1".into(),
1781 deployment_id: "deployment-1".into(),
1782 worker_pool_id: "pool-1".into(),
1783 runtime_endpoint: "https://runtime.example".into(),
1784 protocol: "pull.v1".into(),
1785 auth_profile: AUTH_PROFILE_TOKEN_AUTH.into(),
1786 identity_endpoint: String::new(),
1787 };
1788
1789 let value = serde_json::to_value(ExternalWorkerTokenRequest::from(&authority)).unwrap();
1790
1791 assert_eq!(
1792 value,
1793 serde_json::json!({
1794 "project_id": "project-1",
1795 "environment_id": "environment-1",
1796 "deployment_id": "deployment-1",
1797 "worker_pool_id": "pool-1"
1798 })
1799 );
1800 }
1801
1802 #[test]
1803 fn external_worker_authority_overrides_preconfigured_routing_metadata() {
1804 let authority = ExternalWorkerAuthority {
1805 project_id: "discovered-project".into(),
1806 environment_id: "discovered-environment".into(),
1807 deployment_id: "discovered-deployment".into(),
1808 worker_pool_id: "discovered-pool".into(),
1809 runtime_endpoint: "https://runtime.example".into(),
1810 protocol: "pull.v1".into(),
1811 auth_profile: AUTH_PROFILE_TOKEN_AUTH.into(),
1812 identity_endpoint: String::new(),
1813 };
1814 let mut metadata = HashMap::from([
1815 ("project_id".to_string(), "configured-project".to_string()),
1816 (
1817 "deployment_id".to_string(),
1818 "configured-deployment".to_string(),
1819 ),
1820 ]);
1821
1822 authority.apply_to_metadata(&mut metadata);
1823
1824 assert_eq!(metadata.get("project_id").unwrap(), "discovered-project");
1825 assert_eq!(
1826 metadata.get("environment_id").unwrap(),
1827 "discovered-environment"
1828 );
1829 assert_eq!(
1830 metadata.get("deployment_id").unwrap(),
1831 "discovered-deployment"
1832 );
1833 assert_eq!(metadata.get("worker_pool_id").unwrap(), "discovered-pool");
1834 }
1835
1836 #[test]
1837 fn retryable_engine_status_includes_partition_handoff_errors() {
1838 let status = tonic::Status::internal(
1839 "Engine AppendBatch failed: no sequencer available after retry",
1840 );
1841 assert!(is_retryable_engine_status(&status));
1842
1843 let status = tonic::Status::failed_precondition("stale epoch on forward: 162 < 163");
1844 assert!(is_retryable_engine_status(&status));
1845 }
1846
1847 #[test]
1848 fn retryable_engine_status_does_not_retry_plain_internal_errors() {
1849 let status = tonic::Status::internal("serialization failed");
1850 assert!(!is_retryable_engine_status(&status));
1851 }
1852
1853 #[test]
1854 fn activation_rpc_retry_is_bounded_to_transient_statuses() {
1855 let lagging_replica =
1856 tonic::Status::unavailable("activation actv1_test is not yet visible on this replica");
1857 assert!(should_retry_activation_status(&lagging_replica, 0));
1858 assert!(!should_retry_activation_status(
1859 &lagging_replica,
1860 ENGINE_ACTIVATION_RPC_ATTEMPTS - 1
1861 ));
1862
1863 let conflict = tonic::Status::already_exists("payload conflict");
1864 assert!(!should_retry_activation_status(&conflict, 0));
1865
1866 let typed_conflict = tonic::Status::with_details(
1867 tonic::Code::Unavailable,
1868 "typed activation outcome",
1869 bytes::Bytes::from_static(b"activation-error-detail"),
1870 );
1871 assert!(!should_retry_activation_status(&typed_conflict, 0));
1872 }
1873
1874 #[tokio::test]
1875 async fn retired_engine_identity_reconnects_before_using_cached_channels() {
1876 let mut client = EngineClient {
1877 clients: Vec::new(),
1879 next: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1880 authoritative_worker_id: Some("worker".into()),
1881 identity_authorization: Some(Arc::new(RwLock::new(None))),
1882 endpoint: "invalid endpoint".into(),
1883 };
1884 let error = client.next_client().await.unwrap_err();
1885 assert!(error.to_string().contains("Invalid engine endpoint"));
1886 assert_eq!(client.next.load(std::sync::atomic::Ordering::Relaxed), 0);
1887 }
1888
1889 #[tokio::test]
1890 async fn append_batch_rejects_multi_run_before_retry_or_rpc() {
1891 let mut client = EngineClient {
1892 clients: Vec::new(),
1893 next: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1894 authoritative_worker_id: None,
1895 identity_authorization: None,
1896 endpoint: "http://127.0.0.1:1".into(),
1897 };
1898 let records = vec![
1899 Record {
1900 run_id: "run-a".into(),
1901 event_type: "step.started".into(),
1902 ..Default::default()
1903 },
1904 Record {
1905 run_id: "run-b".into(),
1906 event_type: "step.completed".into(),
1907 ..Default::default()
1908 },
1909 ];
1910
1911 let error = client.append_batch(records).await.unwrap_err();
1912
1913 assert!(matches!(error, SdkError::InvalidArgument { .. }));
1914 assert_eq!(
1915 client.next.load(std::sync::atomic::Ordering::Relaxed),
1916 0,
1917 "preflight rejection must happen before the retry loop selects a connection"
1918 );
1919 }
1920
1921 #[test]
1922 fn append_batch_rejects_malformed_response_counts() {
1923 for response in [
1924 AppendBatchResponse {
1925 offsets: vec![1],
1926 written_count: 1,
1927 },
1928 AppendBatchResponse {
1929 offsets: vec![1, 2],
1930 written_count: -1,
1931 },
1932 AppendBatchResponse {
1933 offsets: vec![1, 2],
1934 written_count: 3,
1935 },
1936 ] {
1937 let error = validate_append_batch_response(&response, 2).unwrap_err();
1938 assert!(matches!(error, SdkError::Internal(_)));
1939 }
1940 }
1941
1942 #[test]
1943 fn protocol_capability_negotiation_fails_closed_when_required() {
1944 let durable = DURABLE_ACTIVATION_V1_CAPABILITY.to_string();
1945 let error = validate_protocol_capabilities(
1946 std::slice::from_ref(&durable),
1947 std::slice::from_ref(&durable),
1948 &[],
1949 &[],
1950 )
1951 .unwrap_err();
1952 assert_eq!(error.code(), crate::error::ErrorCode::DurabilityUnavailable);
1953
1954 let error =
1955 validate_protocol_capabilities(&[], &[], &[], &["runtime_v2".into()]).unwrap_err();
1956 assert_eq!(error.code(), crate::error::ErrorCode::DurabilityUnavailable);
1957
1958 validate_protocol_capabilities(
1959 std::slice::from_ref(&durable),
1960 std::slice::from_ref(&durable),
1961 std::slice::from_ref(&durable),
1962 &[],
1963 )
1964 .unwrap();
1965 }
1966
1967 #[test]
1968 fn protocol_capability_negotiation_retains_only_the_intersection() {
1969 assert_eq!(
1970 negotiated_protocol_capabilities(
1971 &[
1972 DURABLE_ACTIVATION_V1_CAPABILITY.into(),
1973 "worker_only".into(),
1974 DURABLE_ACTIVATION_V1_CAPABILITY.into(),
1975 ],
1976 &[
1977 DURABLE_ACTIVATION_V1_CAPABILITY.into(),
1978 "runtime_only".into(),
1979 ],
1980 ),
1981 vec![DURABLE_ACTIVATION_V1_CAPABILITY.to_string()]
1982 );
1983 }
1984
1985 #[test]
1986 fn server_slot_scaling_is_optional_and_additive() {
1987 let (supported, required) = worker_protocol_capabilities();
1988 assert!(supported.contains(&SERVER_SLOT_SCALING_V1_CAPABILITY.to_string()));
1989 assert!(!required.contains(&SERVER_SLOT_SCALING_V1_CAPABILITY.to_string()));
1990
1991 #[derive(Clone, PartialEq, prost::Message)]
1992 struct LegacyPollJobResponse {
1993 #[prost(message, optional, tag = "1")]
1994 job: Option<crate::pb::JobAssignment>,
1995 }
1996
1997 let legacy_bytes = prost::Message::encode_to_vec(&LegacyPollJobResponse { job: None });
1998 let decoded_new = <PollJobResponse as prost::Message>::decode(legacy_bytes.as_slice())
1999 .expect("new worker must decode an old response");
2000 assert!(decoded_new.slot_scaling.is_none());
2001
2002 let new_bytes = prost::Message::encode_to_vec(&PollJobResponse {
2003 job: None,
2004 slot_scaling: Some(crate::pb::SlotScalingHint {
2005 decision: crate::pb::SlotScalingDecision::ScaleUp as i32,
2006 suggested_delta: 2,
2007 }),
2008 });
2009 let decoded_legacy =
2010 <LegacyPollJobResponse as prost::Message>::decode(new_bytes.as_slice())
2011 .expect("old worker must ignore a new response field");
2012 assert!(decoded_legacy.job.is_none());
2013 }
2014}
2015
2016#[derive(Debug, Clone)]
2018pub struct CheckpointResult {
2019 pub success: bool,
2021 pub error_message: Option<String>,
2023 pub memoized: bool,
2025 pub cached_output: Option<Vec<u8>>,
2027}