1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
16use std::fmt;
17use std::sync::{Arc, Mutex};
18use std::time::Duration;
19
20use rustev_contract::canonical::canonical_value_bytes;
21use rustev_contract::definition::{Determinism, Operation};
22use rustev_contract::descriptor::{BackendDescriptor, Isolation, OutputKind};
23use rustev_contract::ids::{ArtifactId, DescriptorId};
24use rustev_contract::limits::REMOTE_V1;
25use rustev_contract::output::RawOutput;
26use rustev_contract::remote::{
27 Attribution, Batching, CancelRequest, CancelResponse, CancelSupport, DescribeRequest,
28 DescribeResponse, Disclosure, ExchangeMember, HostedBackend, InferItem, InferRequest,
29 InferResponse, ItemResult, MAX_ITEMS, MemberOutcome, PROTOCOL_MAJOR, RemoteCancelAnswer,
30 RemoteCode, RemoteTerms, ServedIdentity, WirePrincipal, protocol_name,
31};
32use rustev_contract::run::{
33 BatchMembership, Charge, ChargeProvenance, CostBound, CostModel, EvidenceUnavailable,
34 ExchangeReference, RemoteEvidence, RemoteState, RetentionClass,
35};
36use rustev_contract::{Document, Identified, schema};
37use rustev_core::seams::{
38 AdapterFailure, AttemptCall, AttemptReport, BatchAttemptCall, BatchAttemptReport,
39 BatchCapability, BatchMemberReport, BoxFuture, CancelAck, CancelSignal, DecisionBackend,
40 RemoteEnd,
41};
42use serde::Deserialize;
43use tokio::sync::oneshot;
44
45use crate::budget::{Budget, TimeoutBeyondBudget, check_transport_timeout};
46use crate::cancel::{cancel_outcome, cancelled_report};
47use crate::cost::{UnitRate, attribute, convert_bound, convert_charge};
48use crate::exchange::{
49 ExchangeSink, ExchangeStats, Exchanges, ReconciliationOffer, RetainedBytes, blank_record,
50 output_digest,
51};
52use crate::http::{
53 Endpoint, EndpointError, HttpPool, HttpReply, HttpTransport, Scheme, Secret, TransportError,
54};
55use crate::identity::{AdapterBinding, served_identity};
56use crate::phases::TransportPhases;
57use crate::pool::{KeyMaterial, POOL_KEY_SCHEMA, PoolBinding, credential_identity};
58use crate::principal::{EndpointOperator, PrincipalPosture, PrincipalWire};
59use crate::server::{CANCEL_PATH, DESCRIBE_PATH, INFER_PATH};
60use crate::taxonomy::{RemoteFailure, classify_status, is_local_only};
61use crate::wire::{SentTracker, digest_bytes};
62
63pub const MAPPING_VERSION: &str = "rustev.remote/1 identity mapping";
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct BatchConfig {
71 pub window_ms: u64,
74 pub max_items: u32,
77}
78
79#[derive(Clone)]
83pub struct ClientConfig {
84 pub endpoint: String,
86 pub backend_id: String,
88 pub endpoint_class: String,
90 pub bearer: Option<Secret>,
91 pub tls: Option<Arc<rustls::ClientConfig>>,
93 pub setup_budget_ms: u64,
95 pub transport_timeout_ms: Option<u64>,
98 pub transit_margin_ms: u64,
100 pub unit_rate: UnitRate,
102 pub privacy: BTreeMap<String, String>,
104 pub batching: Option<BatchConfig>,
106 pub retain_bytes: bool,
108 pub await_cancel_confirmation: bool,
113 pub cancel_budget_ms: u64,
115 pub phases: TransportPhases,
118 pub protocol_major: u32,
121 pub operator: EndpointOperator,
123 pub principal: PrincipalPosture,
125 pub pool: Option<Arc<HttpPool>>,
129 pub tls_label: Option<String>,
133 pub batch_dispatch: Option<u32>,
138 pub run_evidence: bool,
142}
143
144impl ClientConfig {
145 pub fn new(
146 endpoint: impl Into<String>,
147 backend_id: impl Into<String>,
148 endpoint_class: impl Into<String>,
149 ) -> Self {
150 ClientConfig {
151 endpoint: endpoint.into(),
152 backend_id: backend_id.into(),
153 endpoint_class: endpoint_class.into(),
154 bearer: None,
155 tls: None,
156 setup_budget_ms: 5_000,
157 transport_timeout_ms: None,
158 transit_margin_ms: 20,
159 unit_rate: UnitRate::ONE,
160 privacy: BTreeMap::new(),
161 batching: None,
162 retain_bytes: false,
163 await_cancel_confirmation: false,
164 cancel_budget_ms: 1_000,
165 phases: TransportPhases::Disabled,
166 protocol_major: PROTOCOL_MAJOR,
167 operator: EndpointOperator::ThirdParty,
168 principal: PrincipalPosture::Omitted,
169 pool: None,
170 tls_label: None,
171 run_evidence: false,
172 batch_dispatch: None,
173 }
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum SetupError {
180 Endpoint(EndpointError),
181 TimeoutBeyondBudget(TimeoutBeyondBudget),
182 Remote {
184 code: RemoteCode,
185 detail: String,
186 },
187 NoCommonVersion(u32),
188 NoSuchBackend(String),
189 Identity(String),
191 Terms(String),
193 Config(String),
196}
197
198impl fmt::Display for SetupError {
199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200 match self {
201 SetupError::Endpoint(e) => write!(f, "{e}"),
202 SetupError::TimeoutBeyondBudget(e) => write!(f, "{e}"),
203 SetupError::Remote { code, detail } => {
204 write!(f, "negotiation failed: remote:{}: {detail}", code.as_str())
205 }
206 SetupError::NoCommonVersion(v) => write!(f, "remote chose protocol version {v}"),
207 SetupError::NoSuchBackend(b) => write!(f, "the remote side hosts no `{b}`"),
208 SetupError::Identity(s) => write!(f, "descriptor identity: {s}"),
209 SetupError::Terms(s) => write!(f, "remote terms: {s}"),
210 SetupError::Config(s) => write!(f, "configuration: {s}"),
211 }
212 }
213}
214
215impl std::error::Error for SetupError {}
216
217struct RecentIds {
219 set: HashSet<String>,
220 order: VecDeque<String>,
221}
222
223const RECENT_IDS: usize = 65_536;
224
225impl RecentIds {
226 fn insert(&mut self, id: &str) -> bool {
227 if self.set.contains(id) {
228 return false;
229 }
230 if self.order.len() >= RECENT_IDS
231 && let Some(old) = self.order.pop_front()
232 {
233 self.set.remove(&old);
234 }
235 self.set.insert(id.to_string());
236 self.order.push_back(id.to_string());
237 true
238 }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242enum Status {
243 Waiting,
244 Withdrawn,
246 Abandoned,
248 Delivered,
249}
250
251struct Member {
252 attempt_id: String,
253 projection: String,
254 kind: OutputKind,
255 budget: Budget,
256 status: Status,
257 tx: Option<oneshot::Sender<AttemptReport>>,
258 settled: bool,
260 confirmed: Option<Charge>,
264 awaiting: bool,
267 late_share: Option<u64>,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271enum Phase {
272 Collecting,
273 Sending,
274}
275
276struct ExState {
277 phase: Phase,
278 members: Vec<Member>,
279 flush: Option<oneshot::Sender<()>>,
280 finished: Option<bool>,
282}
283
284struct Exchange {
285 state: Mutex<ExState>,
286 sent: SentTracker,
287 trace_id: String,
288 key: Option<(String, String)>,
289 principal: Option<WirePrincipal>,
291 batch_id: Option<String>,
294}
295
296struct Inner {
297 transport: HttpTransport,
298 config: ClientConfig,
299 remote: HostedBackend,
300 descriptor: BackendDescriptor,
301 binding: ArtifactId,
302 exchanges: Exchanges,
303 recent: Mutex<RecentIds>,
304 open: Mutex<HashMap<(String, String), Arc<Exchange>>>,
305 max_items: usize,
306 version: u32,
308 protocol: &'static str,
309 evidence: Mutex<EvidenceStore>,
311 batch_max: Option<usize>,
314}
315
316#[derive(Default)]
319struct EvidenceStore {
320 map: HashMap<String, RemoteEvidence>,
321 order: VecDeque<String>,
322}
323
324const EVIDENCE_KEPT: usize = 4_096;
325
326impl EvidenceStore {
327 fn put(&mut self, e: RemoteEvidence) {
328 if !self.map.contains_key(&e.attempt_id) {
329 if self.order.len() >= EVIDENCE_KEPT
330 && let Some(old) = self.order.pop_front()
331 {
332 self.map.remove(&old);
333 }
334 self.order.push_back(e.attempt_id.clone());
335 }
336 self.map.insert(e.attempt_id.clone(), e);
337 }
338
339 fn take(&mut self, attempt_id: &str) -> Option<RemoteEvidence> {
340 let e = self.map.remove(attempt_id)?;
341 self.order.retain(|x| x != attempt_id);
342 Some(e)
343 }
344}
345
346fn evidence(
348 inner: &Inner,
349 attempt_id: &str,
350 principal: Option<&WirePrincipal>,
351 reason: EvidenceUnavailable,
352) -> RemoteEvidence {
353 RemoteEvidence {
354 attempt_id: attempt_id.to_string(),
355 step: String::new(),
356 instance: vec![],
357 exchange: ExchangeReference::Unavailable { reason },
358 requested_identity: None,
359 served: ServedIdentity::unknown(),
360 binding: inner.binding.clone(),
361 protocol: inner.protocol.into(),
362 endpoint_class: inner.config.endpoint_class.clone(),
363 code: None,
364 remote_state: RemoteState::Finished,
365 charge_provenance: ChargeProvenance::Unknown,
366 batch: None,
367 principal_digest: principal.map(|p| p.evidence().digest),
368 }
369}
370
371#[derive(Clone)]
373pub struct RemoteClient {
374 inner: Arc<Inner>,
375}
376
377fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
378 m.lock().unwrap_or_else(|e| e.into_inner())
379}
380
381fn setup_failure(code: RemoteCode, detail: impl Into<String>) -> SetupError {
382 SetupError::Remote {
383 code,
384 detail: detail.into(),
385 }
386}
387
388fn transport_code(e: &TransportError) -> (RemoteCode, String) {
389 match e {
390 TransportError::Unsent(d) => (RemoteCode::TransportUnsent, d.clone()),
391 TransportError::Interrupted(d) => (RemoteCode::TransportInterrupted, d.clone()),
392 TransportError::TooLarge { limit } => (
393 RemoteCode::MalformedResponse,
394 format!("response exceeds {limit} bytes"),
395 ),
396 TransportError::Expired { sent: false } => {
397 (RemoteCode::TransportUnsent, "the budget ran out".into())
398 }
399 TransportError::Expired { sent: true } => (
400 RemoteCode::TransportInterrupted,
401 "the budget ran out before a complete response".into(),
402 ),
403 TransportError::PhaseExpired(p) => (
404 p.code,
405 format!(
406 "the {:?} phase ran out after {} ms of its {} ms allowance",
407 p.phase, p.elapsed_ms, p.allowance_ms
408 ),
409 ),
410 }
411}
412
413fn budget_ran_out(e: &TransportError, budget: &Budget) -> bool {
417 match e {
418 TransportError::Expired { .. } => budget.expired(),
419 TransportError::PhaseExpired(p) => {
420 p.limit == rustev_contract::remote::PhaseLimit::Budget && budget.expired()
421 }
422 _ => false,
423 }
424}
425
426fn validate_terms(t: &RemoteTerms) -> Result<(), SetupError> {
427 let bad = |s: &str| Err(SetupError::Terms(s.to_string()));
428 if (t.served.disclosure == Disclosure::Pinned) != t.served.identity.is_some() {
429 return bad("a served identity is given exactly when it is pinned");
430 }
431 if t.max_request_bytes == 0 || t.max_response_bytes == 0 {
432 return bad("zero request or response limit");
433 }
434 if let Batching::SharedState { max_items: 0 } = t.batching {
435 return bad("shared_state with max_items 0");
436 }
437 Ok(())
438}
439
440impl RemoteClient {
441 pub async fn connect(
445 config: ClientConfig,
446 sink: Arc<dyn ExchangeSink>,
447 ) -> Result<RemoteClient, SetupError> {
448 check_transport_timeout(config.transport_timeout_ms, config.setup_budget_ms)
449 .map_err(SetupError::TimeoutBeyondBudget)?;
450 config
451 .phases
452 .validate()
453 .map_err(|e| SetupError::Config(e.to_string()))?;
454 let protocol = protocol_name(config.protocol_major).ok_or_else(|| {
455 SetupError::Config(format!("no protocol version {}", config.protocol_major))
456 })?;
457 let principal_wire = config
458 .principal
459 .validate(config.operator, config.protocol_major)
460 .map_err(|e| SetupError::Config(e.to_string()))?;
461 if config
462 .batch_dispatch
463 .is_some_and(|m| m < 2 || m as usize > MAX_ITEMS)
464 {
465 return Err(SetupError::Config(format!(
466 "a runtime-planned batch has 2 to {MAX_ITEMS} members"
467 )));
468 }
469 let endpoint = Endpoint::parse(&config.endpoint).map_err(SetupError::Endpoint)?;
470 let transport = HttpTransport::new(
471 endpoint,
472 config.tls.clone(),
473 config.bearer.clone(),
474 config.transport_timeout_ms.map(Duration::from_millis),
475 )
476 .map_err(SetupError::Endpoint)?
477 .with_phases(config.phases);
478 let body = DescribeRequest {
479 schema: schema::REMOTE_DESCRIBE.into(),
480 versions: vec![config.protocol_major],
481 }
482 .canonical()
483 .map_err(|e| SetupError::Terms(e.to_string()))?;
484 let budget = Budget::from_ms(config.setup_budget_ms);
485 let reply = transport
486 .post(
487 DESCRIBE_PATH,
488 body,
489 REMOTE_V1.max_bytes,
490 &budget,
491 &SentTracker::new(),
492 )
493 .await
494 .map_err(|e| {
495 let (code, detail) = transport_code(&e);
496 setup_failure(code, detail)
497 })?;
498 if let Some(code) = classify_status(reply.status) {
499 return Err(setup_failure(code, format!("HTTP {}", reply.status)));
500 }
501 let described = DescribeResponse::parse(&reply.body)
502 .map_err(|e| setup_failure(RemoteCode::MalformedResponse, e.to_string()))?;
503 if described.version != config.protocol_major {
504 return Err(SetupError::NoCommonVersion(described.version));
505 }
506 let remote = described
507 .backends
508 .into_iter()
509 .find(|b| b.descriptor.backend_id == config.backend_id)
510 .ok_or_else(|| SetupError::NoSuchBackend(config.backend_id.clone()))?;
511 let id = remote
512 .descriptor
513 .id()
514 .map_err(|e| SetupError::Identity(e.to_string()))?;
515 if id != remote.descriptor_id {
516 return Err(SetupError::Identity(
517 "the descriptor does not hash to its stated identity".into(),
518 ));
519 }
520 validate_terms(&remote.terms)?;
521 if let Some(p) = config
522 .privacy
523 .keys()
524 .find(|k| !remote.terms.privacy.contains(k))
525 {
526 return Err(SetupError::Terms(format!(
527 "privacy option `{p}` is not supported"
528 )));
529 }
530 let binding = AdapterBinding {
531 protocol: protocol.into(),
532 endpoint_class: config.endpoint_class.clone(),
533 remote_artifact: Some(remote.descriptor.artifact.clone()),
534 remote_descriptor: Some(remote.descriptor_id.clone()),
535 requested_model: None,
536 model_pin: remote.terms.served.identity.clone(),
537 mapping_version: MAPPING_VERSION.into(),
538 mapping_tables: BTreeMap::new(),
539 routing_privacy: config.privacy.clone(),
540 max_request_bytes: remote.terms.max_request_bytes,
541 max_response_bytes: remote.terms.max_response_bytes,
542 transport_phases: config.phases,
543 principal_wire: principal_wire.clone(),
544 run_record_schema: config
545 .run_evidence
546 .then(|| rustev_contract::schema::RUN_V2.to_string()),
547 batch_dispatch_max_members: config.batch_dispatch,
548 connection_pool: config
549 .pool
550 .as_ref()
551 .map(|p| p.binding())
552 .unwrap_or(PoolBinding::Disabled),
553 }
554 .artifact()
555 .map_err(|e| SetupError::Identity(e.to_string()))?;
556 let transport = match &config.pool {
559 None => transport,
560 Some(pool) => {
561 let endpoint = transport.endpoint().clone();
562 let tls = match (endpoint.scheme, &config.tls, &config.tls_label) {
563 (Scheme::Http, _, _) => Some("plaintext".to_string()),
564 (Scheme::Https, None, _) => Some("webpki-roots".to_string()),
565 (Scheme::Https, Some(_), label) => label.clone(),
566 };
567 let material = KeyMaterial {
568 schema: POOL_KEY_SCHEMA.into(),
569 endpoint_class: config.endpoint_class.clone(),
570 authority: format!(
571 "{}://{}:{}",
572 match endpoint.scheme {
573 Scheme::Http => "http",
574 Scheme::Https => "https",
575 },
576 endpoint.host,
577 endpoint.port
578 ),
579 protocol: format!("{protocol} http/1.1"),
580 proxy_route: "direct".into(),
581 tls,
582 credential: credential_identity(config.bearer.as_ref().map(|s| s.expose())),
583 privacy: config.privacy.clone(),
584 principal_posture: match &principal_wire {
585 PrincipalWire::Omitted => "omitted".into(),
586 PrincipalWire::HostOperated {
587 mapping_version,
588 scope,
589 } => format!("host_operated {mapping_version} {scope}"),
590 },
591 principal: None,
592 binding: Some(binding.clone()),
593 };
594 transport.with_pool(pool.clone(), material)
595 }
596 };
597 let descriptor = BackendDescriptor {
600 artifact: binding.clone(),
601 determinism: Determinism::Unspecified,
602 ..remote.descriptor.clone()
603 };
604 let max_items = match (config.batching, remote.terms.batching) {
605 (Some(b), Batching::SharedState { max_items }) => {
606 (b.max_items.min(max_items) as usize).clamp(1, MAX_ITEMS)
607 }
608 _ => 1,
609 };
610 let batch_max = match (config.batch_dispatch, remote.terms.batching) {
613 (Some(m), Batching::SharedState { max_items }) => {
614 Some((m.min(max_items) as usize).min(MAX_ITEMS)).filter(|n| *n >= 2)
615 }
616 _ => None,
617 };
618 let exchanges = Exchanges::new(sink, config.retain_bytes);
619 let config_version = config.protocol_major;
620 Ok(RemoteClient {
621 inner: Arc::new(Inner {
622 transport,
623 config,
624 remote,
625 descriptor,
626 binding,
627 exchanges,
628 recent: Mutex::new(RecentIds {
629 set: HashSet::new(),
630 order: VecDeque::new(),
631 }),
632 open: Mutex::new(HashMap::new()),
633 max_items,
634 version: config_version,
635 protocol,
636 evidence: Mutex::new(EvidenceStore::default()),
637 batch_max,
638 }),
639 })
640 }
641
642 pub fn terms(&self) -> &RemoteTerms {
644 &self.inner.remote.terms
645 }
646
647 pub fn remote_descriptor(&self) -> (&BackendDescriptor, &DescriptorId) {
649 (
650 &self.inner.remote.descriptor,
651 &self.inner.remote.descriptor_id,
652 )
653 }
654
655 pub fn binding(&self) -> &ArtifactId {
657 &self.inner.binding
658 }
659
660 pub fn max_items(&self) -> usize {
662 self.inner.max_items
663 }
664
665 pub fn exchange_stats(&self) -> ExchangeStats {
666 self.inner.exchanges.stats()
667 }
668}
669
670#[derive(Deserialize)]
671struct ProjectionHead {
672 operation: Operation,
673 #[serde(default)]
674 instance: serde_json::Value,
675 #[serde(default)]
676 values: serde_json::Value,
677}
678
679fn output_kind_of(o: &RawOutput) -> Option<OutputKind> {
680 match o {
681 RawOutput::TextRecord(_) | RawOutput::TypedRecord { .. } => None,
683 RawOutput::Screen { .. } => Some(OutputKind::Screen),
684 RawOutput::Label(_) => Some(OutputKind::Label),
685 RawOutput::Distribution(_) => Some(OutputKind::Distribution),
686 RawOutput::Logits(_) => Some(OutputKind::Logits),
687 RawOutput::Scores(_) => Some(OutputKind::Scores),
688 }
689}
690
691impl DecisionBackend for RemoteClient {
692 fn descriptor(&self) -> &BackendDescriptor {
693 &self.inner.descriptor
694 }
695
696 fn cost_model(&self) -> CostModel {
699 match self.inner.remote.terms.cost {
700 CostBound::Bounded { .. } => CostModel::Bounded,
701 CostBound::Estimated { .. } => CostModel::Estimated,
702 CostBound::Unknown => CostModel::Unknown,
703 }
704 }
705
706 fn cost_bound(&self, _projection: &[u8]) -> CostBound {
707 convert_bound(self.inner.remote.terms.cost, self.inner.config.unit_rate)
708 }
709
710 fn infer<'a>(&'a self, call: AttemptCall<'a>) -> BoxFuture<'a, AttemptReport> {
711 let inner = self.inner.clone();
712 let attempt_id = call.attempt_id.to_string();
713 let signal = call.cancel.clone();
714 let budget = Budget::start(call.cx.remaining_ms);
715 let trace_id = call.cx.trace_id.clone();
716 let projection = call.projection.to_vec();
717 let principal = inner
719 .config
720 .principal
721 .for_attempt(&call.cx.principal_handle);
722 Box::pin(async move {
723 let Ok(principal) = principal else {
724 let report = unsent_failure(
726 RemoteCode::MalformedRequest,
727 "no principal reference within its bounds; not sent",
728 );
729 note_unexchanged(&inner, &attempt_id, None, &report);
730 return report;
731 };
732 let p = principal.clone();
733 let report = attempt(
734 inner.clone(),
735 attempt_id.clone(),
736 projection,
737 trace_id,
738 principal,
739 budget,
740 signal,
741 )
742 .await;
743 note_unexchanged(&inner, &attempt_id, p.as_ref(), &report);
744 report
745 })
746 }
747
748 fn take_remote_evidence(&self, attempt_id: &str) -> Option<RemoteEvidence> {
749 lock(&self.inner.evidence).take(attempt_id)
750 }
751
752 fn batch_capability(&self, projection: &[u8]) -> Option<BatchCapability> {
759 self.inner.batch_max?;
760 if self
761 .inner
762 .descriptor
763 .operations
764 .iter()
765 .any(|o| o.screen.is_some() && o.isolation == Some(Isolation::PerItem))
766 {
767 return None;
768 }
769 let head =
770 rustev_contract::bounded::parse_bounded::<ProjectionHead>(projection, &REMOTE_V1)
771 .ok()?;
772 self.inner
773 .descriptor
774 .operations
775 .iter()
776 .any(|o| o.operation == head.operation)
777 .then_some(())?;
778 let state = canonical_value_bytes(&serde_json::json!({
779 "binding": self.inner.binding.as_str(),
780 "instance": head.instance,
781 "values": head.values,
782 }))
783 .ok()?;
784 Some(BatchCapability {
785 compatibility: digest_bytes(&state),
786 work_units: 1,
787 })
788 }
789
790 fn infer_batch<'a>(&'a self, call: BatchAttemptCall) -> BoxFuture<'a, BatchAttemptReport> {
791 let inner = self.inner.clone();
792 Box::pin(async move { batch(inner, call).await })
793 }
794}
795
796fn batch_refused(failure: AdapterFailure, code: RemoteCode, detail: &str) -> BatchAttemptReport {
798 BatchAttemptReport {
799 result: Err((failure, crate::taxonomy::failure_detail(code, detail))),
800 charge: Charge::Observed { units: 0 },
801 cancel: CancelAck::NotRequested,
802 remote: RemoteEnd::Finished,
803 }
804}
805
806async fn batch(inner: Arc<Inner>, call: BatchAttemptCall) -> BatchAttemptReport {
812 let dispatch_start = tokio::time::Instant::now();
813 let Some(max) = inner.batch_max else {
814 return batch_refused(
815 AdapterFailure::Permanent,
816 RemoteCode::Capability,
817 "runtime-planned batches are not enabled",
818 );
819 };
820 if call.members.is_empty() || call.members.len() > max {
821 return batch_refused(
822 AdapterFailure::Permanent,
823 RemoteCode::Capability,
824 "the batch exceeds the negotiated bound",
825 );
826 }
827 if call.members.len() > 1
830 && inner
831 .descriptor
832 .operations
833 .iter()
834 .any(|o| o.screen.is_some() && o.isolation == Some(Isolation::PerItem))
835 {
836 return batch_refused(
837 AdapterFailure::Permanent,
838 RemoteCode::Capability,
839 "per-item capabilities cannot be dispatched in a batch",
840 );
841 }
842 let handle = &call.members[0].cx.principal_handle;
844 if call
845 .members
846 .iter()
847 .any(|m| &m.cx.principal_handle != handle || m.cx.trace_id != call.members[0].cx.trace_id)
848 {
849 return batch_refused(
850 AdapterFailure::Permanent,
851 RemoteCode::MalformedRequest,
852 "batch members differ in principal or decision",
853 );
854 }
855 let Ok(principal) = inner.config.principal.for_attempt(handle) else {
856 for m in &call.members {
857 let r = unsent_failure(RemoteCode::MalformedRequest, "no principal reference");
858 note_unexchanged(&inner, &m.attempt_id, None, &r);
859 }
860 return batch_refused(
861 AdapterFailure::Permanent,
862 RemoteCode::MalformedRequest,
863 "no principal reference within its bounds; not sent",
864 );
865 };
866 let trace_id = call.members[0].cx.trace_id.clone();
867 let mut answers: Vec<Option<AttemptReport>> = Vec::with_capacity(call.members.len());
868 let mut waiting = vec![];
869 let mut members = vec![];
870 for (i, m) in call.members.iter().enumerate() {
871 let budget = Budget::from_start(dispatch_start, m.cx.remaining_ms);
873 if call.cancel.is_raised() || budget.expired() {
874 answers.push(Some(cancelled_report(
875 cancel_outcome(false, inner.remote.terms.cancellation, None),
876 "removed before sending",
877 )));
878 continue;
879 }
880 match prepare(
881 &inner,
882 &m.attempt_id,
883 m.projection.clone(),
884 &principal,
885 budget,
886 &m.cancel,
887 ) {
888 Ok((member, rx, _)) => {
889 answers.push(None);
890 waiting.push((i, rx, budget));
891 members.push(member);
892 }
893 Err(report) => answers.push(Some(report)),
894 }
895 }
896 if !members.is_empty() {
897 let ex = open_batch(&inner, trace_id, principal.clone(), call.batch_id, members);
898 for (i, rx, budget) in waiting {
899 let m = &call.members[i];
900 answers[i] =
901 Some(wait_member(&inner, &ex, &m.attempt_id, rx, budget, m.cancel.clone()).await);
902 }
903 }
904 let reports: Vec<AttemptReport> = answers
905 .into_iter()
906 .map(|r| r.expect("every member answered"))
907 .collect();
908 for (m, r) in call.members.iter().zip(&reports) {
909 note_unexchanged(&inner, &m.attempt_id, principal.as_ref(), r);
910 }
911 BatchAttemptReport {
912 charge: crate::cost::sum_charges(&reports.iter().map(|r| r.charge).collect::<Vec<_>>()),
913 cancel: if reports.iter().any(|r| r.cancel == CancelAck::Unconfirmed) {
914 CancelAck::Unconfirmed
915 } else if reports.iter().any(|r| r.cancel == CancelAck::Stopped) {
916 CancelAck::Stopped
917 } else {
918 CancelAck::NotRequested
919 },
920 remote: if reports
923 .iter()
924 .any(|r| r.remote == RemoteEnd::PossiblyContinuing)
925 {
926 RemoteEnd::PossiblyContinuing
927 } else {
928 RemoteEnd::Finished
929 },
930 result: Ok(call
931 .members
932 .iter()
933 .zip(reports)
934 .map(|(m, r)| BatchMemberReport {
935 attempt_id: m.attempt_id.clone(),
936 result: r.result,
937 })
938 .collect()),
939 }
940}
941
942fn open_batch(
945 inner: &Arc<Inner>,
946 trace_id: String,
947 principal: Option<WirePrincipal>,
948 batch_id: String,
949 members: Vec<Member>,
950) -> Arc<Exchange> {
951 let (flush_tx, flush_rx) = oneshot::channel();
952 let first_budget = members
953 .iter()
954 .map(|m| m.budget)
955 .reduce(Budget::min)
956 .expect("non-empty");
957 let ex = Arc::new(Exchange {
958 state: Mutex::new(ExState {
959 phase: Phase::Collecting,
960 members,
961 flush: Some(flush_tx),
962 finished: None,
963 }),
964 sent: SentTracker::new(),
965 trace_id,
966 key: None,
967 principal,
968 batch_id: Some(batch_id),
969 });
970 tokio::spawn(run_exchange(
971 inner.clone(),
972 ex.clone(),
973 flush_rx,
974 Duration::ZERO,
975 first_budget,
976 ));
977 ex
978}
979
980fn note_unexchanged(
983 inner: &Inner,
984 attempt_id: &str,
985 principal: Option<&WirePrincipal>,
986 report: &AttemptReport,
987) {
988 if !inner.config.run_evidence {
989 return;
990 }
991 let mut store = lock(&inner.evidence);
992 if store.map.contains_key(attempt_id) {
993 return;
994 }
995 let mut e = evidence(
996 inner,
997 attempt_id,
998 principal,
999 EvidenceUnavailable::NoExchange,
1000 );
1001 e.code = match &report.result {
1002 Ok(_) => None,
1003 Err((_, detail)) => crate::taxonomy::code_of_detail(detail),
1004 };
1005 e.charge_provenance = ChargeProvenance::NothingSent;
1006 store.put(e);
1007}
1008
1009fn stopped_unsent(inner: &Inner, attempt_id: &str, support: CancelSupport) -> AttemptReport {
1014 let report = cancelled_report(
1015 cancel_outcome(false, support, None),
1016 "stopped before sending",
1017 );
1018 if inner.config.run_evidence
1019 && let Some(e) = lock(&inner.evidence).map.get_mut(attempt_id)
1020 && e.exchange
1021 == (ExchangeReference::Unavailable {
1022 reason: EvidenceUnavailable::Pending,
1023 })
1024 {
1025 e.exchange = ExchangeReference::Unavailable {
1026 reason: EvidenceUnavailable::NoExchange,
1027 };
1028 e.code = Some(RemoteCode::Cancelled);
1029 e.charge_provenance = ChargeProvenance::NothingSent;
1030 }
1031 report
1032}
1033
1034fn unsent_failure(code: RemoteCode, detail: &str) -> AttemptReport {
1035 RemoteFailure::new(
1036 code,
1037 detail,
1038 Some(Charge::Observed { units: 0 }),
1039 true,
1040 false,
1041 )
1042 .report()
1043}
1044
1045async fn attempt(
1046 inner: Arc<Inner>,
1047 attempt_id: String,
1048 projection: Vec<u8>,
1049 trace_id: String,
1050 principal: Option<WirePrincipal>,
1051 budget: Budget,
1052 signal: CancelSignal,
1053) -> AttemptReport {
1054 let (member, rx, state) =
1055 match prepare(&inner, &attempt_id, projection, &principal, budget, &signal) {
1056 Ok(p) => p,
1057 Err(report) => return report,
1058 };
1059 let key = (inner.max_items > 1)
1062 .then_some(state)
1063 .flatten()
1064 .map(|digest| (trace_id.clone(), digest));
1065 let ex = join(&inner, key, trace_id, principal, member);
1066 wait_member(&inner, &ex, &attempt_id, rx, budget, signal).await
1067}
1068
1069fn prepare(
1075 inner: &Arc<Inner>,
1076 attempt_id: &str,
1077 projection: Vec<u8>,
1078 principal: &Option<WirePrincipal>,
1079 budget: Budget,
1080 signal: &CancelSignal,
1081) -> Result<(Member, oneshot::Receiver<AttemptReport>, Option<String>), AttemptReport> {
1082 if signal.is_raised() {
1083 return Err(cancelled_report(
1084 cancel_outcome(false, inner.remote.terms.cancellation, None),
1085 "cancelled before sending",
1086 ));
1087 }
1088 if !lock(&inner.recent).insert(attempt_id) {
1090 return Err(unsent_failure(
1091 RemoteCode::MalformedRequest,
1092 "attempt id already submitted; not resubmitted",
1093 ));
1094 }
1095 if projection.len() as u64 > inner.descriptor.input_limit.max_bytes {
1096 return Err(unsent_failure(
1097 RemoteCode::Capability,
1098 "input exceeds the declared limit",
1099 ));
1100 }
1101 let (Ok(text), Ok(head)) = (
1102 String::from_utf8(projection.clone()),
1103 rustev_contract::bounded::parse_bounded::<ProjectionHead>(&projection, &REMOTE_V1),
1104 ) else {
1105 return Err(unsent_failure(
1106 RemoteCode::MalformedRequest,
1107 "the projection is not JSON",
1108 ));
1109 };
1110 let Some(capability) = inner
1111 .descriptor
1112 .operations
1113 .iter()
1114 .find(|o| o.operation == head.operation)
1115 else {
1116 return Err(unsent_failure(
1117 RemoteCode::Capability,
1118 "operation not declared",
1119 ));
1120 };
1121 let state = canonical_value_bytes(&serde_json::json!({
1122 "instance": head.instance,
1123 "values": head.values,
1124 "principal": principal.as_ref().map(|p| p.evidence().digest),
1125 }))
1126 .ok()
1127 .map(|b| digest_bytes(&b))
1128 .filter(|_| capability.isolation != Some(Isolation::PerItem));
1129 let (tx, rx) = oneshot::channel();
1130 let member = Member {
1131 attempt_id: attempt_id.to_string(),
1132 projection: text,
1133 kind: capability.output.clone(),
1134 budget,
1135 status: Status::Waiting,
1136 tx: Some(tx),
1137 settled: false,
1138 confirmed: None,
1139 awaiting: false,
1140 late_share: None,
1141 };
1142 if inner.config.run_evidence {
1143 let e = evidence(
1144 inner,
1145 attempt_id,
1146 principal.as_ref(),
1147 EvidenceUnavailable::Pending,
1148 );
1149 lock(&inner.evidence).put(e);
1150 }
1151 Ok((member, rx, state))
1152}
1153
1154async fn wait_member(
1158 inner: &Arc<Inner>,
1159 ex: &Arc<Exchange>,
1160 attempt_id: &str,
1161 mut rx: oneshot::Receiver<AttemptReport>,
1162 budget: Budget,
1163 signal: CancelSignal,
1164) -> AttemptReport {
1165 tokio::select! {
1166 biased;
1167 _ = signal.raised() => {}
1168 r = &mut rx => return r.unwrap_or_else(|_| lost(ex.sent.may_have_sent())),
1169 _ = budget.expiry() => {}
1170 }
1171 cancel_member(inner, ex, attempt_id, rx, budget).await
1172}
1173
1174fn bytes_may_leave(sent: &SentTracker, others_waiting: bool) -> bool {
1179 if sent.is_aborted() {
1180 false
1181 } else if others_waiting {
1182 true
1183 } else {
1184 !sent.try_abort()
1185 }
1186}
1187
1188fn lost(sent: bool) -> AttemptReport {
1192 let reported = (!sent).then_some(Charge::Observed { units: 0 });
1193 RemoteFailure::new(
1194 RemoteCode::RemoteError,
1195 "the exchange ended without an answer",
1196 reported,
1197 true,
1198 sent,
1199 )
1200 .report()
1201}
1202
1203fn join(
1205 inner: &Arc<Inner>,
1206 key: Option<(String, String)>,
1207 trace_id: String,
1208 principal: Option<WirePrincipal>,
1209 member: Member,
1210) -> Arc<Exchange> {
1211 let mut open = lock(&inner.open);
1212 if let Some(k) = &key
1213 && let Some(ex) = open.get(k).cloned()
1214 {
1215 let mut st = lock(&ex.state);
1216 if st.phase == Phase::Collecting && st.members.len() < inner.max_items {
1217 st.members.push(member);
1218 if st.members.len() >= inner.max_items
1219 && let Some(f) = st.flush.take()
1220 {
1221 let _ = f.send(());
1222 }
1223 drop(st);
1224 return ex;
1225 }
1226 }
1227 let (flush_tx, flush_rx) = oneshot::channel();
1228 let window = match (key.is_some(), inner.config.batching) {
1229 (true, Some(b)) => Duration::from_millis(b.window_ms),
1230 _ => Duration::ZERO,
1231 };
1232 let first_budget = member.budget;
1233 let ex = Arc::new(Exchange {
1234 state: Mutex::new(ExState {
1235 phase: Phase::Collecting,
1236 members: vec![member],
1237 flush: Some(flush_tx),
1238 finished: None,
1239 }),
1240 sent: SentTracker::new(),
1241 trace_id,
1242 key: key.clone(),
1243 principal,
1244 batch_id: None,
1245 });
1246 if let Some(k) = key {
1247 open.insert(k, ex.clone());
1248 }
1249 drop(open);
1250 tokio::spawn(run_exchange(
1251 inner.clone(),
1252 ex.clone(),
1253 flush_rx,
1254 window,
1255 first_budget,
1256 ));
1257 ex
1258}
1259
1260async fn cancel_member(
1261 inner: &Arc<Inner>,
1262 ex: &Arc<Exchange>,
1263 attempt_id: &str,
1264 mut rx: oneshot::Receiver<AttemptReport>,
1265 budget: Budget,
1266) -> AttemptReport {
1267 let support = inner.remote.terms.cancellation;
1268 let sent;
1269 let awaiting;
1270 {
1271 let mut st = lock(&ex.state);
1272 let phase = st.phase;
1273 let others_waiting = st
1274 .members
1275 .iter()
1276 .any(|m| m.attempt_id != attempt_id && m.status == Status::Waiting);
1277 let Some(m) = st.members.iter_mut().find(|m| m.attempt_id == attempt_id) else {
1278 return lost(ex.sent.may_have_sent());
1279 };
1280 if m.status == Status::Delivered {
1281 drop(st);
1283 return rx
1284 .try_recv()
1285 .unwrap_or_else(|_| lost(ex.sent.may_have_sent()));
1286 }
1287 match phase {
1288 Phase::Collecting => {
1289 m.status = Status::Withdrawn;
1290 m.settled = true;
1291 m.tx = None;
1292 drop(st);
1293 return stopped_unsent(inner, attempt_id, support);
1296 }
1297 Phase::Sending => {
1298 m.status = Status::Abandoned;
1299 m.tx = None;
1300 sent = bytes_may_leave(&ex.sent, others_waiting);
1302 if !sent {
1303 m.settled = true;
1304 }
1305 awaiting = sent
1309 && !others_waiting
1310 && inner.config.await_cancel_confirmation
1311 && support == CancelSupport::Confirmed;
1312 m.awaiting = awaiting;
1313 }
1314 }
1315 }
1316 if !sent {
1317 return stopped_unsent(inner, attempt_id, support);
1318 }
1319 if support == CancelSupport::None {
1320 return cancelled_report(cancel_outcome(true, support, None), "stopped waiting");
1321 }
1322 let ids = vec![attempt_id.to_string()];
1323 if awaiting {
1324 let confirmed = send_cancel(inner, ids, inner.config.phases.cancel_budget(budget))
1325 .await
1326 .and_then(|answers| stopped_charge(&answers, attempt_id))
1327 .map(|c| convert_charge(c, inner.config.unit_rate));
1328 let outcome = cancel_outcome(true, support, confirmed);
1329 let late = {
1330 let mut st = lock(&ex.state);
1331 let Some(m) = st.members.iter_mut().find(|m| m.attempt_id == attempt_id) else {
1332 return lost(ex.sent.may_have_sent());
1333 };
1334 m.awaiting = false;
1335 let parked = m.late_share.take();
1336 if m.settled {
1337 None
1338 } else if outcome.ack == CancelAck::Stopped {
1339 m.settled = true;
1341 None
1342 } else {
1343 m.settled = parked.is_some();
1344 parked
1345 }
1346 };
1347 if let Some(units) = late {
1348 inner.exchanges.offer(ReconciliationOffer {
1349 attempt_id: attempt_id.to_string(),
1350 observed_units: units,
1351 binding: inner.binding.clone(),
1352 });
1353 }
1354 return cancelled_report(outcome, "cancel requested");
1355 }
1356 let (inner2, ex2, id) = (inner.clone(), ex.clone(), attempt_id.to_string());
1359 let cancel_budget = inner
1360 .config
1361 .phases
1362 .cancel_budget(Budget::from_ms(inner.config.cancel_budget_ms));
1363 tokio::spawn(async move {
1364 let confirmed = send_cancel(&inner2, ids, cancel_budget)
1365 .await
1366 .and_then(|answers| stopped_charge(&answers, &id))
1367 .map(|c| convert_charge(c, inner2.config.unit_rate));
1368 let Some(c @ Charge::Observed { units }) = confirmed else {
1369 return;
1370 };
1371 let offer = {
1372 let mut st = lock(&ex2.state);
1373 let no_response = st.finished == Some(false);
1374 match st.members.iter_mut().find(|m| m.attempt_id == id) {
1375 Some(m) => {
1376 m.confirmed = Some(c);
1377 let now = no_response && !m.settled;
1378 m.settled |= now;
1379 now
1380 }
1381 None => false,
1382 }
1383 };
1384 if offer {
1385 inner2.exchanges.offer(ReconciliationOffer {
1386 attempt_id: id,
1387 observed_units: units,
1388 binding: inner2.binding.clone(),
1389 });
1390 }
1391 });
1392 cancelled_report(cancel_outcome(true, support, None), "cancel requested")
1393}
1394
1395fn stopped_charge(answers: &CancelResponse, attempt_id: &str) -> Option<Charge> {
1396 let mut found = answers
1397 .answers
1398 .iter()
1399 .filter(|a| a.attempt_id == attempt_id);
1400 let one = found.next()?;
1401 if found.next().is_some() {
1402 return None;
1403 }
1404 match one.answer {
1405 RemoteCancelAnswer::Stopped { charge } => Some(charge),
1406 _ => None,
1407 }
1408}
1409
1410async fn send_cancel(
1411 inner: &Arc<Inner>,
1412 attempt_ids: Vec<String>,
1413 budget: Budget,
1414) -> Option<CancelResponse> {
1415 let body = CancelRequest {
1416 schema: schema::REMOTE_CANCEL.into(),
1417 version: inner.version,
1418 backend_id: inner.remote.descriptor.backend_id.clone(),
1419 attempt_ids,
1420 }
1421 .canonical()
1422 .ok()?;
1423 let max = (inner.remote.terms.max_response_bytes as usize).min(REMOTE_V1.max_bytes);
1424 let reply = inner
1425 .transport
1426 .post(CANCEL_PATH, body, max, &budget, &SentTracker::new())
1427 .await
1428 .ok()?;
1429 if classify_status(reply.status).is_some() {
1430 return None;
1431 }
1432 CancelResponse::parse(&reply.body).ok()
1433}
1434
1435struct Determined {
1437 outcome: Result<RawOutput, (RemoteCode, String)>,
1438 share: Option<Charge>,
1441 in_progress: bool,
1443}
1444
1445async fn run_exchange(
1446 inner: Arc<Inner>,
1447 ex: Arc<Exchange>,
1448 flush: oneshot::Receiver<()>,
1449 window: Duration,
1450 first_budget: Budget,
1451) {
1452 if !window.is_zero() {
1453 let until = first_budget.phase_deadline(Some(window));
1454 tokio::select! {
1455 _ = tokio::time::sleep_until(until) => {}
1456 _ = flush => {}
1457 }
1458 }
1459 let (items, kinds, budget) = {
1461 let mut open = lock(&inner.open);
1462 if let Some(k) = &ex.key
1463 && open.get(k).is_some_and(|e| Arc::ptr_eq(e, &ex))
1464 {
1465 open.remove(k);
1466 }
1467 let mut st = lock(&ex.state);
1468 st.phase = Phase::Sending;
1469 st.members.retain(|m| m.status != Status::Withdrawn);
1470 if ex.batch_id.is_none() {
1473 st.members.sort_by(|a, b| a.attempt_id.cmp(&b.attempt_id));
1474 }
1475 if st.members.is_empty() {
1476 return;
1477 }
1478 let budget = st
1479 .members
1480 .iter()
1481 .map(|m| m.budget)
1482 .reduce(Budget::min)
1483 .expect("non-empty");
1484 let items: Vec<InferItem> = st
1485 .members
1486 .iter()
1487 .map(|m| InferItem {
1488 attempt_id: m.attempt_id.clone(),
1489 projection: m.projection.clone(),
1490 })
1491 .collect();
1492 let kinds: Vec<OutputKind> = st.members.iter().map(|m| m.kind.clone()).collect();
1493 (items, kinds, budget)
1494 };
1495 let terms = &inner.remote.terms;
1496 let told = budget.told_ms(inner.config.transit_margin_ms);
1497 let request = InferRequest {
1498 schema: schema::REMOTE_INFER.into(),
1499 version: inner.version,
1500 backend_id: inner.remote.descriptor.backend_id.clone(),
1501 descriptor: inner.remote.descriptor_id.clone(),
1502 artifact: inner.remote.descriptor.artifact.clone(),
1503 budget_ms: told,
1504 trace_id: ex.trace_id.clone(),
1505 privacy: inner.config.privacy.clone(),
1506 items,
1507 principal: ex.principal.clone(),
1508 };
1509 let body = request.canonical().unwrap_or_default();
1510 let mut record = blank_record(inner.protocol, inner.binding.clone(), vec![]);
1511 record.principal = request.principal.as_ref().map(WirePrincipal::evidence);
1512 record.privacy_requested = inner.config.privacy.clone();
1513 record.request_digest = Some(digest_bytes(&body));
1514 if let (Some(b), true) = (inner.config.batching, request.items.len() > 1) {
1515 record.attribution = Attribution::SharedState {
1516 coalescing_window_ms: b.window_ms,
1517 };
1518 }
1519 if ex.batch_id.is_some() {
1520 record.attribution = Attribution::RuntimePlanned;
1521 }
1522 let n = request.items.len();
1523 let all = |code: RemoteCode, detail: &str, share: Option<Charge>| -> Vec<Determined> {
1524 (0..n)
1525 .map(|_| Determined {
1526 outcome: Err((code, detail.to_string())),
1527 share,
1528 in_progress: false,
1529 })
1530 .collect()
1531 };
1532 let mut reply_body: Option<Vec<u8>> = None;
1533 let mut live_only = false;
1537 let determined: Vec<Determined> = if told == 0 || body.is_empty() {
1538 ex.sent.try_abort();
1540 live_only = true;
1541 all(
1542 RemoteCode::TransportUnsent,
1543 "no budget left to give the remote side",
1544 None,
1545 )
1546 } else if body.len() as u64 > terms.max_request_bytes {
1547 ex.sent.try_abort();
1548 all(
1549 RemoteCode::Capability,
1550 "request exceeds the negotiated size",
1551 None,
1552 )
1553 } else {
1554 let max = (terms.max_response_bytes as usize).min(REMOTE_V1.max_bytes);
1555 let (r, connection) = inner
1556 .transport
1557 .post_observed(
1558 INFER_PATH,
1559 body.clone(),
1560 max,
1561 &budget,
1562 &ex.sent,
1563 ex.principal.as_ref().map(|p| p.evidence().digest),
1564 )
1565 .await;
1566 record.connection = connection;
1567 match r {
1568 Err(e) => {
1569 live_only = budget_ran_out(&e, &budget);
1570 if let TransportError::PhaseExpired(p) = &e {
1571 record.phase_expiry = Some(*p);
1572 }
1573 let (code, detail) = transport_code(&e);
1574 all(code, &detail, None)
1575 }
1576 Ok(HttpReply {
1577 status,
1578 retry_after,
1579 body,
1580 }) => {
1581 record.status = Some(status);
1582 record.retry_after = retry_after.clone();
1583 record.response_digest = Some(digest_bytes(&body));
1584 reply_body = Some(body.clone());
1585 match classify_status(status) {
1586 Some(code) => {
1587 let mut d = format!("HTTP {status}");
1588 if let Some(r) = &retry_after {
1589 d.push_str(&format!("; retry-after {r} (recorded, not waited)"));
1590 }
1591 all(code, &d, None)
1592 }
1593 None => read_response(&inner, &request, &kinds, &body, &mut record),
1594 }
1595 }
1596 }
1597 };
1598 record.bytes_sent = ex.sent.may_have_sent();
1599 let retained_request = match &request.principal {
1602 None => body,
1603 Some(p) => {
1604 let mut r = request.clone();
1605 r.principal = Some(WirePrincipal {
1606 reference: p.evidence().digest,
1607 ..p.clone()
1608 });
1609 r.canonical().unwrap_or_default()
1610 }
1611 };
1612 finish(
1613 &inner,
1614 &ex,
1615 determined,
1616 live_only,
1617 record,
1618 retained_request,
1619 reply_body,
1620 );
1621}
1622
1623fn read_response(
1625 inner: &Inner,
1626 request: &InferRequest,
1627 kinds: &[OutputKind],
1628 body: &[u8],
1629 record: &mut rustev_contract::remote::ExchangeRecord,
1630) -> Vec<Determined> {
1631 let n = request.items.len();
1632 let all = |code: RemoteCode, detail: String, share: Option<Charge>| -> Vec<Determined> {
1633 (0..n)
1634 .map(|_| Determined {
1635 outcome: Err((code, detail.clone())),
1636 share,
1637 in_progress: false,
1638 })
1639 .collect()
1640 };
1641 let resp = match InferResponse::parse(body) {
1642 Ok(r) if r.version == inner.version => r,
1643 Ok(r) => {
1646 return all(
1647 RemoteCode::IdentityMismatch,
1648 format!(
1649 "protocol version {} where {} was negotiated",
1650 r.version, inner.version
1651 ),
1652 None,
1653 );
1654 }
1655 Err(e) => return all(RemoteCode::MalformedResponse, e.to_string(), None),
1656 };
1657 record.route = resp.route.clone();
1658 record.generation_id = resp.generation_id.clone();
1659 record.provider_extras = resp.extras.clone();
1660 let terms = &inner.remote.terms;
1661 let rate = inner.config.unit_rate;
1662 let total = convert_charge(resp.charge, rate);
1663 record.reported_cost = match resp.charge {
1664 Charge::Observed { units } | Charge::Estimated { units } => {
1665 Some(rustev_contract::remote::ReportedCost {
1666 amount: units.to_string(),
1667 currency: "remote-units".into(),
1668 })
1669 }
1670 Charge::Unknown => None,
1671 };
1672 record.exchange_charge = total;
1673 let reported = (resp.charge != Charge::Unknown).then_some(total);
1674 let served = match served_identity(&terms.served, resp.served.identity.as_deref()) {
1675 Ok(s) => s,
1676 Err(m) => {
1677 record.served = ServedIdentity {
1678 identity: Some(m.served.clone()),
1679 disclosure: Disclosure::Reported,
1680 };
1681 return all(
1682 RemoteCode::IdentityMismatch,
1683 format!("served `{}` where `{}` is pinned", m.served, m.pinned),
1684 reported,
1685 );
1686 }
1687 };
1688 if served.disclosure == Disclosure::Unknown
1690 && let Some(name) = &resp.served.identity
1691 {
1692 record
1693 .provider_extras
1694 .insert("served_identity_claim".into(), name.clone());
1695 }
1696 record.served = served;
1697 if resp.artifact != inner.remote.descriptor.artifact {
1698 return all(
1699 RemoteCode::IdentityMismatch,
1700 "the remote artifact differs from the negotiated one".into(),
1701 reported,
1702 );
1703 }
1704 let mut counts: HashMap<&str, usize> = HashMap::new();
1707 for a in &resp.items {
1708 *counts.entry(a.attempt_id.as_str()).or_default() += 1;
1709 }
1710 let asked: HashSet<&str> = request
1711 .items
1712 .iter()
1713 .map(|i| i.attempt_id.as_str())
1714 .collect();
1715 if counts.keys().any(|k| !asked.contains(k)) {
1716 return all(
1717 RemoteCode::MalformedResponse,
1718 "an answer names an attempt that was not asked".into(),
1719 reported,
1720 );
1721 }
1722 request
1723 .items
1724 .iter()
1725 .zip(kinds)
1726 .map(|(item, kind)| {
1727 let mut d = Determined {
1728 outcome: Err((RemoteCode::MalformedResponse, String::new())),
1729 share: reported,
1730 in_progress: false,
1731 };
1732 match counts.get(item.attempt_id.as_str()).copied().unwrap_or(0) {
1733 0 => d.outcome = Err((RemoteCode::MalformedResponse, "no answer".into())),
1734 1 => {}
1735 _ => {
1736 d.outcome = Err((RemoteCode::MalformedResponse, "answered twice".into()));
1737 return d;
1738 }
1739 }
1740 let Some(a) = resp.items.iter().find(|a| a.attempt_id == item.attempt_id) else {
1741 return d;
1742 };
1743 let declared = Some(kind.clone());
1744 d.outcome = match &a.result {
1745 ItemResult::Output(o) if output_kind_of(o) == declared => Ok(o.clone()),
1746 ItemResult::Output(o) => Err((
1747 RemoteCode::MalformedResponse,
1748 format!(
1749 "a {} answer where {:?} is declared",
1750 output_kind_of(o)
1751 .map(|kind| format!("{kind:?}"))
1752 .unwrap_or_else(|| "TextRecord".into()),
1753 declared
1754 ),
1755 )),
1756 ItemResult::Failure { code, .. } if is_local_only(*code) => Err((
1759 RemoteCode::MalformedResponse,
1760 format!("the adapter-only code `{}` in an answer", code.as_str()),
1761 )),
1762 ItemResult::Failure { code, detail } => Err((*code, detail.clone())),
1763 ItemResult::InProgress => {
1764 d.in_progress = true;
1765 Err((RemoteCode::RemoteError, "in_progress".into()))
1766 }
1767 };
1768 d
1769 })
1770 .collect()
1771}
1772
1773fn finish(
1776 inner: &Inner,
1777 ex: &Exchange,
1778 det: Vec<Determined>,
1779 live_only: bool,
1780 mut record: rustev_contract::remote::ExchangeRecord,
1781 request: Vec<u8>,
1782 response: Option<Vec<u8>>,
1783) {
1784 let terms = &inner.remote.terms;
1785 let sent = record.bytes_sent;
1786 let mut offers = vec![];
1787 let mut reports = vec![];
1790 {
1791 let mut st = lock(&ex.state);
1792 st.finished = Some(response.is_some());
1793 if response.is_none() {
1794 for m in &mut st.members {
1797 if let (Status::Abandoned, false, Some(Charge::Observed { units })) =
1798 (m.status, m.settled, m.confirmed)
1799 {
1800 m.settled = true;
1801 offers.push(ReconciliationOffer {
1802 attempt_id: m.attempt_id.clone(),
1803 observed_units: units,
1804 binding: inner.binding.clone(),
1805 });
1806 }
1807 }
1808 }
1809 let receives =
1812 |m: &Member| m.status == Status::Waiting && !(live_only && m.budget.expired());
1813 let received: Vec<bool> = st.members.iter().map(receives).collect();
1814 let any_received = received.iter().any(|r| *r);
1815 let mut members = vec![];
1816 {
1817 let reported = det.first().and_then(|d| d.share);
1818 let shares = match reported {
1821 Some(total) if any_received => attribute(total, &received),
1822 Some(total) => attribute(total, &vec![true; received.len()]),
1823 None => vec![Charge::Unknown; received.len()],
1824 };
1825 record.late = !any_received && response.is_some();
1826 for ((m, d), share) in st.members.iter_mut().zip(det).zip(shares) {
1827 let waiting = receives(m);
1828 let report = match &d.outcome {
1829 Ok(o) => AttemptReport {
1830 result: Ok(o.clone()),
1831 charge: share,
1832 cancel: CancelAck::NotRequested,
1833 remote: RemoteEnd::Finished,
1834 },
1835 Err((RemoteCode::Cancelled, detail)) => RemoteFailure::new(
1837 RemoteCode::RemoteError,
1838 format!("cancelled at the remote side without a request: {detail}"),
1839 d.share.map(|_| share),
1840 terms.refusals_charged,
1841 sent,
1842 )
1843 .report(),
1844 Err((code, detail)) => {
1845 let reported = d.share.map(|_| share);
1846 let mut f = RemoteFailure::new(
1847 *code,
1848 detail.clone(),
1849 reported,
1850 terms.refusals_charged,
1851 sent,
1852 );
1853 if d.in_progress {
1854 f.charge = Charge::Unknown;
1855 f.remote = RemoteEnd::PossiblyContinuing;
1856 }
1857 f.report()
1858 }
1859 };
1860 members.push(ExchangeMember {
1861 attempt_id: m.attempt_id.clone(),
1862 outcome: match &d.outcome {
1863 Ok(o) => MemberOutcome::Output {
1864 digest: output_digest(o).unwrap_or_default(),
1865 },
1866 Err((code, detail)) => MemberOutcome::Failure {
1867 code: *code,
1868 detail: detail.clone(),
1869 },
1870 },
1871 charge: if waiting || record.late {
1872 report.charge
1873 } else {
1874 Charge::Unknown
1875 },
1876 });
1877 if waiting {
1878 m.status = Status::Delivered;
1879 m.settled = true;
1880 if let Some(tx) = m.tx.take() {
1881 reports.push((tx, report));
1882 }
1883 } else if m.status == Status::Abandoned && !m.settled && response.is_some() {
1884 let offered = if any_received {
1887 matches!(reported, Some(Charge::Observed { .. })).then_some(0)
1889 } else {
1890 match share {
1891 Charge::Observed { units } => Some(units),
1892 _ => None,
1893 }
1894 };
1895 if m.awaiting {
1896 m.late_share = offered;
1897 } else if let Some(units) = offered {
1898 m.settled = true;
1899 offers.push(ReconciliationOffer {
1900 attempt_id: m.attempt_id.clone(),
1901 observed_units: units,
1902 binding: inner.binding.clone(),
1903 });
1904 }
1905 }
1906 }
1907 }
1908 record.members = members;
1909 }
1910 for o in offers {
1911 inner.exchanges.offer(o);
1912 }
1913 let retained = RetainedBytes { request, response };
1914 let (members, served, sent_any, batch_id) = (
1915 record.members.clone(),
1916 record.served.clone(),
1917 record.bytes_sent,
1918 ex.batch_id
1919 .clone()
1920 .unwrap_or_else(|| record.request_digest.clone().unwrap_or_default()),
1921 );
1922 let note = inner.exchanges.deliver(record, Some(retained));
1923 if inner.config.run_evidence {
1924 let exchange = match (note.digest, note.located) {
1926 (Some(digest), Ok(Some(locator))) => ExchangeReference::Retained {
1927 digest,
1928 schema: rustev_contract::schema::REMOTE_EXCHANGE.into(),
1929 retention: if inner.exchanges.retains_bytes() {
1930 RetentionClass::BytesRetained
1931 } else {
1932 RetentionClass::DigestOnly
1933 },
1934 locator,
1935 },
1936 (_, Ok(None)) => ExchangeReference::Unavailable {
1937 reason: EvidenceUnavailable::NoLocator,
1938 },
1939 _ => ExchangeReference::Unavailable {
1940 reason: EvidenceUnavailable::NotDelivered,
1941 },
1942 };
1943 let n = members.len();
1944 let mut store = lock(&inner.evidence);
1945 for (index, m) in members.iter().enumerate() {
1946 if let Some(e) = store.map.get_mut(&m.attempt_id) {
1947 e.exchange = exchange.clone();
1948 e.served = served.clone();
1949 e.code = match &m.outcome {
1950 MemberOutcome::Failure { code, .. } => Some(*code),
1951 MemberOutcome::Output { .. } => None,
1952 };
1953 e.charge_provenance = match m.charge {
1954 _ if !sent_any => ChargeProvenance::NothingSent,
1955 Charge::Unknown => ChargeProvenance::Unknown,
1956 _ if n > 1 => ChargeProvenance::Attributed,
1957 _ => ChargeProvenance::Reported,
1958 };
1959 e.batch = (n > 1).then(|| BatchMembership {
1960 batch_id: batch_id.clone(),
1961 index: index as u32,
1962 });
1963 }
1964 }
1965 }
1966 for (tx, report) in reports {
1967 let _ = tx.send(report);
1968 }
1969}
1970
1971pub fn map_retained(body: &[u8], attempt_id: &str, declared: OutputKind) -> Option<RawOutput> {
1976 let resp = InferResponse::parse(body).ok()?;
1977 protocol_name(resp.version)?;
1978 let mut answers = resp.items.iter().filter(|a| a.attempt_id == attempt_id);
1979 let one = answers.next()?;
1980 if answers.next().is_some() {
1981 return None;
1982 }
1983 match &one.result {
1984 ItemResult::Output(o) if output_kind_of(o).as_ref() == Some(&declared) => Some(o.clone()),
1985 _ => None,
1986 }
1987}
1988
1989#[cfg(test)]
1990mod tests {
1991 use super::*;
1992
1993 #[test]
1994 fn an_aborted_exchange_never_counts_as_sent_even_with_siblings() {
1995 let t = SentTracker::new();
1996 assert!(t.try_abort());
1997 assert!(!bytes_may_leave(&t, true));
1998 assert!(!bytes_may_leave(&t, false));
1999 let idle = SentTracker::new();
2000 assert!(bytes_may_leave(&idle, true), "siblings will send it");
2001 let alone = SentTracker::new();
2002 assert!(!bytes_may_leave(&alone, false), "a lone member aborts");
2003 let writing = SentTracker::new();
2004 assert!(writing.begin_write());
2005 assert!(bytes_may_leave(&writing, false));
2006 }
2007
2008 #[test]
2009 fn a_lost_answer_after_sending_is_possibly_continuing() {
2010 let r = lost(true);
2011 assert_eq!(
2012 (r.charge, r.remote),
2013 (Charge::Unknown, RemoteEnd::PossiblyContinuing)
2014 );
2015 let r = lost(false);
2016 assert_eq!(
2017 (r.charge, r.remote),
2018 (Charge::Observed { units: 0 }, RemoteEnd::Finished)
2019 );
2020 }
2021}