use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const GLASS_PROTOCOL_VERSION: u32 = 1;
const MAX_ID_BYTES: usize = 128;
const MAX_OPERATION_BYTES: usize = 96;
const MAX_ERROR_CODE_BYTES: usize = 64;
const MAX_MESSAGE_BYTES: usize = 512;
const MAX_DEADLINE_MS: u64 = 15 * 60 * 1_000;
pub const TASK_COMPILE_OPERATION: &str = "task.compile";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TaskCompilePayload {
pub task: crate::task_protocol::GlassTask,
}
impl TaskCompilePayload {
pub fn validate(&self) -> Result<(), ProtocolError> {
self.task
.validate()
.map_err(|error| ProtocolError::InvalidField(error.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TaskCompileResult {
pub plan: crate::task_compiler::TaskExecutionPlan,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TaskValidationResult {
pub valid: bool,
pub schema_version: u32,
pub task: crate::task_protocol::TaskKind,
}
impl TaskCompileResult {
pub fn validate(&self) -> Result<(), ProtocolError> {
self.plan
.validate()
.map_err(|error| ProtocolError::InvalidField(error.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MutationLeaseRef {
pub session_id: String,
pub token: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GlassRequest {
pub protocol_version: u32,
pub request_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub correlation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mutation_lease: Option<MutationLeaseRef>,
pub operation: String,
pub payload: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub deadline_ms: Option<u64>,
}
impl GlassRequest {
pub fn validate(&self) -> Result<(), ProtocolError> {
if self.protocol_version != GLASS_PROTOCOL_VERSION {
return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
}
validate_identifier(&self.request_id, "requestId")?;
if let Some(correlation_id) = &self.correlation_id {
validate_identifier(correlation_id, "correlationId")?;
}
if let Some(session_id) = &self.session_id {
validate_identifier(session_id, "sessionId")?;
}
if let Some(lease) = &self.mutation_lease {
validate_identifier(&lease.session_id, "mutationLease.sessionId")?;
validate_identifier(&lease.token, "mutationLease.token")?;
}
if self.operation.is_empty() || self.operation.len() > MAX_OPERATION_BYTES {
return Err(ProtocolError::InvalidField(
"operation must be a bounded non-empty string".into(),
));
}
if self.operation.chars().any(char::is_whitespace) {
return Err(ProtocolError::InvalidField(
"operation must not contain whitespace".into(),
));
}
if let Some(deadline_ms) = self.deadline_ms
&& !(1..=MAX_DEADLINE_MS).contains(&deadline_ms)
{
return Err(ProtocolError::InvalidField(format!(
"deadlineMs must be 1..={MAX_DEADLINE_MS}"
)));
}
Ok(())
}
pub fn decode_task_compile(&self) -> Result<TaskCompilePayload, ProtocolError> {
self.validate()?;
if self.operation != TASK_COMPILE_OPERATION {
return Err(ProtocolError::InvalidField(format!(
"expected operation {TASK_COMPILE_OPERATION}"
)));
}
let payload: TaskCompilePayload =
serde_json::from_value(self.payload.clone()).map_err(|error| {
ProtocolError::InvalidField(format!("task.compile payload: {error}"))
})?;
payload.validate()?;
Ok(payload)
}
}
pub fn compile_task_request(
request: &GlassRequest,
) -> Result<crate::task_compiler::TaskExecutionPlan, ProtocolError> {
let payload = request.decode_task_compile()?;
crate::task_compiler::compile_task(&payload.task)
.map_err(|error| ProtocolError::InvalidField(error.to_string()))
}
pub fn compile_task_result(request: &GlassRequest) -> Result<TaskCompileResult, ProtocolError> {
Ok(TaskCompileResult {
plan: compile_task_request(request)?,
})
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GlassResponse {
pub protocol_version: u32,
pub request_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub correlation_id: Option<String>,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<GlassError>,
}
impl GlassResponse {
pub fn validate(&self) -> Result<(), ProtocolError> {
if self.protocol_version != GLASS_PROTOCOL_VERSION {
return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
}
validate_identifier(&self.request_id, "requestId")?;
if let Some(correlation_id) = &self.correlation_id {
validate_identifier(correlation_id, "correlationId")?;
}
match (self.ok, self.result.is_some(), self.error.is_some()) {
(true, true, false) | (false, false, true) => Ok(()),
_ => Err(ProtocolError::InvalidField(
"ok responses require result and error responses require error".into(),
)),
}
}
pub fn decode_task_compile_result(&self) -> Result<TaskCompileResult, ProtocolError> {
self.validate()?;
if !self.ok {
return Err(ProtocolError::InvalidField(
"task.compile result requires a successful response".into(),
));
}
let value = self
.result
.clone()
.ok_or_else(|| ProtocolError::InvalidField("task.compile result is missing".into()))?;
let result: TaskCompileResult = serde_json::from_value(value).map_err(|error| {
ProtocolError::InvalidField(format!("task.compile result: {error}"))
})?;
result.validate()?;
Ok(result)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ErrorPhase {
#[default]
Preflight,
Dispatch,
PostDispatch,
Verification,
Reconciliation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum RetryClassification {
SafeImmediate,
#[default]
SafeAfterReobserve,
SafeAfterReconcile,
UnsafeUntilReconciled,
RequiresUserDecision,
NotRetryable,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RetryGuidance {
pub classification: RetryClassification,
pub recommended_operation: String,
}
impl Default for RetryGuidance {
fn default() -> Self {
Self {
classification: RetryClassification::SafeAfterReobserve,
recommended_operation: "inspect_page".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GlassError {
pub code: String,
#[serde(default)]
pub phase: ErrorPhase,
pub message: String,
#[serde(default)]
pub mutation_possible: bool,
#[serde(default)]
pub retry: RetryGuidance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retryable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl GlassError {
pub fn validate(&self) -> Result<(), ProtocolError> {
if self.code.is_empty() || self.code.len() > MAX_ERROR_CODE_BYTES {
return Err(ProtocolError::InvalidField(
"error code must be a bounded non-empty string".into(),
));
}
if self.message.is_empty() || self.message.len() > MAX_MESSAGE_BYTES {
return Err(ProtocolError::InvalidField(
"error message must be a bounded non-empty string".into(),
));
}
validate_identifier(
&self.retry.recommended_operation,
"retry.recommendedOperation",
)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProtocolError {
UnsupportedVersion(u32),
InvalidField(String),
}
impl std::fmt::Display for ProtocolError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedVersion(version) => {
write!(formatter, "unsupported Glass protocol version {version}")
}
Self::InvalidField(detail) => formatter.write_str(detail),
}
}
}
impl std::error::Error for ProtocolError {}
fn validate_identifier(value: &str, field: &str) -> Result<(), ProtocolError> {
if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_whitespace) {
return Err(ProtocolError::InvalidField(format!(
"{field} must be a bounded non-whitespace identifier"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn request() -> GlassRequest {
GlassRequest {
protocol_version: GLASS_PROTOCOL_VERSION,
request_id: "request-1".into(),
correlation_id: Some("run-1".into()),
session_id: Some("session-1".into()),
mutation_lease: Some(MutationLeaseRef {
session_id: "session-1".into(),
token: "lease-1".into(),
}),
operation: "browser.observe".into(),
payload: serde_json::json!({"level": "interactive"}),
deadline_ms: Some(5_000),
}
}
#[test]
fn request_round_trips_and_validates() {
let request = request();
request.validate().unwrap();
let value = serde_json::to_value(&request).unwrap();
assert_eq!(value["protocolVersion"], 1);
assert_eq!(value["mutationLease"]["sessionId"], "session-1");
let decoded: GlassRequest = serde_json::from_value(value).unwrap();
assert_eq!(decoded, request);
}
#[test]
fn response_requires_exactly_one_outcome() {
let response = GlassResponse {
protocol_version: GLASS_PROTOCOL_VERSION,
request_id: "request-1".into(),
correlation_id: None,
ok: false,
result: None,
error: Some(GlassError {
code: "target.stale".into(),
phase: ErrorPhase::Preflight,
message: "a mutation lease is required".into(),
mutation_possible: false,
retry: RetryGuidance {
classification: RetryClassification::SafeAfterReobserve,
recommended_operation: "inspect_page".into(),
},
retryable: Some(true),
details: None,
}),
};
response.validate().unwrap();
let mut invalid = response.clone();
invalid.ok = true;
assert!(invalid.validate().is_err());
}
#[test]
fn bounds_and_unknown_fields_fail_closed() {
let mut request = request();
request.operation = "bad operation".into();
assert!(request.validate().is_err());
let unknown = serde_json::json!({
"protocolVersion": 1,
"requestId": "request-1",
"operation": "browser.observe",
"payload": {},
"future": true
});
assert!(serde_json::from_value::<GlassRequest>(unknown).is_err());
}
#[test]
fn task_compile_boundary_decodes_and_compiles_without_browser_state() {
let task = serde_json::json!({
"schemaVersion": 1,
"task": "region.extract",
"scope": {"regionName": "Checkout"},
"limits": {"maxActions": 8, "timeoutMs": 5000, "maxItems": 32},
"risk": "readOnly"
});
let request = GlassRequest {
protocol_version: GLASS_PROTOCOL_VERSION,
request_id: "compile-1".into(),
correlation_id: None,
session_id: None,
mutation_lease: None,
operation: TASK_COMPILE_OPERATION.into(),
payload: serde_json::json!({"task": task}),
deadline_ms: None,
};
let plan = compile_task_request(&request).unwrap();
assert_eq!(plan.task, crate::task_protocol::TaskKind::RegionExtract);
assert_eq!(plan.scope.region_name.as_deref(), Some("Checkout"));
assert_eq!(plan.limits.max_actions, 8);
assert_eq!(
plan.revision,
crate::task_protocol::TaskRevisionPolicy::Exact
);
let mut wrong_operation = request.clone();
wrong_operation.operation = "browser.observe".into();
assert!(wrong_operation.decode_task_compile().is_err());
let mut unknown = request.clone();
unknown.payload["futureField"] = true.into();
assert!(unknown.decode_task_compile().is_err());
let mut invalid = request;
invalid.payload["task"]["task"] = "form.fill".into();
assert!(compile_task_request(&invalid).is_err());
}
#[test]
fn task_compile_result_round_trips_through_success_response() {
let request = GlassRequest {
protocol_version: GLASS_PROTOCOL_VERSION,
request_id: "compile-2".into(),
correlation_id: None,
session_id: None,
mutation_lease: None,
operation: TASK_COMPILE_OPERATION.into(),
payload: serde_json::json!({
"task": {
"schemaVersion": 1,
"task": "field.read",
"scope": {"entityKind": "field", "entityName": "Email"},
"limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 1},
"risk": "readOnly"
}
}),
deadline_ms: None,
};
let result = compile_task_result(&request).unwrap();
let response = GlassResponse {
protocol_version: GLASS_PROTOCOL_VERSION,
request_id: request.request_id.clone(),
correlation_id: None,
ok: true,
result: Some(serde_json::to_value(&result).unwrap()),
error: None,
};
assert_eq!(response.decode_task_compile_result().unwrap(), result);
let mut unknown = response.clone();
unknown.result.as_mut().unwrap()["futureField"] = true.into();
assert!(unknown.decode_task_compile_result().is_err());
let mut failure = response;
failure.ok = false;
failure.result = None;
failure.error = Some(GlassError {
code: "task.invalid".into(),
phase: ErrorPhase::Preflight,
message: "invalid task".into(),
mutation_possible: false,
retry: RetryGuidance::default(),
retryable: None,
details: None,
});
assert!(failure.decode_task_compile_result().is_err());
}
#[test]
fn additive_response_fields_are_tolerated() {
let response: GlassResponse = serde_json::from_value(serde_json::json!({
"protocolVersion": 1,
"requestId": "request-1",
"ok": false,
"error": {
"code": "target.stale",
"message": "stale",
"retryable": true,
"future": "ignored"
},
"future": true
}))
.unwrap();
assert_eq!(
response.error.unwrap().retry.classification,
RetryClassification::SafeAfterReobserve
);
}
}