1use std::collections::HashSet;
6use std::sync::Mutex;
7
8use async_trait::async_trait;
9use devicerail_client::protocol::{
10 DeviceExecuteParams, EventSequence, EventsListParams, SessionEndParams,
11 SessionId as WireSessionId, SessionOutcome as WireSessionOutcome, TestEvent,
12 UiSnapshotGetParams, Verdict as WireVerdict, VerdictRecordParams,
13 VerdictStatus as WireVerdictStatus, feature,
14};
15use devicerail_client::{CallOptions, ClientError, DeviceRailClient, RequestHandle, methods};
16use pointlock_ir::{
17 ActionOutcome, ActionResult, ErrorClass, EventCursor, Observation, ReconcileResult,
18 ScreenshotOmissionReason, UiSnapshotOmissionReason, VerdictStatus,
19};
20use pointlock_provider_kit::lockfile::CapabilityAttestation;
21use pointlock_provider_kit::{
22 BoundActionCall, CancellationToken, EvidenceStream, ObserveRequest, ObserveWant, ProviderError,
23 ProviderSession, RetryableSource, SessionHealth, SessionOutcome, UiSnapshotOutcome,
24 VERDICT_EVIDENCE_MAX_ENTRIES, VERDICT_SUMMARY_MAX_CHARS, VerdictWrite, now_ms,
25 observation_projection, synthetic_observation_wants,
26};
27use uuid::Uuid;
28
29use crate::budget::{
30 BoundedError, DEFAULT_CALL_BUDGET_MS, ENVELOPE_MARGIN_MS, bounded, clamp_timeout,
31 envelope_options,
32};
33use crate::convert::{
34 action_outcome_from_wire, action_result_from_wire, asset_ref_to_wire, observation_from_wire,
35 ui_snapshot_omission_from_wire,
36};
37use crate::error_map::{
38 cancelled_before_dispatch, execute_terminal_from_rpc, provider_error_from_client, session_gone,
39};
40use crate::scan::{CallFate, SCAN_PAGE_LIMIT, latest_sequence, scan_for_call};
41
42#[derive(Debug, Default)]
43struct SessionState {
44 ended: bool,
47 degraded: Option<String>,
50 dispatched: HashSet<Uuid>,
53 watermark: Option<EventSequence>,
56}
57
58pub struct DeviceRailSession {
60 client: DeviceRailClient,
61 attestation: CapabilityAttestation,
62 session_id: WireSessionId,
63 state: Mutex<SessionState>,
64}
65
66impl std::fmt::Debug for DeviceRailSession {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("DeviceRailSession")
69 .field("session_id", &self.session_id)
70 .finish_non_exhaustive()
71 }
72}
73
74impl DeviceRailSession {
75 pub(crate) fn new(
76 client: DeviceRailClient,
77 attestation: CapabilityAttestation,
78 session_id: WireSessionId,
79 ) -> Self {
80 DeviceRailSession {
81 client,
82 attestation,
83 session_id,
84 state: Mutex::new(SessionState::default()),
85 }
86 }
87
88 fn state(&self) -> std::sync::MutexGuard<'_, SessionState> {
89 self.state.lock().expect("session state lock poisoned")
90 }
91
92 fn ensure_active(&self, method: &str) -> Result<(), ProviderError> {
93 if self.state().ended {
94 return Err(session_gone(method));
95 }
96 Ok(())
97 }
98
99 fn feature_enabled(&self, feature: &str) -> bool {
100 self.attestation
101 .features_enabled
102 .iter()
103 .any(|enabled| enabled.as_str() == feature)
104 }
105
106 fn pre_cancelled(cancel: &Option<CancellationToken>) -> bool {
107 cancel.as_ref().is_some_and(CancellationToken::is_cancelled)
108 }
109
110 fn note_degradation(&self, error: &ClientError) {
112 if let ClientError::RemoteRpc { error, .. } = error
113 && error.data.code == "session_degraded"
114 {
115 self.state().degraded = Some(error.data.message.clone());
116 }
117 }
118
119 async fn await_with_cancel<T: Send + 'static>(
124 &self,
125 handle: RequestHandle<T>,
126 cancel: Option<CancellationToken>,
127 ) -> Result<T, ClientError> {
128 let request_id = handle.id().clone();
129 let result = handle.result();
130 let Some(token) = cancel else {
131 return result.await;
132 };
133 let mut result = std::pin::pin!(result);
134 tokio::select! {
135 outcome = &mut result => outcome,
136 () = token.cancelled() => {
137 let _ = self.client.cancel(request_id).await;
139 result.await
140 }
141 }
142 }
143
144 async fn events_page(
148 &self,
149 after: Option<EventSequence>,
150 ) -> Result<Vec<TestEvent>, BoundedError> {
151 let params = EventsListParams {
152 session_id: Some(self.session_id.clone()),
153 after_sequence: after,
154 limit: Some(SCAN_PAGE_LIMIT),
155 };
156 bounded(
157 self.client
158 .call::<methods::EventsList>(Some(params), CallOptions::default()),
159 )
160 .await
161 }
162
163 fn verdict_record_gate(negotiated: bool) -> Result<(), ProviderError> {
170 if negotiated {
171 return Ok(());
172 }
173 Err(ProviderError::new(
174 ErrorClass::CapabilityDrift,
175 "verdict.record.v1 was not negotiated in this session; the compiler \
176 guarantees every IR requires it (04 §9.2) — reaching recordVerdict \
177 without it is capability drift",
178 RetryableSource::Classifier,
179 ))
180 }
181
182 fn execute_budgets(
187 call: &BoundActionCall,
188 ) -> Result<(Option<u64>, Option<u64>), ProviderError> {
189 match (call.action_timeout_ms, call.request_timeout_ms) {
190 (Some(action), Some(envelope)) if action >= envelope => Err(ProviderError::new(
191 ErrorClass::BindArgumentsInvalid,
192 format!(
193 "budget invariant violated: actionTimeoutMs ({action}) must be strictly \
194 below the request envelope timeoutMs ({envelope}) (04 §9.7 invariant 1)"
195 ),
196 RetryableSource::Classifier,
197 )),
198 (action, Some(envelope)) => Ok((action, Some(envelope))),
199 (Some(action), None) => Ok((
200 Some(action),
201 Some(action.saturating_add(ENVELOPE_MARGIN_MS)),
202 )),
203 (None, None) => Ok((None, None)),
204 }
205 }
206
207 async fn execute_observation(
216 &self,
217 call: BoundActionCall,
218 wants: Vec<ObserveWant>,
219 cancel: Option<CancellationToken>,
220 ) -> Result<ActionOutcome, ProviderError> {
221 let started_at_ms = now_ms();
222 let observation = self
223 .observe(
224 ObserveRequest {
225 wants: wants.clone(),
226 },
227 cancel,
228 )
229 .await?;
230 let output = observation_projection(&observation, &wants);
231 Ok(ActionOutcome::Succeeded {
232 result: Box::new(ActionResult {
233 call_id: call.call_id,
234 started_at_ms,
235 finished_at_ms: now_ms(),
236 output,
237 before: None,
238 after: Some(observation),
242 evidence: Vec::new(),
243 execution: None,
244 }),
245 })
246 }
247}
248
249#[async_trait]
250impl ProviderSession for DeviceRailSession {
251 fn attestation(&self) -> &CapabilityAttestation {
252 &self.attestation
253 }
254
255 async fn execute(
256 &self,
257 call: BoundActionCall,
258 cancel: Option<CancellationToken>,
259 ) -> Result<ActionOutcome, ProviderError> {
260 self.ensure_active("execute")?;
261 if Self::pre_cancelled(&cancel) {
262 return Err(cancelled_before_dispatch());
265 }
266 if !self.attestation.actions.contains_key(&call.action_name)
273 && let Some(wants) = synthetic_observation_wants(&call)?
274 {
275 return self.execute_observation(call, wants, cancel).await;
276 }
277 if !self.attestation.actions.contains_key(&call.action_name) {
279 return Err(ProviderError::new(
280 ErrorClass::CapabilityDrift,
281 format!(
282 "actionName `{}` is not in the attested action set; refusing to dispatch",
283 call.action_name
284 ),
285 RetryableSource::Classifier,
286 ));
287 }
288 let call_id = Uuid::parse_str(&call.call_id).map_err(|error| {
291 ProviderError::new(
292 ErrorClass::BindArgumentsInvalid,
293 format!("callId must be the runner-generated UUID: {error}"),
294 RetryableSource::Classifier,
295 )
296 })?;
297 let (action_ms, envelope_ms) = Self::execute_budgets(&call)?;
298 if !self.state().dispatched.insert(call_id) {
299 return Err(ProviderError::new(
300 ErrorClass::BindArgumentsInvalid,
301 format!(
302 "duplicate callId {call_id}: a retry is a new callId with a new WAL intent \
303 (04 §3)"
304 ),
305 RetryableSource::Classifier,
306 ));
307 }
308
309 let params = DeviceExecuteParams {
310 id: call_id,
311 name: call.action_name.as_str().to_owned(),
312 arguments: call.arguments,
313 action_timeout_ms: action_ms.map(clamp_timeout),
314 };
315 let options = CallOptions {
316 timeout_ms: envelope_ms.map(clamp_timeout),
317 };
318 let handle = self
319 .client
320 .begin_call::<methods::DeviceExecute>(params, options)
321 .map_err(|error| provider_error_from_client(error, "device.execute"))?;
322
323 match self.await_with_cancel(handle, cancel).await {
324 Ok(result) => Ok(ActionOutcome::Succeeded {
326 result: Box::new(action_result_from_wire(&result)),
327 }),
328 Err(error) => {
329 if let ClientError::RemoteRpc { error: rpc, .. } = &error
333 && let Some(outcome) = execute_terminal_from_rpc(rpc)
334 {
335 return Ok(outcome);
336 }
337 self.note_degradation(&error);
340 Err(provider_error_from_client(error, "device.execute"))
341 }
342 }
343 }
344
345 async fn observe(
346 &self,
347 req: ObserveRequest,
348 cancel: Option<CancellationToken>,
349 ) -> Result<Observation, ProviderError> {
350 self.ensure_active("observe")?;
351 if Self::pre_cancelled(&cancel) {
352 return Err(cancelled_before_dispatch());
353 }
354 let handle = self
357 .client
358 .begin_call::<methods::DeviceObserve>(
359 methods::NoParams,
360 envelope_options(DEFAULT_CALL_BUDGET_MS),
361 )
362 .map_err(|error| provider_error_from_client(error, "device.observe"))?;
363 let wire_observation = self
364 .await_with_cancel(handle, cancel)
365 .await
366 .map_err(|error| {
367 self.note_degradation(&error);
368 provider_error_from_client(error, "device.observe")
369 })?;
370 let mut observation = observation_from_wire(&wire_observation);
371
372 if req.wants.contains(&ObserveWant::Screenshot)
376 && observation.screenshot.is_none()
377 && observation.screenshot_omission.is_none()
378 {
379 observation.screenshot_omission = Some(ScreenshotOmissionReason::Policy);
381 }
382 if req.wants.contains(&ObserveWant::UiSnapshot)
383 && observation.ui_snapshot.is_none()
384 && observation.ui_snapshot_omission.is_none()
385 {
386 observation.ui_snapshot_omission = Some(UiSnapshotOmissionReason::DriverUnsupported);
389 }
390 Ok(observation)
391 }
392
393 async fn ui_snapshot(&self, observation_id: &str) -> Result<UiSnapshotOutcome, ProviderError> {
394 self.ensure_active("uiSnapshot")?;
395 if !self.feature_enabled(feature::OBSERVATION_UI_SNAPSHOT_V1) {
399 return Err(ProviderError::new(
400 ErrorClass::CapabilityDrift,
401 "ui.snapshot.get requires observation.uiSnapshot.v1, which this session did \
402 not negotiate (04 §9.2: reaching this is capability drift)",
403 RetryableSource::Classifier,
404 ));
405 }
406 let observation_id = Uuid::parse_str(observation_id).map_err(|error| {
407 ProviderError::new(
408 ErrorClass::BindArgumentsInvalid,
409 format!("observationId must be the provider-issued UUID: {error}"),
410 RetryableSource::Classifier,
411 )
412 })?;
413 let call = self.client.call::<methods::UiSnapshotGet>(
414 UiSnapshotGetParams { observation_id },
415 CallOptions::default(),
416 );
417 match bounded(call).await {
418 Ok(snapshot) => Ok(UiSnapshotOutcome::Available {
419 snapshot: serde_json::to_value(snapshot).map_err(|error| {
421 ProviderError::new(
422 ErrorClass::TransportLost,
423 format!("ui.snapshot.get result failed to re-serialize: {error}"),
424 RetryableSource::Classifier,
425 )
426 })?,
427 }),
428 Err(BoundedError::Client(ClientError::RemoteRpc { error, .. }))
432 if error.data.code == "ui_snapshot_unavailable" =>
433 {
434 let reason = error
435 .data
436 .details
437 .as_ref()
438 .and_then(|details| details.get("omissionReason"))
439 .and_then(|value| serde_json::from_value(value.clone()).ok())
440 .map(ui_snapshot_omission_from_wire)
441 .unwrap_or(UiSnapshotOmissionReason::DriverUnsupported);
442 Ok(UiSnapshotOutcome::Unavailable { reason })
443 }
444 Err(error) => Err(error.into_provider_error("ui.snapshot.get")),
445 }
446 }
447
448 async fn reconcile(
449 &self,
450 call_id: &str,
451 issuing: &EventCursor,
452 ) -> Result<ReconcileResult, ProviderError> {
453 if issuing.session_id != self.session_id.to_string() {
461 return Ok(ReconcileResult::LogUnavailable {
462 reason: format!(
463 "the issuing session {} is not this session ({}); cross-generation \
464 log retrieval is not implemented in v0.1",
465 issuing.session_id, self.session_id
466 ),
467 });
468 }
469 if self.state().ended {
473 return Ok(ReconcileResult::LogUnavailable {
474 reason: "the provider session has ended; the issuing session's event log is \
475 no longer reachable through this connection"
476 .to_owned(),
477 });
478 }
479 let sessions = match bounded(
484 self.client
485 .call::<methods::SessionsList>(methods::NoParams, CallOptions::default()),
486 )
487 .await
488 {
489 Ok(sessions) => sessions,
490 Err(error) => {
491 return Ok(ReconcileResult::LogUnavailable {
492 reason: format!("the daemon's session index is unreadable: {error}"),
493 });
494 }
495 };
496 if !sessions.iter().any(|session| session.id == self.session_id) {
497 return Ok(ReconcileResult::LogUnavailable {
498 reason: format!(
499 "issuing session {} is absent from the daemon's session index (event log \
500 cleared or deleted)",
501 self.session_id
502 ),
503 });
504 }
505 let Ok(call_uuid) = Uuid::parse_str(call_id) else {
508 return Ok(ReconcileResult::NeverDispatched);
509 };
510 let floor = EventSequence::new(issuing.last_sequence);
511 match scan_for_call(call_uuid, floor, |after| self.events_page(after)).await {
512 Ok(CallFate::Completed(outcome)) => Ok(ReconcileResult::Completed {
515 outcome: Box::new(action_outcome_from_wire(&outcome)),
516 }),
517 Ok(CallFate::StartedNoTerminal) => Ok(ReconcileResult::StartedNoTerminal),
518 Ok(CallFate::NoTrace) => Ok(ReconcileResult::NeverDispatched),
519 Err(error) => Ok(ReconcileResult::LogUnavailable {
520 reason: format!("event log scan failed mid-range: {error}"),
521 }),
522 }
523 }
524
525 async fn fetch_evidence(
526 &self,
527 asset: &pointlock_ir::AssetRef,
528 ) -> Result<EvidenceStream, ProviderError> {
529 self.ensure_active("fetchEvidence")?;
530 let _ = asset_ref_to_wire(asset);
538 Err(ProviderError::new(
539 ErrorClass::ActionFailedFinal,
540 format!(
541 "fetchEvidence is unsupported by the DeviceRail wire surface: no byte channel \
542 exists for asset URI `{}` (devicerail:// assets are daemon-internal; \
543 devicerail-client has no fetch API)",
544 asset.uri
545 ),
546 RetryableSource::Classifier,
547 ))
548 }
549
550 async fn record_verdict(&self, verdict: VerdictWrite) -> Result<(), ProviderError> {
551 self.ensure_active("recordVerdict")?;
552 let summary_chars = verdict.summary.chars().count();
555 if summary_chars > VERDICT_SUMMARY_MAX_CHARS {
556 return Err(ProviderError::new(
557 ErrorClass::BindArgumentsInvalid,
558 format!(
559 "verdict summary is {summary_chars} chars; the wire cap is \
560 {VERDICT_SUMMARY_MAX_CHARS} (fail-closed, 04 §5)"
561 ),
562 RetryableSource::Classifier,
563 ));
564 }
565 if verdict.evidence.len() > VERDICT_EVIDENCE_MAX_ENTRIES {
566 return Err(ProviderError::new(
567 ErrorClass::BindArgumentsInvalid,
568 format!(
569 "verdict cites {} evidence entries; the wire cap is \
570 {VERDICT_EVIDENCE_MAX_ENTRIES} (fail-closed, 04 §5)",
571 verdict.evidence.len()
572 ),
573 RetryableSource::Classifier,
574 ));
575 }
576 Self::verdict_record_gate(self.feature_enabled(feature::VERDICT_RECORD_V1))?;
577 let params = VerdictRecordParams {
578 verdict: WireVerdict {
579 status: match verdict.status {
580 VerdictStatus::Pass => WireVerdictStatus::Pass,
581 VerdictStatus::Fail => WireVerdictStatus::Fail,
582 VerdictStatus::Unknown => WireVerdictStatus::Unknown,
583 },
584 summary: verdict.summary,
585 evidence: verdict.evidence.iter().map(asset_ref_to_wire).collect(),
586 },
587 };
588 bounded(
589 self.client
590 .call::<methods::VerdictRecord>(params, CallOptions::default()),
591 )
592 .await
593 .map(|_receipt| ())
594 .map_err(|error| error.into_provider_error("verdict.record"))
595 }
596
597 async fn current_cursor(&self) -> Result<EventCursor, ProviderError> {
598 self.ensure_active("currentCursor")?;
599 let from = self.state().watermark;
604 let latest = latest_sequence(from, |after| self.events_page(after))
605 .await
606 .map_err(|error| error.into_provider_error("events.list"))?;
607 if let Some(sequence) = latest {
608 self.state().watermark = Some(sequence);
609 }
610 Ok(EventCursor {
611 session_id: self.session_id.to_string(),
612 last_sequence: latest.map(EventSequence::get).unwrap_or(0),
613 })
614 }
615
616 async fn health(&self) -> Result<SessionHealth, ProviderError> {
617 let (ended, degraded) = {
619 let state = self.state();
620 (state.ended, state.degraded.clone())
621 };
622 if ended {
623 return Ok(SessionHealth {
624 ok: false,
625 degraded,
626 });
627 }
628 let probe = bounded(
629 self.client
630 .call::<methods::SessionCurrent>(methods::NoParams, CallOptions::default()),
631 )
632 .await;
633 Ok(match probe {
634 Ok(current) if current.session_id == self.session_id => {
635 SessionHealth { ok: true, degraded }
636 }
637 Ok(current) => SessionHealth {
638 ok: false,
639 degraded: Some(format!(
640 "daemon's active session {} is not the bound session {}",
641 current.session_id, self.session_id
642 )),
643 },
644 Err(error) => SessionHealth {
645 ok: false,
646 degraded: Some(error.to_string()),
647 },
648 })
649 }
650
651 async fn end(
652 &self,
653 outcome: SessionOutcome,
654 reason: Option<String>,
655 ) -> Result<(), ProviderError> {
656 {
658 let mut state = self.state();
659 if state.ended {
660 return Ok(());
661 }
662 state.ended = true;
663 }
664 let params = SessionEndParams {
668 outcome: Some(match outcome {
669 SessionOutcome::Completed => WireSessionOutcome::Completed,
670 SessionOutcome::Failed => WireSessionOutcome::Failed,
671 SessionOutcome::Cancelled => WireSessionOutcome::Cancelled,
672 SessionOutcome::Shutdown => WireSessionOutcome::Shutdown,
673 }),
674 reason,
675 };
676 let _ = bounded(
677 self.client
678 .call::<methods::SessionEnd>(Some(params), CallOptions::default()),
679 )
680 .await;
681 let _ = bounded(self.client.call::<methods::DeviceDisconnect>(
682 methods::NoParams,
683 envelope_options(DEFAULT_CALL_BUDGET_MS),
684 ))
685 .await;
686 let _ = self.client.close().await;
687 Ok(())
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694 use pointlock_ir::ActionName;
695 use serde_json::json;
696
697 fn call(action_ms: Option<u64>, request_ms: Option<u64>) -> BoundActionCall {
698 BoundActionCall {
699 call_id: Uuid::new_v4().to_string(),
700 action_name: ActionName::new("tap").unwrap(),
701 arguments: json!({}),
702 action_timeout_ms: action_ms,
703 request_timeout_ms: request_ms,
704 }
705 }
706
707 #[test]
708 fn record_verdict_without_the_feature_is_capability_drift() {
709 let error = DeviceRailSession::verdict_record_gate(false).expect_err("drift");
712 assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
713 assert!(error.message.contains("verdict.record.v1"));
714 DeviceRailSession::verdict_record_gate(true).expect("negotiated");
716 }
717
718 #[test]
719 fn envelope_budget_is_derived_from_the_action_budget() {
720 let (action, envelope) =
723 DeviceRailSession::execute_budgets(&call(Some(15_000), None)).unwrap();
724 assert_eq!(action, Some(15_000));
725 assert_eq!(envelope, Some(20_000));
726
727 let (action, envelope) =
729 DeviceRailSession::execute_budgets(&call(Some(1_000), Some(30_000))).unwrap();
730 assert_eq!(action, Some(1_000));
731 assert_eq!(envelope, Some(30_000));
732
733 let (action, envelope) = DeviceRailSession::execute_budgets(&call(None, None)).unwrap();
735 assert_eq!(action, None);
736 assert_eq!(envelope, None);
737 }
738
739 #[test]
740 fn budget_invariant_one_fails_closed() {
741 for (action, envelope) in [(5_000, 5_000), (6_000, 5_000)] {
743 let error = DeviceRailSession::execute_budgets(&call(Some(action), Some(envelope)))
744 .expect_err("invariant 1");
745 assert_eq!(error.error_class, ErrorClass::BindArgumentsInvalid);
746 }
747 }
748}