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 { .. } | RawOutput::Ranking { .. } => {
683 None
684 }
685 RawOutput::Screen { .. } => Some(OutputKind::Screen),
686 RawOutput::Label(_) => Some(OutputKind::Label),
687 RawOutput::Distribution(_) => Some(OutputKind::Distribution),
688 RawOutput::Logits(_) => Some(OutputKind::Logits),
689 RawOutput::Scores(_) => Some(OutputKind::Scores),
690 }
691}
692
693impl DecisionBackend for RemoteClient {
694 fn descriptor(&self) -> &BackendDescriptor {
695 &self.inner.descriptor
696 }
697
698 fn cost_model(&self) -> CostModel {
701 match self.inner.remote.terms.cost {
702 CostBound::Bounded { .. } => CostModel::Bounded,
703 CostBound::Estimated { .. } => CostModel::Estimated,
704 CostBound::Unknown => CostModel::Unknown,
705 }
706 }
707
708 fn cost_bound(&self, _projection: &[u8]) -> CostBound {
709 convert_bound(self.inner.remote.terms.cost, self.inner.config.unit_rate)
710 }
711
712 fn infer<'a>(&'a self, call: AttemptCall<'a>) -> BoxFuture<'a, AttemptReport> {
713 let inner = self.inner.clone();
714 let attempt_id = call.attempt_id.to_string();
715 let signal = call.cancel.clone();
716 let budget = Budget::start(call.cx.remaining_ms);
717 let trace_id = call.cx.trace_id.clone();
718 let projection = call.projection.to_vec();
719 let principal = inner
721 .config
722 .principal
723 .for_attempt(&call.cx.principal_handle);
724 Box::pin(async move {
725 let Ok(principal) = principal else {
726 let report = unsent_failure(
728 RemoteCode::MalformedRequest,
729 "no principal reference within its bounds; not sent",
730 );
731 note_unexchanged(&inner, &attempt_id, None, &report);
732 return report;
733 };
734 let p = principal.clone();
735 let report = attempt(
736 inner.clone(),
737 attempt_id.clone(),
738 projection,
739 trace_id,
740 principal,
741 budget,
742 signal,
743 )
744 .await;
745 note_unexchanged(&inner, &attempt_id, p.as_ref(), &report);
746 report
747 })
748 }
749
750 fn take_remote_evidence(&self, attempt_id: &str) -> Option<RemoteEvidence> {
751 lock(&self.inner.evidence).take(attempt_id)
752 }
753
754 fn batch_capability(&self, projection: &[u8]) -> Option<BatchCapability> {
762 self.inner.batch_max?;
763 if self
764 .inner
765 .descriptor
766 .operations
767 .iter()
768 .any(|o| o.isolation == Some(Isolation::PerItem))
769 {
770 return None;
771 }
772 let head =
773 rustev_contract::bounded::parse_bounded::<ProjectionHead>(projection, &REMOTE_V1)
774 .ok()?;
775 self.inner
776 .descriptor
777 .operations
778 .iter()
779 .any(|o| o.operation == head.operation)
780 .then_some(())?;
781 let state = canonical_value_bytes(&serde_json::json!({
782 "binding": self.inner.binding.as_str(),
783 "instance": head.instance,
784 "values": head.values,
785 }))
786 .ok()?;
787 Some(BatchCapability {
788 compatibility: digest_bytes(&state),
789 work_units: 1,
790 })
791 }
792
793 fn infer_batch<'a>(&'a self, call: BatchAttemptCall) -> BoxFuture<'a, BatchAttemptReport> {
794 let inner = self.inner.clone();
795 Box::pin(async move { batch(inner, call).await })
796 }
797}
798
799fn batch_refused(failure: AdapterFailure, code: RemoteCode, detail: &str) -> BatchAttemptReport {
801 BatchAttemptReport {
802 result: Err((failure, crate::taxonomy::failure_detail(code, detail))),
803 charge: Charge::Observed { units: 0 },
804 cancel: CancelAck::NotRequested,
805 remote: RemoteEnd::Finished,
806 }
807}
808
809async fn batch(inner: Arc<Inner>, call: BatchAttemptCall) -> BatchAttemptReport {
815 let dispatch_start = tokio::time::Instant::now();
816 let Some(max) = inner.batch_max else {
817 return batch_refused(
818 AdapterFailure::Permanent,
819 RemoteCode::Capability,
820 "runtime-planned batches are not enabled",
821 );
822 };
823 if call.members.is_empty() || call.members.len() > max {
824 return batch_refused(
825 AdapterFailure::Permanent,
826 RemoteCode::Capability,
827 "the batch exceeds the negotiated bound",
828 );
829 }
830 if call.members.len() > 1
833 && inner
834 .descriptor
835 .operations
836 .iter()
837 .any(|o| o.isolation == Some(Isolation::PerItem))
838 {
839 return batch_refused(
840 AdapterFailure::Permanent,
841 RemoteCode::Capability,
842 "per-item capabilities cannot be dispatched in a batch",
843 );
844 }
845 let handle = &call.members[0].cx.principal_handle;
847 if call
848 .members
849 .iter()
850 .any(|m| &m.cx.principal_handle != handle || m.cx.trace_id != call.members[0].cx.trace_id)
851 {
852 return batch_refused(
853 AdapterFailure::Permanent,
854 RemoteCode::MalformedRequest,
855 "batch members differ in principal or decision",
856 );
857 }
858 let Ok(principal) = inner.config.principal.for_attempt(handle) else {
859 for m in &call.members {
860 let r = unsent_failure(RemoteCode::MalformedRequest, "no principal reference");
861 note_unexchanged(&inner, &m.attempt_id, None, &r);
862 }
863 return batch_refused(
864 AdapterFailure::Permanent,
865 RemoteCode::MalformedRequest,
866 "no principal reference within its bounds; not sent",
867 );
868 };
869 let trace_id = call.members[0].cx.trace_id.clone();
870 let mut answers: Vec<Option<AttemptReport>> = Vec::with_capacity(call.members.len());
871 let mut waiting = vec![];
872 let mut members = vec![];
873 for (i, m) in call.members.iter().enumerate() {
874 let budget = Budget::from_start(dispatch_start, m.cx.remaining_ms);
876 if call.cancel.is_raised() || budget.expired() {
877 answers.push(Some(cancelled_report(
878 cancel_outcome(false, inner.remote.terms.cancellation, None),
879 "removed before sending",
880 )));
881 continue;
882 }
883 match prepare(
884 &inner,
885 &m.attempt_id,
886 m.projection.clone(),
887 &principal,
888 budget,
889 &m.cancel,
890 ) {
891 Ok((member, rx, _)) => {
892 answers.push(None);
893 waiting.push((i, rx, budget));
894 members.push(member);
895 }
896 Err(report) => answers.push(Some(report)),
897 }
898 }
899 if !members.is_empty() {
900 let ex = open_batch(&inner, trace_id, principal.clone(), call.batch_id, members);
901 for (i, rx, budget) in waiting {
902 let m = &call.members[i];
903 answers[i] =
904 Some(wait_member(&inner, &ex, &m.attempt_id, rx, budget, m.cancel.clone()).await);
905 }
906 }
907 let reports: Vec<AttemptReport> = answers
908 .into_iter()
909 .map(|r| r.expect("every member answered"))
910 .collect();
911 for (m, r) in call.members.iter().zip(&reports) {
912 note_unexchanged(&inner, &m.attempt_id, principal.as_ref(), r);
913 }
914 BatchAttemptReport {
915 charge: crate::cost::sum_charges(&reports.iter().map(|r| r.charge).collect::<Vec<_>>()),
916 cancel: if reports.iter().any(|r| r.cancel == CancelAck::Unconfirmed) {
917 CancelAck::Unconfirmed
918 } else if reports.iter().any(|r| r.cancel == CancelAck::Stopped) {
919 CancelAck::Stopped
920 } else {
921 CancelAck::NotRequested
922 },
923 remote: if reports
926 .iter()
927 .any(|r| r.remote == RemoteEnd::PossiblyContinuing)
928 {
929 RemoteEnd::PossiblyContinuing
930 } else {
931 RemoteEnd::Finished
932 },
933 result: Ok(call
934 .members
935 .iter()
936 .zip(reports)
937 .map(|(m, r)| BatchMemberReport {
938 attempt_id: m.attempt_id.clone(),
939 result: r.result,
940 })
941 .collect()),
942 }
943}
944
945fn open_batch(
948 inner: &Arc<Inner>,
949 trace_id: String,
950 principal: Option<WirePrincipal>,
951 batch_id: String,
952 members: Vec<Member>,
953) -> Arc<Exchange> {
954 let (flush_tx, flush_rx) = oneshot::channel();
955 let first_budget = members
956 .iter()
957 .map(|m| m.budget)
958 .reduce(Budget::min)
959 .expect("non-empty");
960 let ex = Arc::new(Exchange {
961 state: Mutex::new(ExState {
962 phase: Phase::Collecting,
963 members,
964 flush: Some(flush_tx),
965 finished: None,
966 }),
967 sent: SentTracker::new(),
968 trace_id,
969 key: None,
970 principal,
971 batch_id: Some(batch_id),
972 });
973 tokio::spawn(run_exchange(
974 inner.clone(),
975 ex.clone(),
976 flush_rx,
977 Duration::ZERO,
978 first_budget,
979 ));
980 ex
981}
982
983fn note_unexchanged(
986 inner: &Inner,
987 attempt_id: &str,
988 principal: Option<&WirePrincipal>,
989 report: &AttemptReport,
990) {
991 if !inner.config.run_evidence {
992 return;
993 }
994 let mut store = lock(&inner.evidence);
995 if store.map.contains_key(attempt_id) {
996 return;
997 }
998 let mut e = evidence(
999 inner,
1000 attempt_id,
1001 principal,
1002 EvidenceUnavailable::NoExchange,
1003 );
1004 e.code = match &report.result {
1005 Ok(_) => None,
1006 Err((_, detail)) => crate::taxonomy::code_of_detail(detail),
1007 };
1008 e.charge_provenance = ChargeProvenance::NothingSent;
1009 store.put(e);
1010}
1011
1012fn stopped_unsent(inner: &Inner, attempt_id: &str, support: CancelSupport) -> AttemptReport {
1017 let report = cancelled_report(
1018 cancel_outcome(false, support, None),
1019 "stopped before sending",
1020 );
1021 if inner.config.run_evidence
1022 && let Some(e) = lock(&inner.evidence).map.get_mut(attempt_id)
1023 && e.exchange
1024 == (ExchangeReference::Unavailable {
1025 reason: EvidenceUnavailable::Pending,
1026 })
1027 {
1028 e.exchange = ExchangeReference::Unavailable {
1029 reason: EvidenceUnavailable::NoExchange,
1030 };
1031 e.code = Some(RemoteCode::Cancelled);
1032 e.charge_provenance = ChargeProvenance::NothingSent;
1033 }
1034 report
1035}
1036
1037fn unsent_failure(code: RemoteCode, detail: &str) -> AttemptReport {
1038 RemoteFailure::new(
1039 code,
1040 detail,
1041 Some(Charge::Observed { units: 0 }),
1042 true,
1043 false,
1044 )
1045 .report()
1046}
1047
1048async fn attempt(
1049 inner: Arc<Inner>,
1050 attempt_id: String,
1051 projection: Vec<u8>,
1052 trace_id: String,
1053 principal: Option<WirePrincipal>,
1054 budget: Budget,
1055 signal: CancelSignal,
1056) -> AttemptReport {
1057 let (member, rx, state) =
1058 match prepare(&inner, &attempt_id, projection, &principal, budget, &signal) {
1059 Ok(p) => p,
1060 Err(report) => return report,
1061 };
1062 let key = (inner.max_items > 1)
1065 .then_some(state)
1066 .flatten()
1067 .map(|digest| (trace_id.clone(), digest));
1068 let ex = join(&inner, key, trace_id, principal, member);
1069 wait_member(&inner, &ex, &attempt_id, rx, budget, signal).await
1070}
1071
1072fn prepare(
1078 inner: &Arc<Inner>,
1079 attempt_id: &str,
1080 projection: Vec<u8>,
1081 principal: &Option<WirePrincipal>,
1082 budget: Budget,
1083 signal: &CancelSignal,
1084) -> Result<(Member, oneshot::Receiver<AttemptReport>, Option<String>), AttemptReport> {
1085 if signal.is_raised() {
1086 return Err(cancelled_report(
1087 cancel_outcome(false, inner.remote.terms.cancellation, None),
1088 "cancelled before sending",
1089 ));
1090 }
1091 if !lock(&inner.recent).insert(attempt_id) {
1093 return Err(unsent_failure(
1094 RemoteCode::MalformedRequest,
1095 "attempt id already submitted; not resubmitted",
1096 ));
1097 }
1098 if projection.len() as u64 > inner.descriptor.input_limit.max_bytes {
1099 return Err(unsent_failure(
1100 RemoteCode::Capability,
1101 "input exceeds the declared limit",
1102 ));
1103 }
1104 let (Ok(text), Ok(head)) = (
1105 String::from_utf8(projection.clone()),
1106 rustev_contract::bounded::parse_bounded::<ProjectionHead>(&projection, &REMOTE_V1),
1107 ) else {
1108 return Err(unsent_failure(
1109 RemoteCode::MalformedRequest,
1110 "the projection is not JSON",
1111 ));
1112 };
1113 let Some(capability) = inner
1114 .descriptor
1115 .operations
1116 .iter()
1117 .find(|o| o.operation == head.operation)
1118 else {
1119 return Err(unsent_failure(
1120 RemoteCode::Capability,
1121 "operation not declared",
1122 ));
1123 };
1124 let state = canonical_value_bytes(&serde_json::json!({
1125 "instance": head.instance,
1126 "values": head.values,
1127 "principal": principal.as_ref().map(|p| p.evidence().digest),
1128 }))
1129 .ok()
1130 .map(|b| digest_bytes(&b))
1131 .filter(|_| capability.isolation != Some(Isolation::PerItem));
1132 let (tx, rx) = oneshot::channel();
1133 let member = Member {
1134 attempt_id: attempt_id.to_string(),
1135 projection: text,
1136 kind: capability.output.clone(),
1137 budget,
1138 status: Status::Waiting,
1139 tx: Some(tx),
1140 settled: false,
1141 confirmed: None,
1142 awaiting: false,
1143 late_share: None,
1144 };
1145 if inner.config.run_evidence {
1146 let e = evidence(
1147 inner,
1148 attempt_id,
1149 principal.as_ref(),
1150 EvidenceUnavailable::Pending,
1151 );
1152 lock(&inner.evidence).put(e);
1153 }
1154 Ok((member, rx, state))
1155}
1156
1157async fn wait_member(
1161 inner: &Arc<Inner>,
1162 ex: &Arc<Exchange>,
1163 attempt_id: &str,
1164 mut rx: oneshot::Receiver<AttemptReport>,
1165 budget: Budget,
1166 signal: CancelSignal,
1167) -> AttemptReport {
1168 tokio::select! {
1169 biased;
1170 _ = signal.raised() => {}
1171 r = &mut rx => return r.unwrap_or_else(|_| lost(ex.sent.may_have_sent())),
1172 _ = budget.expiry() => {}
1173 }
1174 cancel_member(inner, ex, attempt_id, rx, budget).await
1175}
1176
1177fn bytes_may_leave(sent: &SentTracker, others_waiting: bool) -> bool {
1182 if sent.is_aborted() {
1183 false
1184 } else if others_waiting {
1185 true
1186 } else {
1187 !sent.try_abort()
1188 }
1189}
1190
1191fn lost(sent: bool) -> AttemptReport {
1195 let reported = (!sent).then_some(Charge::Observed { units: 0 });
1196 RemoteFailure::new(
1197 RemoteCode::RemoteError,
1198 "the exchange ended without an answer",
1199 reported,
1200 true,
1201 sent,
1202 )
1203 .report()
1204}
1205
1206fn join(
1208 inner: &Arc<Inner>,
1209 key: Option<(String, String)>,
1210 trace_id: String,
1211 principal: Option<WirePrincipal>,
1212 member: Member,
1213) -> Arc<Exchange> {
1214 let mut open = lock(&inner.open);
1215 if let Some(k) = &key
1216 && let Some(ex) = open.get(k).cloned()
1217 {
1218 let mut st = lock(&ex.state);
1219 if st.phase == Phase::Collecting && st.members.len() < inner.max_items {
1220 st.members.push(member);
1221 if st.members.len() >= inner.max_items
1222 && let Some(f) = st.flush.take()
1223 {
1224 let _ = f.send(());
1225 }
1226 drop(st);
1227 return ex;
1228 }
1229 }
1230 let (flush_tx, flush_rx) = oneshot::channel();
1231 let window = match (key.is_some(), inner.config.batching) {
1232 (true, Some(b)) => Duration::from_millis(b.window_ms),
1233 _ => Duration::ZERO,
1234 };
1235 let first_budget = member.budget;
1236 let ex = Arc::new(Exchange {
1237 state: Mutex::new(ExState {
1238 phase: Phase::Collecting,
1239 members: vec![member],
1240 flush: Some(flush_tx),
1241 finished: None,
1242 }),
1243 sent: SentTracker::new(),
1244 trace_id,
1245 key: key.clone(),
1246 principal,
1247 batch_id: None,
1248 });
1249 if let Some(k) = key {
1250 open.insert(k, ex.clone());
1251 }
1252 drop(open);
1253 tokio::spawn(run_exchange(
1254 inner.clone(),
1255 ex.clone(),
1256 flush_rx,
1257 window,
1258 first_budget,
1259 ));
1260 ex
1261}
1262
1263async fn cancel_member(
1264 inner: &Arc<Inner>,
1265 ex: &Arc<Exchange>,
1266 attempt_id: &str,
1267 mut rx: oneshot::Receiver<AttemptReport>,
1268 budget: Budget,
1269) -> AttemptReport {
1270 let support = inner.remote.terms.cancellation;
1271 let sent;
1272 let awaiting;
1273 {
1274 let mut st = lock(&ex.state);
1275 let phase = st.phase;
1276 let others_waiting = st
1277 .members
1278 .iter()
1279 .any(|m| m.attempt_id != attempt_id && m.status == Status::Waiting);
1280 let Some(m) = st.members.iter_mut().find(|m| m.attempt_id == attempt_id) else {
1281 return lost(ex.sent.may_have_sent());
1282 };
1283 if m.status == Status::Delivered {
1284 drop(st);
1286 return rx
1287 .try_recv()
1288 .unwrap_or_else(|_| lost(ex.sent.may_have_sent()));
1289 }
1290 match phase {
1291 Phase::Collecting => {
1292 m.status = Status::Withdrawn;
1293 m.settled = true;
1294 m.tx = None;
1295 drop(st);
1296 return stopped_unsent(inner, attempt_id, support);
1299 }
1300 Phase::Sending => {
1301 m.status = Status::Abandoned;
1302 m.tx = None;
1303 sent = bytes_may_leave(&ex.sent, others_waiting);
1305 if !sent {
1306 m.settled = true;
1307 }
1308 awaiting = sent
1312 && !others_waiting
1313 && inner.config.await_cancel_confirmation
1314 && support == CancelSupport::Confirmed;
1315 m.awaiting = awaiting;
1316 }
1317 }
1318 }
1319 if !sent {
1320 return stopped_unsent(inner, attempt_id, support);
1321 }
1322 if support == CancelSupport::None {
1323 return cancelled_report(cancel_outcome(true, support, None), "stopped waiting");
1324 }
1325 let ids = vec![attempt_id.to_string()];
1326 if awaiting {
1327 let confirmed = send_cancel(inner, ids, inner.config.phases.cancel_budget(budget))
1328 .await
1329 .and_then(|answers| stopped_charge(&answers, attempt_id))
1330 .map(|c| convert_charge(c, inner.config.unit_rate));
1331 let outcome = cancel_outcome(true, support, confirmed);
1332 let late = {
1333 let mut st = lock(&ex.state);
1334 let Some(m) = st.members.iter_mut().find(|m| m.attempt_id == attempt_id) else {
1335 return lost(ex.sent.may_have_sent());
1336 };
1337 m.awaiting = false;
1338 let parked = m.late_share.take();
1339 if m.settled {
1340 None
1341 } else if outcome.ack == CancelAck::Stopped {
1342 m.settled = true;
1344 None
1345 } else {
1346 m.settled = parked.is_some();
1347 parked
1348 }
1349 };
1350 if let Some(units) = late {
1351 inner.exchanges.offer(ReconciliationOffer {
1352 attempt_id: attempt_id.to_string(),
1353 observed_units: units,
1354 binding: inner.binding.clone(),
1355 });
1356 }
1357 return cancelled_report(outcome, "cancel requested");
1358 }
1359 let (inner2, ex2, id) = (inner.clone(), ex.clone(), attempt_id.to_string());
1362 let cancel_budget = inner
1363 .config
1364 .phases
1365 .cancel_budget(Budget::from_ms(inner.config.cancel_budget_ms));
1366 tokio::spawn(async move {
1367 let confirmed = send_cancel(&inner2, ids, cancel_budget)
1368 .await
1369 .and_then(|answers| stopped_charge(&answers, &id))
1370 .map(|c| convert_charge(c, inner2.config.unit_rate));
1371 let Some(c @ Charge::Observed { units }) = confirmed else {
1372 return;
1373 };
1374 let offer = {
1375 let mut st = lock(&ex2.state);
1376 let no_response = st.finished == Some(false);
1377 match st.members.iter_mut().find(|m| m.attempt_id == id) {
1378 Some(m) => {
1379 m.confirmed = Some(c);
1380 let now = no_response && !m.settled;
1381 m.settled |= now;
1382 now
1383 }
1384 None => false,
1385 }
1386 };
1387 if offer {
1388 inner2.exchanges.offer(ReconciliationOffer {
1389 attempt_id: id,
1390 observed_units: units,
1391 binding: inner2.binding.clone(),
1392 });
1393 }
1394 });
1395 cancelled_report(cancel_outcome(true, support, None), "cancel requested")
1396}
1397
1398fn stopped_charge(answers: &CancelResponse, attempt_id: &str) -> Option<Charge> {
1399 let mut found = answers
1400 .answers
1401 .iter()
1402 .filter(|a| a.attempt_id == attempt_id);
1403 let one = found.next()?;
1404 if found.next().is_some() {
1405 return None;
1406 }
1407 match one.answer {
1408 RemoteCancelAnswer::Stopped { charge } => Some(charge),
1409 _ => None,
1410 }
1411}
1412
1413async fn send_cancel(
1414 inner: &Arc<Inner>,
1415 attempt_ids: Vec<String>,
1416 budget: Budget,
1417) -> Option<CancelResponse> {
1418 let body = CancelRequest {
1419 schema: schema::REMOTE_CANCEL.into(),
1420 version: inner.version,
1421 backend_id: inner.remote.descriptor.backend_id.clone(),
1422 attempt_ids,
1423 }
1424 .canonical()
1425 .ok()?;
1426 let max = (inner.remote.terms.max_response_bytes as usize).min(REMOTE_V1.max_bytes);
1427 let reply = inner
1428 .transport
1429 .post(CANCEL_PATH, body, max, &budget, &SentTracker::new())
1430 .await
1431 .ok()?;
1432 if classify_status(reply.status).is_some() {
1433 return None;
1434 }
1435 CancelResponse::parse(&reply.body).ok()
1436}
1437
1438struct Determined {
1440 outcome: Result<RawOutput, (RemoteCode, String)>,
1441 share: Option<Charge>,
1444 in_progress: bool,
1446}
1447
1448async fn run_exchange(
1449 inner: Arc<Inner>,
1450 ex: Arc<Exchange>,
1451 flush: oneshot::Receiver<()>,
1452 window: Duration,
1453 first_budget: Budget,
1454) {
1455 if !window.is_zero() {
1456 let until = first_budget.phase_deadline(Some(window));
1457 tokio::select! {
1458 _ = tokio::time::sleep_until(until) => {}
1459 _ = flush => {}
1460 }
1461 }
1462 let (items, kinds, budget) = {
1464 let mut open = lock(&inner.open);
1465 if let Some(k) = &ex.key
1466 && open.get(k).is_some_and(|e| Arc::ptr_eq(e, &ex))
1467 {
1468 open.remove(k);
1469 }
1470 let mut st = lock(&ex.state);
1471 st.phase = Phase::Sending;
1472 st.members.retain(|m| m.status != Status::Withdrawn);
1473 if ex.batch_id.is_none() {
1476 st.members.sort_by(|a, b| a.attempt_id.cmp(&b.attempt_id));
1477 }
1478 if st.members.is_empty() {
1479 return;
1480 }
1481 let budget = st
1482 .members
1483 .iter()
1484 .map(|m| m.budget)
1485 .reduce(Budget::min)
1486 .expect("non-empty");
1487 let items: Vec<InferItem> = st
1488 .members
1489 .iter()
1490 .map(|m| InferItem {
1491 attempt_id: m.attempt_id.clone(),
1492 projection: m.projection.clone(),
1493 })
1494 .collect();
1495 let kinds: Vec<OutputKind> = st.members.iter().map(|m| m.kind.clone()).collect();
1496 (items, kinds, budget)
1497 };
1498 let terms = &inner.remote.terms;
1499 let told = budget.told_ms(inner.config.transit_margin_ms);
1500 let request = InferRequest {
1501 schema: schema::REMOTE_INFER.into(),
1502 version: inner.version,
1503 backend_id: inner.remote.descriptor.backend_id.clone(),
1504 descriptor: inner.remote.descriptor_id.clone(),
1505 artifact: inner.remote.descriptor.artifact.clone(),
1506 budget_ms: told,
1507 trace_id: ex.trace_id.clone(),
1508 privacy: inner.config.privacy.clone(),
1509 items,
1510 principal: ex.principal.clone(),
1511 };
1512 let body = request.canonical().unwrap_or_default();
1513 let mut record = blank_record(inner.protocol, inner.binding.clone(), vec![]);
1514 record.principal = request.principal.as_ref().map(WirePrincipal::evidence);
1515 record.privacy_requested = inner.config.privacy.clone();
1516 record.request_digest = Some(digest_bytes(&body));
1517 if let (Some(b), true) = (inner.config.batching, request.items.len() > 1) {
1518 record.attribution = Attribution::SharedState {
1519 coalescing_window_ms: b.window_ms,
1520 };
1521 }
1522 if ex.batch_id.is_some() {
1523 record.attribution = Attribution::RuntimePlanned;
1524 }
1525 let n = request.items.len();
1526 let all = |code: RemoteCode, detail: &str, share: Option<Charge>| -> Vec<Determined> {
1527 (0..n)
1528 .map(|_| Determined {
1529 outcome: Err((code, detail.to_string())),
1530 share,
1531 in_progress: false,
1532 })
1533 .collect()
1534 };
1535 let mut reply_body: Option<Vec<u8>> = None;
1536 let mut live_only = false;
1540 let determined: Vec<Determined> = if told == 0 || body.is_empty() {
1541 ex.sent.try_abort();
1543 live_only = true;
1544 all(
1545 RemoteCode::TransportUnsent,
1546 "no budget left to give the remote side",
1547 None,
1548 )
1549 } else if body.len() as u64 > terms.max_request_bytes {
1550 ex.sent.try_abort();
1551 all(
1552 RemoteCode::Capability,
1553 "request exceeds the negotiated size",
1554 None,
1555 )
1556 } else {
1557 let max = (terms.max_response_bytes as usize).min(REMOTE_V1.max_bytes);
1558 let (r, connection) = inner
1559 .transport
1560 .post_observed(
1561 INFER_PATH,
1562 body.clone(),
1563 max,
1564 &budget,
1565 &ex.sent,
1566 ex.principal.as_ref().map(|p| p.evidence().digest),
1567 )
1568 .await;
1569 record.connection = connection;
1570 match r {
1571 Err(e) => {
1572 live_only = budget_ran_out(&e, &budget);
1573 if let TransportError::PhaseExpired(p) = &e {
1574 record.phase_expiry = Some(*p);
1575 }
1576 let (code, detail) = transport_code(&e);
1577 all(code, &detail, None)
1578 }
1579 Ok(HttpReply {
1580 status,
1581 retry_after,
1582 body,
1583 }) => {
1584 record.status = Some(status);
1585 record.retry_after = retry_after.clone();
1586 record.response_digest = Some(digest_bytes(&body));
1587 reply_body = Some(body.clone());
1588 match classify_status(status) {
1589 Some(code) => {
1590 let mut d = format!("HTTP {status}");
1591 if let Some(r) = &retry_after {
1592 d.push_str(&format!("; retry-after {r} (recorded, not waited)"));
1593 }
1594 all(code, &d, None)
1595 }
1596 None => read_response(&inner, &request, &kinds, &body, &mut record),
1597 }
1598 }
1599 }
1600 };
1601 record.bytes_sent = ex.sent.may_have_sent();
1602 let retained_request = match &request.principal {
1605 None => body,
1606 Some(p) => {
1607 let mut r = request.clone();
1608 r.principal = Some(WirePrincipal {
1609 reference: p.evidence().digest,
1610 ..p.clone()
1611 });
1612 r.canonical().unwrap_or_default()
1613 }
1614 };
1615 finish(
1616 &inner,
1617 &ex,
1618 determined,
1619 live_only,
1620 record,
1621 retained_request,
1622 reply_body,
1623 );
1624}
1625
1626fn read_response(
1628 inner: &Inner,
1629 request: &InferRequest,
1630 kinds: &[OutputKind],
1631 body: &[u8],
1632 record: &mut rustev_contract::remote::ExchangeRecord,
1633) -> Vec<Determined> {
1634 let n = request.items.len();
1635 let all = |code: RemoteCode, detail: String, share: Option<Charge>| -> Vec<Determined> {
1636 (0..n)
1637 .map(|_| Determined {
1638 outcome: Err((code, detail.clone())),
1639 share,
1640 in_progress: false,
1641 })
1642 .collect()
1643 };
1644 let resp = match InferResponse::parse(body) {
1645 Ok(r) if r.version == inner.version => r,
1646 Ok(r) => {
1649 return all(
1650 RemoteCode::IdentityMismatch,
1651 format!(
1652 "protocol version {} where {} was negotiated",
1653 r.version, inner.version
1654 ),
1655 None,
1656 );
1657 }
1658 Err(e) => return all(RemoteCode::MalformedResponse, e.to_string(), None),
1659 };
1660 record.route = resp.route.clone();
1661 record.generation_id = resp.generation_id.clone();
1662 record.provider_extras = resp.extras.clone();
1663 let terms = &inner.remote.terms;
1664 let rate = inner.config.unit_rate;
1665 let total = convert_charge(resp.charge, rate);
1666 record.reported_cost = match resp.charge {
1667 Charge::Observed { units } | Charge::Estimated { units } => {
1668 Some(rustev_contract::remote::ReportedCost {
1669 amount: units.to_string(),
1670 currency: "remote-units".into(),
1671 })
1672 }
1673 Charge::Unknown => None,
1674 };
1675 record.exchange_charge = total;
1676 let reported = (resp.charge != Charge::Unknown).then_some(total);
1677 let served = match served_identity(&terms.served, resp.served.identity.as_deref()) {
1678 Ok(s) => s,
1679 Err(m) => {
1680 record.served = ServedIdentity {
1681 identity: Some(m.served.clone()),
1682 disclosure: Disclosure::Reported,
1683 };
1684 return all(
1685 RemoteCode::IdentityMismatch,
1686 format!("served `{}` where `{}` is pinned", m.served, m.pinned),
1687 reported,
1688 );
1689 }
1690 };
1691 if served.disclosure == Disclosure::Unknown
1693 && let Some(name) = &resp.served.identity
1694 {
1695 record
1696 .provider_extras
1697 .insert("served_identity_claim".into(), name.clone());
1698 }
1699 record.served = served;
1700 if resp.artifact != inner.remote.descriptor.artifact {
1701 return all(
1702 RemoteCode::IdentityMismatch,
1703 "the remote artifact differs from the negotiated one".into(),
1704 reported,
1705 );
1706 }
1707 let mut counts: HashMap<&str, usize> = HashMap::new();
1710 for a in &resp.items {
1711 *counts.entry(a.attempt_id.as_str()).or_default() += 1;
1712 }
1713 let asked: HashSet<&str> = request
1714 .items
1715 .iter()
1716 .map(|i| i.attempt_id.as_str())
1717 .collect();
1718 if counts.keys().any(|k| !asked.contains(k)) {
1719 return all(
1720 RemoteCode::MalformedResponse,
1721 "an answer names an attempt that was not asked".into(),
1722 reported,
1723 );
1724 }
1725 request
1726 .items
1727 .iter()
1728 .zip(kinds)
1729 .map(|(item, kind)| {
1730 let mut d = Determined {
1731 outcome: Err((RemoteCode::MalformedResponse, String::new())),
1732 share: reported,
1733 in_progress: false,
1734 };
1735 match counts.get(item.attempt_id.as_str()).copied().unwrap_or(0) {
1736 0 => d.outcome = Err((RemoteCode::MalformedResponse, "no answer".into())),
1737 1 => {}
1738 _ => {
1739 d.outcome = Err((RemoteCode::MalformedResponse, "answered twice".into()));
1740 return d;
1741 }
1742 }
1743 let Some(a) = resp.items.iter().find(|a| a.attempt_id == item.attempt_id) else {
1744 return d;
1745 };
1746 let declared = Some(kind.clone());
1747 d.outcome = match &a.result {
1748 ItemResult::Output(o) if output_kind_of(o) == declared => Ok(o.clone()),
1749 ItemResult::Output(o) => Err((
1750 RemoteCode::MalformedResponse,
1751 format!(
1752 "a {} answer where {:?} is declared",
1753 output_kind_of(o)
1754 .map(|kind| format!("{kind:?}"))
1755 .unwrap_or_else(|| "TextRecord".into()),
1756 declared
1757 ),
1758 )),
1759 ItemResult::Failure { code, .. } if is_local_only(*code) => Err((
1762 RemoteCode::MalformedResponse,
1763 format!("the adapter-only code `{}` in an answer", code.as_str()),
1764 )),
1765 ItemResult::Failure { code, detail } => Err((*code, detail.clone())),
1766 ItemResult::InProgress => {
1767 d.in_progress = true;
1768 Err((RemoteCode::RemoteError, "in_progress".into()))
1769 }
1770 };
1771 d
1772 })
1773 .collect()
1774}
1775
1776fn finish(
1779 inner: &Inner,
1780 ex: &Exchange,
1781 det: Vec<Determined>,
1782 live_only: bool,
1783 mut record: rustev_contract::remote::ExchangeRecord,
1784 request: Vec<u8>,
1785 response: Option<Vec<u8>>,
1786) {
1787 let terms = &inner.remote.terms;
1788 let sent = record.bytes_sent;
1789 let mut offers = vec![];
1790 let mut reports = vec![];
1793 {
1794 let mut st = lock(&ex.state);
1795 st.finished = Some(response.is_some());
1796 if response.is_none() {
1797 for m in &mut st.members {
1800 if let (Status::Abandoned, false, Some(Charge::Observed { units })) =
1801 (m.status, m.settled, m.confirmed)
1802 {
1803 m.settled = true;
1804 offers.push(ReconciliationOffer {
1805 attempt_id: m.attempt_id.clone(),
1806 observed_units: units,
1807 binding: inner.binding.clone(),
1808 });
1809 }
1810 }
1811 }
1812 let receives =
1815 |m: &Member| m.status == Status::Waiting && !(live_only && m.budget.expired());
1816 let received: Vec<bool> = st.members.iter().map(receives).collect();
1817 let any_received = received.iter().any(|r| *r);
1818 let mut members = vec![];
1819 {
1820 let reported = det.first().and_then(|d| d.share);
1821 let shares = match reported {
1824 Some(total) if any_received => attribute(total, &received),
1825 Some(total) => attribute(total, &vec![true; received.len()]),
1826 None => vec![Charge::Unknown; received.len()],
1827 };
1828 record.late = !any_received && response.is_some();
1829 for ((m, d), share) in st.members.iter_mut().zip(det).zip(shares) {
1830 let waiting = receives(m);
1831 let report = match &d.outcome {
1832 Ok(o) => AttemptReport {
1833 result: Ok(o.clone()),
1834 charge: share,
1835 cancel: CancelAck::NotRequested,
1836 remote: RemoteEnd::Finished,
1837 },
1838 Err((RemoteCode::Cancelled, detail)) => RemoteFailure::new(
1840 RemoteCode::RemoteError,
1841 format!("cancelled at the remote side without a request: {detail}"),
1842 d.share.map(|_| share),
1843 terms.refusals_charged,
1844 sent,
1845 )
1846 .report(),
1847 Err((code, detail)) => {
1848 let reported = d.share.map(|_| share);
1849 let mut f = RemoteFailure::new(
1850 *code,
1851 detail.clone(),
1852 reported,
1853 terms.refusals_charged,
1854 sent,
1855 );
1856 if d.in_progress {
1857 f.charge = Charge::Unknown;
1858 f.remote = RemoteEnd::PossiblyContinuing;
1859 }
1860 f.report()
1861 }
1862 };
1863 members.push(ExchangeMember {
1864 attempt_id: m.attempt_id.clone(),
1865 outcome: match &d.outcome {
1866 Ok(o) => MemberOutcome::Output {
1867 digest: output_digest(o).unwrap_or_default(),
1868 },
1869 Err((code, detail)) => MemberOutcome::Failure {
1870 code: *code,
1871 detail: detail.clone(),
1872 },
1873 },
1874 charge: if waiting || record.late {
1875 report.charge
1876 } else {
1877 Charge::Unknown
1878 },
1879 });
1880 if waiting {
1881 m.status = Status::Delivered;
1882 m.settled = true;
1883 if let Some(tx) = m.tx.take() {
1884 reports.push((tx, report));
1885 }
1886 } else if m.status == Status::Abandoned && !m.settled && response.is_some() {
1887 let offered = if any_received {
1890 matches!(reported, Some(Charge::Observed { .. })).then_some(0)
1892 } else {
1893 match share {
1894 Charge::Observed { units } => Some(units),
1895 _ => None,
1896 }
1897 };
1898 if m.awaiting {
1899 m.late_share = offered;
1900 } else if let Some(units) = offered {
1901 m.settled = true;
1902 offers.push(ReconciliationOffer {
1903 attempt_id: m.attempt_id.clone(),
1904 observed_units: units,
1905 binding: inner.binding.clone(),
1906 });
1907 }
1908 }
1909 }
1910 }
1911 record.members = members;
1912 }
1913 for o in offers {
1914 inner.exchanges.offer(o);
1915 }
1916 let retained = RetainedBytes { request, response };
1917 let (members, served, sent_any, batch_id) = (
1918 record.members.clone(),
1919 record.served.clone(),
1920 record.bytes_sent,
1921 ex.batch_id
1922 .clone()
1923 .unwrap_or_else(|| record.request_digest.clone().unwrap_or_default()),
1924 );
1925 let note = inner.exchanges.deliver(record, Some(retained));
1926 if inner.config.run_evidence {
1927 let exchange = match (note.digest, note.located) {
1929 (Some(digest), Ok(Some(locator))) => ExchangeReference::Retained {
1930 digest,
1931 schema: rustev_contract::schema::REMOTE_EXCHANGE.into(),
1932 retention: if inner.exchanges.retains_bytes() {
1933 RetentionClass::BytesRetained
1934 } else {
1935 RetentionClass::DigestOnly
1936 },
1937 locator,
1938 },
1939 (_, Ok(None)) => ExchangeReference::Unavailable {
1940 reason: EvidenceUnavailable::NoLocator,
1941 },
1942 _ => ExchangeReference::Unavailable {
1943 reason: EvidenceUnavailable::NotDelivered,
1944 },
1945 };
1946 let n = members.len();
1947 let mut store = lock(&inner.evidence);
1948 for (index, m) in members.iter().enumerate() {
1949 if let Some(e) = store.map.get_mut(&m.attempt_id) {
1950 e.exchange = exchange.clone();
1951 e.served = served.clone();
1952 e.code = match &m.outcome {
1953 MemberOutcome::Failure { code, .. } => Some(*code),
1954 MemberOutcome::Output { .. } => None,
1955 };
1956 e.charge_provenance = match m.charge {
1957 _ if !sent_any => ChargeProvenance::NothingSent,
1958 Charge::Unknown => ChargeProvenance::Unknown,
1959 _ if n > 1 => ChargeProvenance::Attributed,
1960 _ => ChargeProvenance::Reported,
1961 };
1962 e.batch = (n > 1).then(|| BatchMembership {
1963 batch_id: batch_id.clone(),
1964 index: index as u32,
1965 });
1966 }
1967 }
1968 }
1969 for (tx, report) in reports {
1970 let _ = tx.send(report);
1971 }
1972}
1973
1974pub fn map_retained(body: &[u8], attempt_id: &str, declared: OutputKind) -> Option<RawOutput> {
1979 let resp = InferResponse::parse(body).ok()?;
1980 protocol_name(resp.version)?;
1981 let mut answers = resp.items.iter().filter(|a| a.attempt_id == attempt_id);
1982 let one = answers.next()?;
1983 if answers.next().is_some() {
1984 return None;
1985 }
1986 match &one.result {
1987 ItemResult::Output(o) if output_kind_of(o).as_ref() == Some(&declared) => Some(o.clone()),
1988 _ => None,
1989 }
1990}
1991
1992#[cfg(test)]
1993mod tests {
1994 use super::*;
1995
1996 #[test]
1997 fn an_aborted_exchange_never_counts_as_sent_even_with_siblings() {
1998 let t = SentTracker::new();
1999 assert!(t.try_abort());
2000 assert!(!bytes_may_leave(&t, true));
2001 assert!(!bytes_may_leave(&t, false));
2002 let idle = SentTracker::new();
2003 assert!(bytes_may_leave(&idle, true), "siblings will send it");
2004 let alone = SentTracker::new();
2005 assert!(!bytes_may_leave(&alone, false), "a lone member aborts");
2006 let writing = SentTracker::new();
2007 assert!(writing.begin_write());
2008 assert!(bytes_may_leave(&writing, false));
2009 }
2010
2011 #[test]
2012 fn a_lost_answer_after_sending_is_possibly_continuing() {
2013 let r = lost(true);
2014 assert_eq!(
2015 (r.charge, r.remote),
2016 (Charge::Unknown, RemoteEnd::PossiblyContinuing)
2017 );
2018 let r = lost(false);
2019 assert_eq!(
2020 (r.charge, r.remote),
2021 (Charge::Observed { units: 0 }, RemoteEnd::Finished)
2022 );
2023 }
2024}