use std::pin::Pin;
use async_trait::async_trait;
use futures_core::Stream;
use pointlock_ir::{
ActionName, ActionOutcome, AssetRef, ErrorClass, EventCursor, FeatureId, Hash, Observation,
ReconcileResult, UiSnapshotOmissionReason, VerdictStatus,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use tokio_util::sync::CancellationToken;
use crate::error::{ProviderError, RetryableSource};
use crate::lockfile::CapabilityAttestation;
use crate::manifest::ProviderManifest;
pub const VERDICT_SUMMARY_MAX_CHARS: usize = 16384;
pub const VERDICT_EVIDENCE_MAX_ENTRIES: usize = 64;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct OpenSessionOptions {
pub endpoint: Value,
pub device_id: String,
pub required_features: Vec<FeatureId>,
pub lockfile_digest: Hash,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BoundActionCall {
pub call_id: String,
pub action_name: ActionName,
pub arguments: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub action_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_timeout_ms: Option<u64>,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "camelCase")]
pub enum ObserveWant {
Screenshot,
UiSnapshot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ObserveRequest {
pub wants: Vec<ObserveWant>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum UiSnapshotOutcome {
Available {
snapshot: Value,
},
Unavailable {
reason: UiSnapshotOmissionReason,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct VerdictWrite {
pub status: VerdictStatus,
pub summary: String,
pub evidence: Vec<AssetRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SessionHealth {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub degraded: Option<String>,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "camelCase")]
pub enum SessionOutcome {
Completed,
Failed,
Cancelled,
Shutdown,
}
pub type EvidenceStream = Pin<Box<dyn Stream<Item = Result<Vec<u8>, ProviderError>> + Send>>;
#[async_trait]
pub trait Provider: Send + Sync {
fn manifest(&self) -> &ProviderManifest;
async fn open_session(
&self,
opts: OpenSessionOptions,
) -> Result<Box<dyn ProviderSession>, ProviderError>;
}
#[async_trait]
pub trait ProviderSession: Send + Sync {
fn attestation(&self) -> &CapabilityAttestation;
async fn execute(
&self,
call: BoundActionCall,
cancel: Option<CancellationToken>,
) -> Result<ActionOutcome, ProviderError>;
async fn observe(
&self,
req: ObserveRequest,
cancel: Option<CancellationToken>,
) -> Result<Observation, ProviderError>;
async fn ui_snapshot(&self, observation_id: &str) -> Result<UiSnapshotOutcome, ProviderError>;
async fn reconcile(
&self,
call_id: &str,
issuing: &EventCursor,
) -> Result<ReconcileResult, ProviderError>;
async fn fetch_evidence(&self, asset: &AssetRef) -> Result<EvidenceStream, ProviderError>;
async fn record_verdict(&self, verdict: VerdictWrite) -> Result<(), ProviderError>;
async fn current_cursor(&self) -> Result<EventCursor, ProviderError>;
async fn health(&self) -> Result<SessionHealth, ProviderError>;
async fn end(
&self,
outcome: SessionOutcome,
reason: Option<String>,
) -> Result<(), ProviderError>;
}
pub fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
pub fn synthetic_observation_wants(
call: &BoundActionCall,
) -> Result<Option<Vec<ObserveWant>>, ProviderError> {
let invalid = |detail: String| {
ProviderError::new(
ErrorClass::BindArgumentsInvalid,
detail,
RetryableSource::Classifier,
)
};
match call.action_name.as_str() {
"screenshot" => Ok(Some(vec![ObserveWant::Screenshot])),
"observe" => {
let raw = call
.arguments
.get("wants")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| invalid("`observe` requires a `wants` array".to_owned()))?;
let mut wants = Vec::with_capacity(raw.len());
for part in raw {
match part.as_str() {
Some("screenshot") => wants.push(ObserveWant::Screenshot),
Some("uiSnapshot") => wants.push(ObserveWant::UiSnapshot),
other => {
return Err(invalid(format!(
"`observe` wants an entry outside the closed vocabulary: {other:?}"
)));
}
}
}
if wants.is_empty() {
return Err(invalid("`observe` needs at least one part".to_owned()));
}
Ok(Some(wants))
}
_ => Ok(None),
}
}
pub fn observation_projection(
observation: &Observation,
wants: &[ObserveWant],
) -> serde_json::Value {
let mut out = serde_json::Map::new();
out.insert(
"observationId".to_owned(),
serde_json::Value::String(observation.id.clone()),
);
if wants.contains(&ObserveWant::Screenshot) {
if let Some(asset) = &observation.screenshot {
out.insert(
"screenshot".to_owned(),
serde_json::to_value(asset).unwrap_or(serde_json::Value::Null),
);
}
if let Some(reason) = observation.screenshot_omission {
out.insert(
"screenshotOmission".to_owned(),
serde_json::to_value(reason).unwrap_or(serde_json::Value::Null),
);
}
}
if wants.contains(&ObserveWant::UiSnapshot) {
if let Some(snapshot) = &observation.ui_snapshot {
out.insert(
"uiSnapshot".to_owned(),
serde_json::to_value(snapshot).unwrap_or(serde_json::Value::Null),
);
}
if let Some(reason) = observation.ui_snapshot_omission {
out.insert(
"uiSnapshotOmission".to_owned(),
serde_json::to_value(reason).unwrap_or(serde_json::Value::Null),
);
}
}
serde_json::Value::Object(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn bound_action_call_wire_shape() {
let call = BoundActionCall {
call_id: "4a1f2c9e-0000-4000-8000-000000000001".to_owned(),
action_name: ActionName::new("tapElement").unwrap(),
arguments: json!({ "element": { "byText": "OK" } }),
action_timeout_ms: Some(5000),
request_timeout_ms: None,
};
let wire = serde_json::to_value(&call).expect("serialize");
assert_eq!(wire["callId"], "4a1f2c9e-0000-4000-8000-000000000001");
assert_eq!(wire["actionName"], "tapElement");
assert_eq!(wire["actionTimeoutMs"], 5000);
assert!(wire.get("requestTimeoutMs").is_none());
let back: BoundActionCall = serde_json::from_value(wire).expect("deserialize");
assert_eq!(back, call);
}
#[test]
fn observe_want_wire_literals() {
assert_eq!(
serde_json::to_value(ObserveWant::UiSnapshot).unwrap(),
json!("uiSnapshot")
);
assert_eq!(
serde_json::to_value(ObserveWant::Screenshot).unwrap(),
json!("screenshot")
);
}
#[test]
fn session_outcome_wire_literals() {
for (outcome, literal) in [
(SessionOutcome::Completed, "completed"),
(SessionOutcome::Failed, "failed"),
(SessionOutcome::Cancelled, "cancelled"),
(SessionOutcome::Shutdown, "shutdown"),
] {
assert_eq!(serde_json::to_value(outcome).unwrap(), json!(literal));
}
}
}