1use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11pub const GLASS_PROTOCOL_VERSION: u32 = 1;
13const MAX_ID_BYTES: usize = 128;
14const MAX_OPERATION_BYTES: usize = 96;
15const MAX_ERROR_CODE_BYTES: usize = 64;
16const MAX_MESSAGE_BYTES: usize = 512;
17const MAX_DEADLINE_MS: u64 = 15 * 60 * 1_000;
18
19pub const TASK_COMPILE_OPERATION: &str = "task.compile";
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct TaskCompilePayload {
26 pub task: crate::task_protocol::GlassTask,
27}
28
29impl TaskCompilePayload {
30 pub fn validate(&self) -> Result<(), ProtocolError> {
32 self.task
33 .validate()
34 .map_err(|error| ProtocolError::InvalidField(error.to_string()))
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct TaskCompileResult {
42 pub plan: crate::task_compiler::TaskExecutionPlan,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase", deny_unknown_fields)]
48pub struct TaskValidationResult {
49 pub valid: bool,
50 pub schema_version: u32,
51 pub task: crate::task_protocol::TaskKind,
52}
53
54impl TaskCompileResult {
55 pub fn validate(&self) -> Result<(), ProtocolError> {
57 self.plan
58 .validate()
59 .map_err(|error| ProtocolError::InvalidField(error.to_string()))
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct MutationLeaseRef {
67 pub session_id: String,
68 pub token: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase", deny_unknown_fields)]
74pub struct GlassRequest {
75 pub protocol_version: u32,
76 pub request_id: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub correlation_id: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub session_id: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub mutation_lease: Option<MutationLeaseRef>,
83 pub operation: String,
84 pub payload: Value,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub deadline_ms: Option<u64>,
87}
88
89impl GlassRequest {
90 pub fn validate(&self) -> Result<(), ProtocolError> {
92 if self.protocol_version != GLASS_PROTOCOL_VERSION {
93 return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
94 }
95 validate_identifier(&self.request_id, "requestId")?;
96 if let Some(correlation_id) = &self.correlation_id {
97 validate_identifier(correlation_id, "correlationId")?;
98 }
99 if let Some(session_id) = &self.session_id {
100 validate_identifier(session_id, "sessionId")?;
101 }
102 if let Some(lease) = &self.mutation_lease {
103 validate_identifier(&lease.session_id, "mutationLease.sessionId")?;
104 validate_identifier(&lease.token, "mutationLease.token")?;
105 }
106 if self.operation.is_empty() || self.operation.len() > MAX_OPERATION_BYTES {
107 return Err(ProtocolError::InvalidField(
108 "operation must be a bounded non-empty string".into(),
109 ));
110 }
111 if self.operation.chars().any(char::is_whitespace) {
112 return Err(ProtocolError::InvalidField(
113 "operation must not contain whitespace".into(),
114 ));
115 }
116 if let Some(deadline_ms) = self.deadline_ms
117 && !(1..=MAX_DEADLINE_MS).contains(&deadline_ms)
118 {
119 return Err(ProtocolError::InvalidField(format!(
120 "deadlineMs must be 1..={MAX_DEADLINE_MS}"
121 )));
122 }
123 Ok(())
124 }
125
126 pub fn decode_task_compile(&self) -> Result<TaskCompilePayload, ProtocolError> {
128 self.validate()?;
129 if self.operation != TASK_COMPILE_OPERATION {
130 return Err(ProtocolError::InvalidField(format!(
131 "expected operation {TASK_COMPILE_OPERATION}"
132 )));
133 }
134 let payload: TaskCompilePayload =
135 serde_json::from_value(self.payload.clone()).map_err(|error| {
136 ProtocolError::InvalidField(format!("task.compile payload: {error}"))
137 })?;
138 payload.validate()?;
139 Ok(payload)
140 }
141}
142
143pub fn compile_task_request(
145 request: &GlassRequest,
146) -> Result<crate::task_compiler::TaskExecutionPlan, ProtocolError> {
147 let payload = request.decode_task_compile()?;
148 crate::task_compiler::compile_task(&payload.task)
149 .map_err(|error| ProtocolError::InvalidField(error.to_string()))
150}
151
152pub fn compile_task_result(request: &GlassRequest) -> Result<TaskCompileResult, ProtocolError> {
154 Ok(TaskCompileResult {
155 plan: compile_task_request(request)?,
156 })
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct GlassResponse {
163 pub protocol_version: u32,
164 pub request_id: String,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub correlation_id: Option<String>,
167 pub ok: bool,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub result: Option<Value>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub error: Option<GlassError>,
172}
173
174impl GlassResponse {
175 pub fn validate(&self) -> Result<(), ProtocolError> {
177 if self.protocol_version != GLASS_PROTOCOL_VERSION {
178 return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
179 }
180 validate_identifier(&self.request_id, "requestId")?;
181 if let Some(correlation_id) = &self.correlation_id {
182 validate_identifier(correlation_id, "correlationId")?;
183 }
184 match (self.ok, self.result.is_some(), self.error.is_some()) {
185 (true, true, false) | (false, false, true) => Ok(()),
186 _ => Err(ProtocolError::InvalidField(
187 "ok responses require result and error responses require error".into(),
188 )),
189 }
190 }
191
192 pub fn decode_task_compile_result(&self) -> Result<TaskCompileResult, ProtocolError> {
194 self.validate()?;
195 if !self.ok {
196 return Err(ProtocolError::InvalidField(
197 "task.compile result requires a successful response".into(),
198 ));
199 }
200 let value = self
201 .result
202 .clone()
203 .ok_or_else(|| ProtocolError::InvalidField("task.compile result is missing".into()))?;
204 let result: TaskCompileResult = serde_json::from_value(value).map_err(|error| {
205 ProtocolError::InvalidField(format!("task.compile result: {error}"))
206 })?;
207 result.validate()?;
208 Ok(result)
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
214#[serde(rename_all = "camelCase")]
215pub enum ErrorPhase {
216 #[default]
217 Preflight,
218 Dispatch,
219 PostDispatch,
220 Verification,
221 Reconciliation,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
226#[serde(rename_all = "camelCase")]
227pub enum RetryClassification {
228 SafeImmediate,
229 #[default]
230 SafeAfterReobserve,
231 SafeAfterReconcile,
232 UnsafeUntilReconciled,
233 RequiresUserDecision,
234 NotRetryable,
235 Unknown,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct RetryGuidance {
242 pub classification: RetryClassification,
243 pub recommended_operation: String,
244}
245
246impl Default for RetryGuidance {
247 fn default() -> Self {
248 Self {
249 classification: RetryClassification::SafeAfterReobserve,
250 recommended_operation: "inspect_page".into(),
251 }
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct GlassError {
259 pub code: String,
260 #[serde(default)]
261 pub phase: ErrorPhase,
262 pub message: String,
263 #[serde(default)]
264 pub mutation_possible: bool,
265 #[serde(default)]
266 pub retry: RetryGuidance,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub retryable: Option<bool>,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 pub details: Option<Value>,
272}
273
274impl GlassError {
275 pub fn validate(&self) -> Result<(), ProtocolError> {
277 if self.code.is_empty() || self.code.len() > MAX_ERROR_CODE_BYTES {
278 return Err(ProtocolError::InvalidField(
279 "error code must be a bounded non-empty string".into(),
280 ));
281 }
282 if self.message.is_empty() || self.message.len() > MAX_MESSAGE_BYTES {
283 return Err(ProtocolError::InvalidField(
284 "error message must be a bounded non-empty string".into(),
285 ));
286 }
287 validate_identifier(
288 &self.retry.recommended_operation,
289 "retry.recommendedOperation",
290 )?;
291 Ok(())
292 }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum ProtocolError {
298 UnsupportedVersion(u32),
299 InvalidField(String),
300}
301
302impl std::fmt::Display for ProtocolError {
303 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304 match self {
305 Self::UnsupportedVersion(version) => {
306 write!(formatter, "unsupported Glass protocol version {version}")
307 }
308 Self::InvalidField(detail) => formatter.write_str(detail),
309 }
310 }
311}
312
313impl std::error::Error for ProtocolError {}
314
315fn validate_identifier(value: &str, field: &str) -> Result<(), ProtocolError> {
316 if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_whitespace) {
317 return Err(ProtocolError::InvalidField(format!(
318 "{field} must be a bounded non-whitespace identifier"
319 )));
320 }
321 Ok(())
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn request() -> GlassRequest {
329 GlassRequest {
330 protocol_version: GLASS_PROTOCOL_VERSION,
331 request_id: "request-1".into(),
332 correlation_id: Some("run-1".into()),
333 session_id: Some("session-1".into()),
334 mutation_lease: Some(MutationLeaseRef {
335 session_id: "session-1".into(),
336 token: "lease-1".into(),
337 }),
338 operation: "browser.observe".into(),
339 payload: serde_json::json!({"level": "interactive"}),
340 deadline_ms: Some(5_000),
341 }
342 }
343
344 #[test]
345 fn request_round_trips_and_validates() {
346 let request = request();
347 request.validate().unwrap();
348 let value = serde_json::to_value(&request).unwrap();
349 assert_eq!(value["protocolVersion"], 1);
350 assert_eq!(value["mutationLease"]["sessionId"], "session-1");
351 let decoded: GlassRequest = serde_json::from_value(value).unwrap();
352 assert_eq!(decoded, request);
353 }
354
355 #[test]
356 fn response_requires_exactly_one_outcome() {
357 let response = GlassResponse {
358 protocol_version: GLASS_PROTOCOL_VERSION,
359 request_id: "request-1".into(),
360 correlation_id: None,
361 ok: false,
362 result: None,
363 error: Some(GlassError {
364 code: "target.stale".into(),
365 phase: ErrorPhase::Preflight,
366 message: "a mutation lease is required".into(),
367 mutation_possible: false,
368 retry: RetryGuidance {
369 classification: RetryClassification::SafeAfterReobserve,
370 recommended_operation: "inspect_page".into(),
371 },
372 retryable: Some(true),
373 details: None,
374 }),
375 };
376 response.validate().unwrap();
377 let mut invalid = response.clone();
378 invalid.ok = true;
379 assert!(invalid.validate().is_err());
380 }
381
382 #[test]
383 fn bounds_and_unknown_fields_fail_closed() {
384 let mut request = request();
385 request.operation = "bad operation".into();
386 assert!(request.validate().is_err());
387 let unknown = serde_json::json!({
388 "protocolVersion": 1,
389 "requestId": "request-1",
390 "operation": "browser.observe",
391 "payload": {},
392 "future": true
393 });
394 assert!(serde_json::from_value::<GlassRequest>(unknown).is_err());
395 }
396
397 #[test]
398 fn task_compile_boundary_decodes_and_compiles_without_browser_state() {
399 let task = serde_json::json!({
400 "schemaVersion": 1,
401 "task": "region.extract",
402 "scope": {"regionName": "Checkout"},
403 "limits": {"maxActions": 8, "timeoutMs": 5000, "maxItems": 32},
404 "risk": "readOnly"
405 });
406 let request = GlassRequest {
407 protocol_version: GLASS_PROTOCOL_VERSION,
408 request_id: "compile-1".into(),
409 correlation_id: None,
410 session_id: None,
411 mutation_lease: None,
412 operation: TASK_COMPILE_OPERATION.into(),
413 payload: serde_json::json!({"task": task}),
414 deadline_ms: None,
415 };
416 let plan = compile_task_request(&request).unwrap();
417 assert_eq!(plan.task, crate::task_protocol::TaskKind::RegionExtract);
418 assert_eq!(plan.scope.region_name.as_deref(), Some("Checkout"));
419 assert_eq!(plan.limits.max_actions, 8);
420 assert_eq!(
421 plan.revision,
422 crate::task_protocol::TaskRevisionPolicy::Exact
423 );
424
425 let mut wrong_operation = request.clone();
426 wrong_operation.operation = "browser.observe".into();
427 assert!(wrong_operation.decode_task_compile().is_err());
428
429 let mut unknown = request.clone();
430 unknown.payload["futureField"] = true.into();
431 assert!(unknown.decode_task_compile().is_err());
432
433 let mut invalid = request;
434 invalid.payload["task"]["task"] = "form.fill".into();
435 assert!(compile_task_request(&invalid).is_err());
436 }
437
438 #[test]
439 fn task_compile_result_round_trips_through_success_response() {
440 let request = GlassRequest {
441 protocol_version: GLASS_PROTOCOL_VERSION,
442 request_id: "compile-2".into(),
443 correlation_id: None,
444 session_id: None,
445 mutation_lease: None,
446 operation: TASK_COMPILE_OPERATION.into(),
447 payload: serde_json::json!({
448 "task": {
449 "schemaVersion": 1,
450 "task": "field.read",
451 "scope": {"entityKind": "field", "entityName": "Email"},
452 "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 1},
453 "risk": "readOnly"
454 }
455 }),
456 deadline_ms: None,
457 };
458 let result = compile_task_result(&request).unwrap();
459 let response = GlassResponse {
460 protocol_version: GLASS_PROTOCOL_VERSION,
461 request_id: request.request_id.clone(),
462 correlation_id: None,
463 ok: true,
464 result: Some(serde_json::to_value(&result).unwrap()),
465 error: None,
466 };
467 assert_eq!(response.decode_task_compile_result().unwrap(), result);
468
469 let mut unknown = response.clone();
470 unknown.result.as_mut().unwrap()["futureField"] = true.into();
471 assert!(unknown.decode_task_compile_result().is_err());
472
473 let mut failure = response;
474 failure.ok = false;
475 failure.result = None;
476 failure.error = Some(GlassError {
477 code: "task.invalid".into(),
478 phase: ErrorPhase::Preflight,
479 message: "invalid task".into(),
480 mutation_possible: false,
481 retry: RetryGuidance::default(),
482 retryable: None,
483 details: None,
484 });
485 assert!(failure.decode_task_compile_result().is_err());
486 }
487
488 #[test]
489 fn additive_response_fields_are_tolerated() {
490 let response: GlassResponse = serde_json::from_value(serde_json::json!({
491 "protocolVersion": 1,
492 "requestId": "request-1",
493 "ok": false,
494 "error": {
495 "code": "target.stale",
496 "message": "stale",
497 "retryable": true,
498 "future": "ignored"
499 },
500 "future": true
501 }))
502 .unwrap();
503 assert_eq!(
504 response.error.unwrap().retry.classification,
505 RetryClassification::SafeAfterReobserve
506 );
507 }
508}