use std::collections::HashSet;
use std::sync::Mutex;
use async_trait::async_trait;
use devicerail_client::protocol::{
DeviceExecuteParams, EventSequence, EventsListParams, SessionEndParams,
SessionId as WireSessionId, SessionOutcome as WireSessionOutcome, TestEvent,
UiSnapshotGetParams, Verdict as WireVerdict, VerdictRecordParams,
VerdictStatus as WireVerdictStatus, feature,
};
use devicerail_client::{CallOptions, ClientError, DeviceRailClient, RequestHandle, methods};
use pointlock_ir::{
ActionOutcome, ActionResult, ErrorClass, EventCursor, Observation, ReconcileResult,
ScreenshotOmissionReason, UiSnapshotOmissionReason, VerdictStatus,
};
use pointlock_provider_kit::lockfile::CapabilityAttestation;
use pointlock_provider_kit::{
BoundActionCall, CancellationToken, EvidenceStream, ObserveRequest, ObserveWant, ProviderError,
ProviderSession, RetryableSource, SessionHealth, SessionOutcome, UiSnapshotOutcome,
VERDICT_EVIDENCE_MAX_ENTRIES, VERDICT_SUMMARY_MAX_CHARS, VerdictWrite, now_ms,
observation_projection, synthetic_observation_wants,
};
use uuid::Uuid;
use crate::budget::{
BoundedError, DEFAULT_CALL_BUDGET_MS, ENVELOPE_MARGIN_MS, bounded, clamp_timeout,
envelope_options,
};
use crate::convert::{
action_outcome_from_wire, action_result_from_wire, asset_ref_to_wire, observation_from_wire,
ui_snapshot_omission_from_wire,
};
use crate::error_map::{
cancelled_before_dispatch, execute_terminal_from_rpc, provider_error_from_client, session_gone,
};
use crate::scan::{CallFate, SCAN_PAGE_LIMIT, latest_sequence, scan_for_call};
#[derive(Debug, Default)]
struct SessionState {
ended: bool,
degraded: Option<String>,
dispatched: HashSet<Uuid>,
watermark: Option<EventSequence>,
}
pub struct DeviceRailSession {
client: DeviceRailClient,
attestation: CapabilityAttestation,
session_id: WireSessionId,
state: Mutex<SessionState>,
}
impl std::fmt::Debug for DeviceRailSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeviceRailSession")
.field("session_id", &self.session_id)
.finish_non_exhaustive()
}
}
impl DeviceRailSession {
pub(crate) fn new(
client: DeviceRailClient,
attestation: CapabilityAttestation,
session_id: WireSessionId,
) -> Self {
DeviceRailSession {
client,
attestation,
session_id,
state: Mutex::new(SessionState::default()),
}
}
fn state(&self) -> std::sync::MutexGuard<'_, SessionState> {
self.state.lock().expect("session state lock poisoned")
}
fn ensure_active(&self, method: &str) -> Result<(), ProviderError> {
if self.state().ended {
return Err(session_gone(method));
}
Ok(())
}
fn feature_enabled(&self, feature: &str) -> bool {
self.attestation
.features_enabled
.iter()
.any(|enabled| enabled.as_str() == feature)
}
fn pre_cancelled(cancel: &Option<CancellationToken>) -> bool {
cancel.as_ref().is_some_and(CancellationToken::is_cancelled)
}
fn note_degradation(&self, error: &ClientError) {
if let ClientError::RemoteRpc { error, .. } = error
&& error.data.code == "session_degraded"
{
self.state().degraded = Some(error.data.message.clone());
}
}
async fn await_with_cancel<T: Send + 'static>(
&self,
handle: RequestHandle<T>,
cancel: Option<CancellationToken>,
) -> Result<T, ClientError> {
let request_id = handle.id().clone();
let result = handle.result();
let Some(token) = cancel else {
return result.await;
};
let mut result = std::pin::pin!(result);
tokio::select! {
outcome = &mut result => outcome,
() = token.cancelled() => {
let _ = self.client.cancel(request_id).await;
result.await
}
}
}
async fn events_page(
&self,
after: Option<EventSequence>,
) -> Result<Vec<TestEvent>, BoundedError> {
let params = EventsListParams {
session_id: Some(self.session_id.clone()),
after_sequence: after,
limit: Some(SCAN_PAGE_LIMIT),
};
bounded(
self.client
.call::<methods::EventsList>(Some(params), CallOptions::default()),
)
.await
}
fn verdict_record_gate(negotiated: bool) -> Result<(), ProviderError> {
if negotiated {
return Ok(());
}
Err(ProviderError::new(
ErrorClass::CapabilityDrift,
"verdict.record.v1 was not negotiated in this session; the compiler \
guarantees every IR requires it (04 §9.2) — reaching recordVerdict \
without it is capability drift",
RetryableSource::Classifier,
))
}
fn execute_budgets(
call: &BoundActionCall,
) -> Result<(Option<u64>, Option<u64>), ProviderError> {
match (call.action_timeout_ms, call.request_timeout_ms) {
(Some(action), Some(envelope)) if action >= envelope => Err(ProviderError::new(
ErrorClass::BindArgumentsInvalid,
format!(
"budget invariant violated: actionTimeoutMs ({action}) must be strictly \
below the request envelope timeoutMs ({envelope}) (04 §9.7 invariant 1)"
),
RetryableSource::Classifier,
)),
(action, Some(envelope)) => Ok((action, Some(envelope))),
(Some(action), None) => Ok((
Some(action),
Some(action.saturating_add(ENVELOPE_MARGIN_MS)),
)),
(None, None) => Ok((None, None)),
}
}
async fn execute_observation(
&self,
call: BoundActionCall,
wants: Vec<ObserveWant>,
cancel: Option<CancellationToken>,
) -> Result<ActionOutcome, ProviderError> {
let started_at_ms = now_ms();
let observation = self
.observe(
ObserveRequest {
wants: wants.clone(),
},
cancel,
)
.await?;
let output = observation_projection(&observation, &wants);
Ok(ActionOutcome::Succeeded {
result: Box::new(ActionResult {
call_id: call.call_id,
started_at_ms,
finished_at_ms: now_ms(),
output,
before: None,
after: Some(observation),
evidence: Vec::new(),
execution: None,
}),
})
}
}
#[async_trait]
impl ProviderSession for DeviceRailSession {
fn attestation(&self) -> &CapabilityAttestation {
&self.attestation
}
async fn execute(
&self,
call: BoundActionCall,
cancel: Option<CancellationToken>,
) -> Result<ActionOutcome, ProviderError> {
self.ensure_active("execute")?;
if Self::pre_cancelled(&cancel) {
return Err(cancelled_before_dispatch());
}
if !self.attestation.actions.contains_key(&call.action_name)
&& let Some(wants) = synthetic_observation_wants(&call)?
{
return self.execute_observation(call, wants, cancel).await;
}
if !self.attestation.actions.contains_key(&call.action_name) {
return Err(ProviderError::new(
ErrorClass::CapabilityDrift,
format!(
"actionName `{}` is not in the attested action set; refusing to dispatch",
call.action_name
),
RetryableSource::Classifier,
));
}
let call_id = Uuid::parse_str(&call.call_id).map_err(|error| {
ProviderError::new(
ErrorClass::BindArgumentsInvalid,
format!("callId must be the runner-generated UUID: {error}"),
RetryableSource::Classifier,
)
})?;
let (action_ms, envelope_ms) = Self::execute_budgets(&call)?;
if !self.state().dispatched.insert(call_id) {
return Err(ProviderError::new(
ErrorClass::BindArgumentsInvalid,
format!(
"duplicate callId {call_id}: a retry is a new callId with a new WAL intent \
(04 §3)"
),
RetryableSource::Classifier,
));
}
let params = DeviceExecuteParams {
id: call_id,
name: call.action_name.as_str().to_owned(),
arguments: call.arguments,
action_timeout_ms: action_ms.map(clamp_timeout),
};
let options = CallOptions {
timeout_ms: envelope_ms.map(clamp_timeout),
};
let handle = self
.client
.begin_call::<methods::DeviceExecute>(params, options)
.map_err(|error| provider_error_from_client(error, "device.execute"))?;
match self.await_with_cancel(handle, cancel).await {
Ok(result) => Ok(ActionOutcome::Succeeded {
result: Box::new(action_result_from_wire(&result)),
}),
Err(error) => {
if let ClientError::RemoteRpc { error: rpc, .. } = &error
&& let Some(outcome) = execute_terminal_from_rpc(rpc)
{
return Ok(outcome);
}
self.note_degradation(&error);
Err(provider_error_from_client(error, "device.execute"))
}
}
}
async fn observe(
&self,
req: ObserveRequest,
cancel: Option<CancellationToken>,
) -> Result<Observation, ProviderError> {
self.ensure_active("observe")?;
if Self::pre_cancelled(&cancel) {
return Err(cancelled_before_dispatch());
}
let handle = self
.client
.begin_call::<methods::DeviceObserve>(
methods::NoParams,
envelope_options(DEFAULT_CALL_BUDGET_MS),
)
.map_err(|error| provider_error_from_client(error, "device.observe"))?;
let wire_observation = self
.await_with_cancel(handle, cancel)
.await
.map_err(|error| {
self.note_degradation(&error);
provider_error_from_client(error, "device.observe")
})?;
let mut observation = observation_from_wire(&wire_observation);
if req.wants.contains(&ObserveWant::Screenshot)
&& observation.screenshot.is_none()
&& observation.screenshot_omission.is_none()
{
observation.screenshot_omission = Some(ScreenshotOmissionReason::Policy);
}
if req.wants.contains(&ObserveWant::UiSnapshot)
&& observation.ui_snapshot.is_none()
&& observation.ui_snapshot_omission.is_none()
{
observation.ui_snapshot_omission = Some(UiSnapshotOmissionReason::DriverUnsupported);
}
Ok(observation)
}
async fn ui_snapshot(&self, observation_id: &str) -> Result<UiSnapshotOutcome, ProviderError> {
self.ensure_active("uiSnapshot")?;
if !self.feature_enabled(feature::OBSERVATION_UI_SNAPSHOT_V1) {
return Err(ProviderError::new(
ErrorClass::CapabilityDrift,
"ui.snapshot.get requires observation.uiSnapshot.v1, which this session did \
not negotiate (04 §9.2: reaching this is capability drift)",
RetryableSource::Classifier,
));
}
let observation_id = Uuid::parse_str(observation_id).map_err(|error| {
ProviderError::new(
ErrorClass::BindArgumentsInvalid,
format!("observationId must be the provider-issued UUID: {error}"),
RetryableSource::Classifier,
)
})?;
let call = self.client.call::<methods::UiSnapshotGet>(
UiSnapshotGetParams { observation_id },
CallOptions::default(),
);
match bounded(call).await {
Ok(snapshot) => Ok(UiSnapshotOutcome::Available {
snapshot: serde_json::to_value(snapshot).map_err(|error| {
ProviderError::new(
ErrorClass::TransportLost,
format!("ui.snapshot.get result failed to re-serialize: {error}"),
RetryableSource::Classifier,
)
})?,
}),
Err(BoundedError::Client(ClientError::RemoteRpc { error, .. }))
if error.data.code == "ui_snapshot_unavailable" =>
{
let reason = error
.data
.details
.as_ref()
.and_then(|details| details.get("omissionReason"))
.and_then(|value| serde_json::from_value(value.clone()).ok())
.map(ui_snapshot_omission_from_wire)
.unwrap_or(UiSnapshotOmissionReason::DriverUnsupported);
Ok(UiSnapshotOutcome::Unavailable { reason })
}
Err(error) => Err(error.into_provider_error("ui.snapshot.get")),
}
}
async fn reconcile(
&self,
call_id: &str,
issuing: &EventCursor,
) -> Result<ReconcileResult, ProviderError> {
if issuing.session_id != self.session_id.to_string() {
return Ok(ReconcileResult::LogUnavailable {
reason: format!(
"the issuing session {} is not this session ({}); cross-generation \
log retrieval is not implemented in v0.1",
issuing.session_id, self.session_id
),
});
}
if self.state().ended {
return Ok(ReconcileResult::LogUnavailable {
reason: "the provider session has ended; the issuing session's event log is \
no longer reachable through this connection"
.to_owned(),
});
}
let sessions = match bounded(
self.client
.call::<methods::SessionsList>(methods::NoParams, CallOptions::default()),
)
.await
{
Ok(sessions) => sessions,
Err(error) => {
return Ok(ReconcileResult::LogUnavailable {
reason: format!("the daemon's session index is unreadable: {error}"),
});
}
};
if !sessions.iter().any(|session| session.id == self.session_id) {
return Ok(ReconcileResult::LogUnavailable {
reason: format!(
"issuing session {} is absent from the daemon's session index (event log \
cleared or deleted)",
self.session_id
),
});
}
let Ok(call_uuid) = Uuid::parse_str(call_id) else {
return Ok(ReconcileResult::NeverDispatched);
};
let floor = EventSequence::new(issuing.last_sequence);
match scan_for_call(call_uuid, floor, |after| self.events_page(after)).await {
Ok(CallFate::Completed(outcome)) => Ok(ReconcileResult::Completed {
outcome: Box::new(action_outcome_from_wire(&outcome)),
}),
Ok(CallFate::StartedNoTerminal) => Ok(ReconcileResult::StartedNoTerminal),
Ok(CallFate::NoTrace) => Ok(ReconcileResult::NeverDispatched),
Err(error) => Ok(ReconcileResult::LogUnavailable {
reason: format!("event log scan failed mid-range: {error}"),
}),
}
}
async fn fetch_evidence(
&self,
asset: &pointlock_ir::AssetRef,
) -> Result<EvidenceStream, ProviderError> {
self.ensure_active("fetchEvidence")?;
let _ = asset_ref_to_wire(asset);
Err(ProviderError::new(
ErrorClass::ActionFailedFinal,
format!(
"fetchEvidence is unsupported by the DeviceRail wire surface: no byte channel \
exists for asset URI `{}` (devicerail:// assets are daemon-internal; \
devicerail-client has no fetch API)",
asset.uri
),
RetryableSource::Classifier,
))
}
async fn record_verdict(&self, verdict: VerdictWrite) -> Result<(), ProviderError> {
self.ensure_active("recordVerdict")?;
let summary_chars = verdict.summary.chars().count();
if summary_chars > VERDICT_SUMMARY_MAX_CHARS {
return Err(ProviderError::new(
ErrorClass::BindArgumentsInvalid,
format!(
"verdict summary is {summary_chars} chars; the wire cap is \
{VERDICT_SUMMARY_MAX_CHARS} (fail-closed, 04 §5)"
),
RetryableSource::Classifier,
));
}
if verdict.evidence.len() > VERDICT_EVIDENCE_MAX_ENTRIES {
return Err(ProviderError::new(
ErrorClass::BindArgumentsInvalid,
format!(
"verdict cites {} evidence entries; the wire cap is \
{VERDICT_EVIDENCE_MAX_ENTRIES} (fail-closed, 04 §5)",
verdict.evidence.len()
),
RetryableSource::Classifier,
));
}
Self::verdict_record_gate(self.feature_enabled(feature::VERDICT_RECORD_V1))?;
let params = VerdictRecordParams {
verdict: WireVerdict {
status: match verdict.status {
VerdictStatus::Pass => WireVerdictStatus::Pass,
VerdictStatus::Fail => WireVerdictStatus::Fail,
VerdictStatus::Unknown => WireVerdictStatus::Unknown,
},
summary: verdict.summary,
evidence: verdict.evidence.iter().map(asset_ref_to_wire).collect(),
},
};
bounded(
self.client
.call::<methods::VerdictRecord>(params, CallOptions::default()),
)
.await
.map(|_receipt| ())
.map_err(|error| error.into_provider_error("verdict.record"))
}
async fn current_cursor(&self) -> Result<EventCursor, ProviderError> {
self.ensure_active("currentCursor")?;
let from = self.state().watermark;
let latest = latest_sequence(from, |after| self.events_page(after))
.await
.map_err(|error| error.into_provider_error("events.list"))?;
if let Some(sequence) = latest {
self.state().watermark = Some(sequence);
}
Ok(EventCursor {
session_id: self.session_id.to_string(),
last_sequence: latest.map(EventSequence::get).unwrap_or(0),
})
}
async fn health(&self) -> Result<SessionHealth, ProviderError> {
let (ended, degraded) = {
let state = self.state();
(state.ended, state.degraded.clone())
};
if ended {
return Ok(SessionHealth {
ok: false,
degraded,
});
}
let probe = bounded(
self.client
.call::<methods::SessionCurrent>(methods::NoParams, CallOptions::default()),
)
.await;
Ok(match probe {
Ok(current) if current.session_id == self.session_id => {
SessionHealth { ok: true, degraded }
}
Ok(current) => SessionHealth {
ok: false,
degraded: Some(format!(
"daemon's active session {} is not the bound session {}",
current.session_id, self.session_id
)),
},
Err(error) => SessionHealth {
ok: false,
degraded: Some(error.to_string()),
},
})
}
async fn end(
&self,
outcome: SessionOutcome,
reason: Option<String>,
) -> Result<(), ProviderError> {
{
let mut state = self.state();
if state.ended {
return Ok(());
}
state.ended = true;
}
let params = SessionEndParams {
outcome: Some(match outcome {
SessionOutcome::Completed => WireSessionOutcome::Completed,
SessionOutcome::Failed => WireSessionOutcome::Failed,
SessionOutcome::Cancelled => WireSessionOutcome::Cancelled,
SessionOutcome::Shutdown => WireSessionOutcome::Shutdown,
}),
reason,
};
let _ = bounded(
self.client
.call::<methods::SessionEnd>(Some(params), CallOptions::default()),
)
.await;
let _ = bounded(self.client.call::<methods::DeviceDisconnect>(
methods::NoParams,
envelope_options(DEFAULT_CALL_BUDGET_MS),
))
.await;
let _ = self.client.close().await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use pointlock_ir::ActionName;
use serde_json::json;
fn call(action_ms: Option<u64>, request_ms: Option<u64>) -> BoundActionCall {
BoundActionCall {
call_id: Uuid::new_v4().to_string(),
action_name: ActionName::new("tap").unwrap(),
arguments: json!({}),
action_timeout_ms: action_ms,
request_timeout_ms: request_ms,
}
}
#[test]
fn record_verdict_without_the_feature_is_capability_drift() {
let error = DeviceRailSession::verdict_record_gate(false).expect_err("drift");
assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
assert!(error.message.contains("verdict.record.v1"));
DeviceRailSession::verdict_record_gate(true).expect("negotiated");
}
#[test]
fn envelope_budget_is_derived_from_the_action_budget() {
let (action, envelope) =
DeviceRailSession::execute_budgets(&call(Some(15_000), None)).unwrap();
assert_eq!(action, Some(15_000));
assert_eq!(envelope, Some(20_000));
let (action, envelope) =
DeviceRailSession::execute_budgets(&call(Some(1_000), Some(30_000))).unwrap();
assert_eq!(action, Some(1_000));
assert_eq!(envelope, Some(30_000));
let (action, envelope) = DeviceRailSession::execute_budgets(&call(None, None)).unwrap();
assert_eq!(action, None);
assert_eq!(envelope, None);
}
#[test]
fn budget_invariant_one_fails_closed() {
for (action, envelope) in [(5_000, 5_000), (6_000, 5_000)] {
let error = DeviceRailSession::execute_budgets(&call(Some(action), Some(envelope)))
.expect_err("invariant 1");
assert_eq!(error.error_class, ErrorClass::BindArgumentsInvalid);
}
}
}