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 WEB_IR_VALIDATE_OPERATION: &str = "webIr.validate";
21pub const WEB_IR_INSPECT_OPERATION: &str = "webIr.inspect";
23pub const WEB_IR_DIFF_OPERATION: &str = "webIr.diff";
25pub const WEB_IR_CONTINUITY_OPERATION: &str = "webIr.continuity";
27
28pub const TASK_COMPILE_OPERATION: &str = "task.compile";
30pub const TASK_VALIDATE_OPERATION: &str = "task.validate";
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct TaskCompilePayload {
37 pub task: crate::task_protocol::GlassTask,
38}
39
40impl TaskCompilePayload {
41 pub fn validate(&self) -> Result<(), ProtocolError> {
43 self.task
44 .validate()
45 .map_err(|error| ProtocolError::TaskCompilation(error.into()))
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct TaskValidationPayload {
53 pub task: crate::task_protocol::GlassTask,
54}
55
56impl TaskValidationPayload {
57 pub fn validate(&self) -> Result<(), ProtocolError> {
59 self.task.validate().map_err(ProtocolError::TaskValidation)
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct WebIrDraftPayload {
67 pub draft: crate::web_ir::GlassWebIrDraft,
68}
69
70impl WebIrDraftPayload {
71 pub fn validate(&self) -> Result<(), ProtocolError> {
73 self.draft
74 .validate()
75 .map_err(ProtocolError::WebIrValidation)
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82pub struct WebIrDiffPayload {
83 pub before: crate::web_ir::GlassWebIrDraft,
84 pub after: crate::web_ir::GlassWebIrDraft,
85}
86
87impl WebIrDiffPayload {
88 pub fn validate(&self) -> Result<(), ProtocolError> {
90 self.before
91 .validate()
92 .map_err(ProtocolError::WebIrValidation)?;
93 self.after
94 .validate()
95 .map_err(ProtocolError::WebIrValidation)?;
96 Ok(())
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct WebIrContinuityPayload {
104 pub before: crate::web_ir::GlassWebIrDraft,
105 pub after: crate::web_ir::GlassWebIrDraft,
106 pub entity_id: String,
107}
108
109impl WebIrContinuityPayload {
110 pub fn validate(&self) -> Result<(), ProtocolError> {
112 self.before
113 .validate()
114 .map_err(ProtocolError::WebIrValidation)?;
115 self.after
116 .validate()
117 .map_err(ProtocolError::WebIrValidation)?;
118 validate_identifier(&self.entity_id, "entityId")
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase", deny_unknown_fields)]
125pub struct TaskCompileResult {
126 pub plan: crate::task_compiler::TaskExecutionPlan,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase", deny_unknown_fields)]
132pub struct TaskValidationResult {
133 pub valid: bool,
134 pub schema_version: u32,
135 pub task: crate::task_protocol::TaskKind,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase", deny_unknown_fields)]
141pub struct WebIrValidationResult {
142 pub valid: bool,
143 pub schema_version: u32,
144 pub revision: u64,
145}
146
147impl WebIrValidationResult {
148 pub fn from_draft(draft: &crate::web_ir::GlassWebIrDraft) -> Self {
150 Self {
151 valid: true,
152 schema_version: draft.schema_version,
153 revision: draft.revision,
154 }
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase", deny_unknown_fields)]
161pub struct WebIrInspectionResult {
162 pub schema_version: u32,
163 pub revision: u64,
164 pub entity_count: usize,
165 pub relationship_count: usize,
166 pub coverage: crate::extraction::EvidenceCoverage,
167 pub truncated: bool,
168 pub opaque_regions: u32,
169 pub diagnostic_count: usize,
170 pub relationship_hint_diagnostic_count: usize,
171}
172
173impl WebIrInspectionResult {
174 pub fn from_draft(draft: &crate::web_ir::GlassWebIrDraft) -> Self {
176 Self {
177 schema_version: draft.schema_version,
178 revision: draft.revision,
179 entity_count: draft.entities.len(),
180 relationship_count: draft.relationships.len(),
181 coverage: draft.coverage.clone(),
182 truncated: draft.limits.truncated,
183 opaque_regions: draft.coverage.opaque_regions,
184 diagnostic_count: draft.diagnostics.len(),
185 relationship_hint_diagnostic_count: draft.relationship_hint_diagnostics.len(),
186 }
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct WebIrDiffResult {
194 pub schema_version: u32,
195 pub from_revision: u64,
196 pub to_revision: u64,
197 pub entity_added_count: usize,
198 pub entity_removed_count: usize,
199 pub entity_changed_count: usize,
200 pub relationship_added_count: usize,
201 pub relationship_removed_count: usize,
202 pub coverage_changed: bool,
203 pub limits_changed: bool,
204 pub diagnostics_changed: bool,
205 pub relationship_hint_diagnostics_changed: bool,
206}
207
208impl WebIrDiffResult {
209 pub fn from_diff(diff: &crate::web_ir::GlassWebIrDiff) -> Self {
211 Self {
212 schema_version: diff.schema_version,
213 from_revision: diff.from_revision,
214 to_revision: diff.to_revision,
215 entity_added_count: diff
216 .entity_changes
217 .iter()
218 .filter(|change| change.kind == crate::web_ir::DraftChangeKind::Added)
219 .count(),
220 entity_removed_count: diff
221 .entity_changes
222 .iter()
223 .filter(|change| change.kind == crate::web_ir::DraftChangeKind::Removed)
224 .count(),
225 entity_changed_count: diff
226 .entity_changes
227 .iter()
228 .filter(|change| change.kind == crate::web_ir::DraftChangeKind::Changed)
229 .count(),
230 relationship_added_count: diff
231 .relationship_changes
232 .iter()
233 .filter(|change| change.kind == crate::web_ir::DraftChangeKind::Added)
234 .count(),
235 relationship_removed_count: diff
236 .relationship_changes
237 .iter()
238 .filter(|change| change.kind == crate::web_ir::DraftChangeKind::Removed)
239 .count(),
240 coverage_changed: diff.coverage_changed,
241 limits_changed: diff.limits_changed,
242 diagnostics_changed: diff.diagnostics_changed,
243 relationship_hint_diagnostics_changed: diff.relationship_hint_diagnostics_changed,
244 }
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase", deny_unknown_fields)]
251pub struct WebIrContinuityResult {
252 pub requested_id: String,
253 pub status: crate::web_ir::DraftEntityContinuityStatus,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 pub current_id: Option<String>,
256 pub reason: String,
257}
258
259impl From<crate::web_ir::DraftEntityContinuity> for WebIrContinuityResult {
260 fn from(continuity: crate::web_ir::DraftEntityContinuity) -> Self {
261 Self {
262 requested_id: continuity.requested_id,
263 status: continuity.status,
264 current_id: continuity.current_id,
265 reason: continuity.reason,
266 }
267 }
268}
269
270impl TaskCompileResult {
271 pub fn validate(&self) -> Result<(), ProtocolError> {
273 self.plan.validate().map_err(ProtocolError::TaskCompilation)
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase", deny_unknown_fields)]
280pub struct MutationLeaseRef {
281 pub session_id: String,
282 pub token: String,
283}
284
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase", deny_unknown_fields)]
288pub struct GlassRequest {
289 pub protocol_version: u32,
290 pub request_id: String,
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub correlation_id: Option<String>,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub session_id: Option<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 pub mutation_lease: Option<MutationLeaseRef>,
297 pub operation: String,
298 pub payload: Value,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub deadline_ms: Option<u64>,
301}
302
303impl GlassRequest {
304 pub fn validate(&self) -> Result<(), ProtocolError> {
306 if self.protocol_version != GLASS_PROTOCOL_VERSION {
307 return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
308 }
309 validate_identifier(&self.request_id, "requestId")?;
310 if let Some(correlation_id) = &self.correlation_id {
311 validate_identifier(correlation_id, "correlationId")?;
312 }
313 if let Some(session_id) = &self.session_id {
314 validate_identifier(session_id, "sessionId")?;
315 }
316 if let Some(lease) = &self.mutation_lease {
317 validate_identifier(&lease.session_id, "mutationLease.sessionId")?;
318 validate_identifier(&lease.token, "mutationLease.token")?;
319 }
320 if self.operation.is_empty() || self.operation.len() > MAX_OPERATION_BYTES {
321 return Err(ProtocolError::InvalidField(
322 "operation must be a bounded non-empty string".into(),
323 ));
324 }
325 if self.operation.chars().any(char::is_whitespace) {
326 return Err(ProtocolError::InvalidField(
327 "operation must not contain whitespace".into(),
328 ));
329 }
330 if let Some(deadline_ms) = self.deadline_ms
331 && !(1..=MAX_DEADLINE_MS).contains(&deadline_ms)
332 {
333 return Err(ProtocolError::InvalidField(format!(
334 "deadlineMs must be 1..={MAX_DEADLINE_MS}"
335 )));
336 }
337 Ok(())
338 }
339
340 pub fn decode_task_compile(&self) -> Result<TaskCompilePayload, ProtocolError> {
342 self.validate()?;
343 if self.operation != TASK_COMPILE_OPERATION {
344 return Err(ProtocolError::InvalidField(format!(
345 "expected operation {TASK_COMPILE_OPERATION}"
346 )));
347 }
348 let payload: TaskCompilePayload =
349 serde_json::from_value(self.payload.clone()).map_err(|error| {
350 ProtocolError::InvalidField(format!("task.compile payload: {error}"))
351 })?;
352 payload.validate()?;
353 Ok(payload)
354 }
355
356 pub fn decode_task_validate(&self) -> Result<TaskValidationPayload, ProtocolError> {
358 self.validate()?;
359 if self.operation != TASK_VALIDATE_OPERATION {
360 return Err(ProtocolError::InvalidField(format!(
361 "expected operation {TASK_VALIDATE_OPERATION}"
362 )));
363 }
364 let payload: TaskValidationPayload =
365 serde_json::from_value(self.payload.clone()).map_err(|error| {
366 ProtocolError::InvalidField(format!("task.validate payload: {error}"))
367 })?;
368 payload.validate()?;
369 Ok(payload)
370 }
371
372 pub fn decode_web_ir_validate(&self) -> Result<WebIrDraftPayload, ProtocolError> {
374 self.decode_web_ir_draft(WEB_IR_VALIDATE_OPERATION)
375 }
376
377 pub fn decode_web_ir_inspect(&self) -> Result<WebIrDraftPayload, ProtocolError> {
379 self.decode_web_ir_draft(WEB_IR_INSPECT_OPERATION)
380 }
381
382 fn decode_web_ir_draft(&self, operation: &str) -> Result<WebIrDraftPayload, ProtocolError> {
383 self.validate()?;
384 if self.operation != operation {
385 return Err(ProtocolError::InvalidField(format!(
386 "expected operation {operation}"
387 )));
388 }
389 let payload: WebIrDraftPayload =
390 serde_json::from_value(self.payload.clone()).map_err(|error| {
391 ProtocolError::InvalidField(format!("{operation} payload: {error}"))
392 })?;
393 payload.validate()?;
394 Ok(payload)
395 }
396
397 pub fn decode_web_ir_diff(&self) -> Result<WebIrDiffPayload, ProtocolError> {
399 self.validate()?;
400 if self.operation != WEB_IR_DIFF_OPERATION {
401 return Err(ProtocolError::InvalidField(format!(
402 "expected operation {WEB_IR_DIFF_OPERATION}"
403 )));
404 }
405 let payload: WebIrDiffPayload = serde_json::from_value(self.payload.clone())
406 .map_err(|error| ProtocolError::InvalidField(format!("webIr.diff payload: {error}")))?;
407 payload.validate()?;
408 Ok(payload)
409 }
410
411 pub fn decode_web_ir_continuity(&self) -> Result<WebIrContinuityPayload, ProtocolError> {
413 self.validate()?;
414 if self.operation != WEB_IR_CONTINUITY_OPERATION {
415 return Err(ProtocolError::InvalidField(format!(
416 "expected operation {WEB_IR_CONTINUITY_OPERATION}"
417 )));
418 }
419 let payload: WebIrContinuityPayload = serde_json::from_value(self.payload.clone())
420 .map_err(|error| {
421 ProtocolError::InvalidField(format!("webIr.continuity payload: {error}"))
422 })?;
423 payload.validate()?;
424 Ok(payload)
425 }
426}
427
428pub fn compile_task_request(
430 request: &GlassRequest,
431) -> Result<crate::task_compiler::TaskExecutionPlan, ProtocolError> {
432 let payload = request.decode_task_compile()?;
433 crate::task_compiler::compile_task(&payload.task).map_err(ProtocolError::TaskCompilation)
434}
435
436pub fn compile_task_result(request: &GlassRequest) -> Result<TaskCompileResult, ProtocolError> {
438 Ok(TaskCompileResult {
439 plan: compile_task_request(request)?,
440 })
441}
442
443pub fn validate_task_result(request: &GlassRequest) -> Result<TaskValidationResult, ProtocolError> {
445 let payload = request.decode_task_validate()?;
446 Ok(TaskValidationResult {
447 valid: true,
448 schema_version: payload.task.schema_version,
449 task: payload.task.task,
450 })
451}
452
453pub fn web_ir_validate_result(
455 request: &GlassRequest,
456) -> Result<WebIrValidationResult, ProtocolError> {
457 let payload = request.decode_web_ir_validate()?;
458 Ok(WebIrValidationResult::from_draft(&payload.draft))
459}
460
461pub fn web_ir_inspect_result(
463 request: &GlassRequest,
464) -> Result<WebIrInspectionResult, ProtocolError> {
465 let payload = request.decode_web_ir_inspect()?;
466 Ok(WebIrInspectionResult::from_draft(&payload.draft))
467}
468
469pub fn web_ir_diff_result(request: &GlassRequest) -> Result<WebIrDiffResult, ProtocolError> {
471 let payload = request.decode_web_ir_diff()?;
472 let diff = payload
473 .before
474 .diff(&payload.after)
475 .map_err(ProtocolError::WebIrValidation)?;
476 Ok(WebIrDiffResult::from_diff(&diff))
477}
478
479pub fn web_ir_continuity_result(
481 request: &GlassRequest,
482) -> Result<WebIrContinuityResult, ProtocolError> {
483 let payload = request.decode_web_ir_continuity()?;
484 let continuity = payload
485 .before
486 .classify_entity_continuity(&payload.after, &payload.entity_id)
487 .map_err(ProtocolError::WebIrValidation)?;
488 Ok(WebIrContinuityResult::from(continuity))
489}
490
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
493#[serde(rename_all = "camelCase")]
494pub struct GlassResponse {
495 pub protocol_version: u32,
496 pub request_id: String,
497 #[serde(skip_serializing_if = "Option::is_none")]
498 pub correlation_id: Option<String>,
499 pub ok: bool,
500 #[serde(skip_serializing_if = "Option::is_none")]
501 pub result: Option<Value>,
502 #[serde(skip_serializing_if = "Option::is_none")]
503 pub error: Option<GlassError>,
504}
505
506impl GlassResponse {
507 pub fn validate(&self) -> Result<(), ProtocolError> {
509 if self.protocol_version != GLASS_PROTOCOL_VERSION {
510 return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
511 }
512 validate_identifier(&self.request_id, "requestId")?;
513 if let Some(correlation_id) = &self.correlation_id {
514 validate_identifier(correlation_id, "correlationId")?;
515 }
516 match (self.ok, self.result.is_some(), self.error.is_some()) {
517 (true, true, false) | (false, false, true) => Ok(()),
518 _ => Err(ProtocolError::InvalidField(
519 "ok responses require result and error responses require error".into(),
520 )),
521 }
522 }
523
524 pub fn decode_task_compile_result(&self) -> Result<TaskCompileResult, ProtocolError> {
526 self.validate()?;
527 if !self.ok {
528 return Err(ProtocolError::InvalidField(
529 "task.compile result requires a successful response".into(),
530 ));
531 }
532 let value = self
533 .result
534 .clone()
535 .ok_or_else(|| ProtocolError::InvalidField("task.compile result is missing".into()))?;
536 let result: TaskCompileResult = serde_json::from_value(value).map_err(|error| {
537 ProtocolError::InvalidField(format!("task.compile result: {error}"))
538 })?;
539 result.validate()?;
540 Ok(result)
541 }
542
543 pub fn decode_task_validation_result(&self) -> Result<TaskValidationResult, ProtocolError> {
545 self.validate()?;
546 if !self.ok {
547 return Err(ProtocolError::InvalidField(
548 "task.validate result requires a successful response".into(),
549 ));
550 }
551 let value = self
552 .result
553 .clone()
554 .ok_or_else(|| ProtocolError::InvalidField("task.validate result is missing".into()))?;
555 serde_json::from_value(value)
556 .map_err(|error| ProtocolError::InvalidField(format!("task.validate result: {error}")))
557 }
558
559 pub fn decode_web_ir_validation_result(&self) -> Result<WebIrValidationResult, ProtocolError> {
561 self.decode_web_ir_result("webIr.validate")
562 }
563
564 pub fn decode_web_ir_inspection_result(&self) -> Result<WebIrInspectionResult, ProtocolError> {
566 self.decode_web_ir_result("webIr.inspect")
567 }
568
569 fn decode_web_ir_result<T>(&self, operation: &str) -> Result<T, ProtocolError>
570 where
571 T: for<'de> Deserialize<'de>,
572 {
573 self.validate()?;
574 if !self.ok {
575 return Err(ProtocolError::InvalidField(format!(
576 "{operation} result requires a successful response"
577 )));
578 }
579 let value = self
580 .result
581 .clone()
582 .ok_or_else(|| ProtocolError::InvalidField(format!("{operation} result is missing")))?;
583 serde_json::from_value(value)
584 .map_err(|error| ProtocolError::InvalidField(format!("{operation} result: {error}")))
585 }
586
587 pub fn decode_web_ir_diff_result(&self) -> Result<WebIrDiffResult, ProtocolError> {
589 self.validate()?;
590 if !self.ok {
591 return Err(ProtocolError::InvalidField(
592 "webIr.diff result requires a successful response".into(),
593 ));
594 }
595 let value = self
596 .result
597 .clone()
598 .ok_or_else(|| ProtocolError::InvalidField("webIr.diff result is missing".into()))?;
599 serde_json::from_value(value)
600 .map_err(|error| ProtocolError::InvalidField(format!("webIr.diff result: {error}")))
601 }
602
603 pub fn decode_web_ir_continuity_result(&self) -> Result<WebIrContinuityResult, ProtocolError> {
605 self.validate()?;
606 if !self.ok {
607 return Err(ProtocolError::InvalidField(
608 "webIr.continuity result requires a successful response".into(),
609 ));
610 }
611 let value = self.result.clone().ok_or_else(|| {
612 ProtocolError::InvalidField("webIr.continuity result is missing".into())
613 })?;
614 serde_json::from_value(value).map_err(|error| {
615 ProtocolError::InvalidField(format!("webIr.continuity result: {error}"))
616 })
617 }
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
622#[serde(rename_all = "camelCase")]
623pub enum ErrorPhase {
624 #[default]
625 Preflight,
626 Dispatch,
627 PostDispatch,
628 Verification,
629 Reconciliation,
630}
631
632#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
634#[serde(rename_all = "camelCase")]
635pub enum RetryClassification {
636 SafeImmediate,
637 #[default]
638 SafeAfterReobserve,
639 SafeAfterReconcile,
640 UnsafeUntilReconciled,
641 RequiresUserDecision,
642 NotRetryable,
643 Unknown,
644}
645
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
648#[serde(rename_all = "camelCase")]
649pub struct RetryGuidance {
650 pub classification: RetryClassification,
651 pub recommended_operation: String,
652}
653
654impl Default for RetryGuidance {
655 fn default() -> Self {
656 Self {
657 classification: RetryClassification::SafeAfterReobserve,
658 recommended_operation: "inspect_page".into(),
659 }
660 }
661}
662
663#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
665#[serde(rename_all = "camelCase")]
666pub struct GlassError {
667 pub code: String,
668 #[serde(default)]
669 pub phase: ErrorPhase,
670 pub message: String,
671 #[serde(default)]
672 pub mutation_possible: bool,
673 #[serde(default)]
674 pub retry: RetryGuidance,
675 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub retryable: Option<bool>,
678 #[serde(skip_serializing_if = "Option::is_none")]
679 pub details: Option<Value>,
680}
681
682impl GlassError {
683 pub fn validate(&self) -> Result<(), ProtocolError> {
685 if self.code.is_empty() || self.code.len() > MAX_ERROR_CODE_BYTES {
686 return Err(ProtocolError::InvalidField(
687 "error code must be a bounded non-empty string".into(),
688 ));
689 }
690 if self.message.is_empty() || self.message.len() > MAX_MESSAGE_BYTES {
691 return Err(ProtocolError::InvalidField(
692 "error message must be a bounded non-empty string".into(),
693 ));
694 }
695 validate_identifier(
696 &self.retry.recommended_operation,
697 "retry.recommendedOperation",
698 )?;
699 Ok(())
700 }
701}
702
703#[derive(Debug, Clone, PartialEq, Eq)]
705pub enum ProtocolError {
706 UnsupportedVersion(u32),
707 InvalidField(String),
708 TaskValidation(crate::task_protocol::TaskProtocolError),
709 TaskCompilation(crate::task_compiler::TaskCompilationError),
710 WebIrValidation(crate::web_ir::WebIrValidationError),
711}
712
713impl std::fmt::Display for ProtocolError {
714 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
715 match self {
716 Self::UnsupportedVersion(version) => {
717 write!(formatter, "unsupported Glass protocol version {version}")
718 }
719 Self::InvalidField(detail) => formatter.write_str(detail),
720 Self::TaskValidation(error) => error.fmt(formatter),
721 Self::TaskCompilation(error) => error.fmt(formatter),
722 Self::WebIrValidation(error) => error.fmt(formatter),
723 }
724 }
725}
726
727impl std::error::Error for ProtocolError {}
728
729fn validate_identifier(value: &str, field: &str) -> Result<(), ProtocolError> {
730 if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_whitespace) {
731 return Err(ProtocolError::InvalidField(format!(
732 "{field} must be a bounded non-whitespace identifier"
733 )));
734 }
735 Ok(())
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 fn request() -> GlassRequest {
743 GlassRequest {
744 protocol_version: GLASS_PROTOCOL_VERSION,
745 request_id: "request-1".into(),
746 correlation_id: Some("run-1".into()),
747 session_id: Some("session-1".into()),
748 mutation_lease: Some(MutationLeaseRef {
749 session_id: "session-1".into(),
750 token: "lease-1".into(),
751 }),
752 operation: "browser.observe".into(),
753 payload: serde_json::json!({"level": "interactive"}),
754 deadline_ms: Some(5_000),
755 }
756 }
757
758 fn web_ir_draft(revision: u64, name: &str) -> crate::web_ir::GlassWebIrDraft {
759 serde_json::from_value(serde_json::json!({
760 "schemaVersion": 1,
761 "revision": revision,
762 "entities": [
763 {
764 "id": "page",
765 "kind": "page",
766 "quality": "confirmed",
767 "evidenceSources": []
768 },
769 {
770 "id": "field-1",
771 "kind": "field",
772 "role": "textbox",
773 "name": name,
774 "quality": "strong",
775 "evidenceSources": ["dom"]
776 }
777 ],
778 "relationships": [
779 {"from": "page", "to": "field-1", "kind": "contains"}
780 ],
781 "coverage": {
782 "structural": "strong",
783 "semantic": "strong",
784 "interactiveEntitiesObserved": 1,
785 "opaqueRegions": 0,
786 "reasons": []
787 },
788 "limits": {
789 "truncated": false,
790 "omittedFacts": 0,
791 "textBytes": 0,
792 "missingSources": []
793 }
794 }))
795 .unwrap()
796 }
797
798 #[test]
799 fn request_round_trips_and_validates() {
800 let request = request();
801 request.validate().unwrap();
802 let value = serde_json::to_value(&request).unwrap();
803 assert_eq!(value["protocolVersion"], 1);
804 assert_eq!(value["mutationLease"]["sessionId"], "session-1");
805 let decoded: GlassRequest = serde_json::from_value(value).unwrap();
806 assert_eq!(decoded, request);
807 }
808
809 #[test]
810 fn response_requires_exactly_one_outcome() {
811 let response = GlassResponse {
812 protocol_version: GLASS_PROTOCOL_VERSION,
813 request_id: "request-1".into(),
814 correlation_id: None,
815 ok: false,
816 result: None,
817 error: Some(GlassError {
818 code: "target.stale".into(),
819 phase: ErrorPhase::Preflight,
820 message: "a mutation lease is required".into(),
821 mutation_possible: false,
822 retry: RetryGuidance {
823 classification: RetryClassification::SafeAfterReobserve,
824 recommended_operation: "inspect_page".into(),
825 },
826 retryable: Some(true),
827 details: None,
828 }),
829 };
830 response.validate().unwrap();
831 let mut invalid = response.clone();
832 invalid.ok = true;
833 assert!(invalid.validate().is_err());
834 }
835
836 #[test]
837 fn web_ir_revision_operations_round_trip_with_bounded_results() {
838 let before = web_ir_draft(7, "Email");
839 let after = web_ir_draft(8, "Email address");
840 let diff_request = GlassRequest {
841 protocol_version: GLASS_PROTOCOL_VERSION,
842 request_id: "diff-1".into(),
843 correlation_id: None,
844 session_id: None,
845 mutation_lease: None,
846 operation: WEB_IR_DIFF_OPERATION.into(),
847 payload: serde_json::to_value(WebIrDiffPayload {
848 before: before.clone(),
849 after: after.clone(),
850 })
851 .unwrap(),
852 deadline_ms: None,
853 };
854 let diff = web_ir_diff_result(&diff_request).unwrap();
855 assert_eq!(diff.from_revision, 7);
856 assert_eq!(diff.to_revision, 8);
857 assert_eq!(diff.entity_changed_count, 1);
858 assert_eq!(diff_request.decode_web_ir_diff().unwrap().before, before);
859
860 let continuity_request = GlassRequest {
861 protocol_version: GLASS_PROTOCOL_VERSION,
862 request_id: "continuity-1".into(),
863 correlation_id: None,
864 session_id: None,
865 mutation_lease: None,
866 operation: WEB_IR_CONTINUITY_OPERATION.into(),
867 payload: serde_json::to_value(WebIrContinuityPayload {
868 before,
869 after,
870 entity_id: "field-1".into(),
871 })
872 .unwrap(),
873 deadline_ms: None,
874 };
875 let continuity = web_ir_continuity_result(&continuity_request).unwrap();
876 assert_eq!(
877 continuity.status,
878 crate::web_ir::DraftEntityContinuityStatus::Changed
879 );
880 let response = GlassResponse {
881 protocol_version: GLASS_PROTOCOL_VERSION,
882 request_id: continuity_request.request_id.clone(),
883 correlation_id: None,
884 ok: true,
885 result: Some(serde_json::to_value(&continuity).unwrap()),
886 error: None,
887 };
888 assert_eq!(
889 response.decode_web_ir_continuity_result().unwrap(),
890 continuity
891 );
892 }
893
894 #[test]
895 fn web_ir_inspect_and_validate_operations_round_trip() {
896 let draft = web_ir_draft(7, "Email");
897 let validate_request = GlassRequest {
898 protocol_version: GLASS_PROTOCOL_VERSION,
899 request_id: "validate-1".into(),
900 correlation_id: None,
901 session_id: None,
902 mutation_lease: None,
903 operation: WEB_IR_VALIDATE_OPERATION.into(),
904 payload: serde_json::json!({"draft": draft.clone()}),
905 deadline_ms: None,
906 };
907 let validation = web_ir_validate_result(&validate_request).unwrap();
908 assert!(validation.valid);
909 assert_eq!(
910 validate_request.decode_web_ir_validate().unwrap().draft,
911 draft
912 );
913 let validation_response = GlassResponse {
914 protocol_version: GLASS_PROTOCOL_VERSION,
915 request_id: "validate-1".into(),
916 correlation_id: None,
917 ok: true,
918 result: Some(serde_json::to_value(&validation).unwrap()),
919 error: None,
920 };
921 assert_eq!(
922 validation_response
923 .decode_web_ir_validation_result()
924 .unwrap(),
925 validation
926 );
927
928 let inspect_request = GlassRequest {
929 operation: WEB_IR_INSPECT_OPERATION.into(),
930 request_id: "inspect-1".into(),
931 payload: serde_json::json!({"draft": draft}),
932 ..validate_request
933 };
934 let inspection = web_ir_inspect_result(&inspect_request).unwrap();
935 let inspection_response = GlassResponse {
936 protocol_version: GLASS_PROTOCOL_VERSION,
937 request_id: "inspect-1".into(),
938 correlation_id: None,
939 ok: true,
940 result: Some(serde_json::to_value(&inspection).unwrap()),
941 error: None,
942 };
943 assert_eq!(
944 inspection_response
945 .decode_web_ir_inspection_result()
946 .unwrap(),
947 inspection
948 );
949 }
950
951 #[test]
952 fn bounds_and_unknown_fields_fail_closed() {
953 let mut request = request();
954 request.operation = "bad operation".into();
955 assert!(request.validate().is_err());
956 let unknown = serde_json::json!({
957 "protocolVersion": 1,
958 "requestId": "request-1",
959 "operation": "browser.observe",
960 "payload": {},
961 "future": true
962 });
963 assert!(serde_json::from_value::<GlassRequest>(unknown).is_err());
964 }
965
966 #[test]
967 fn task_validate_boundary_decodes_without_compiling() {
968 let task = serde_json::json!({
969 "schemaVersion": 1,
970 "task": "region.extract",
971 "scope": {"regionName": "Checkout"},
972 "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 16},
973 "risk": "readOnly"
974 });
975 let request = GlassRequest {
976 protocol_version: GLASS_PROTOCOL_VERSION,
977 request_id: "validate-task-1".into(),
978 correlation_id: None,
979 session_id: None,
980 mutation_lease: None,
981 operation: TASK_VALIDATE_OPERATION.into(),
982 payload: serde_json::json!({"task": task}),
983 deadline_ms: None,
984 };
985 let result = validate_task_result(&request).unwrap();
986 assert_eq!(
987 result,
988 TaskValidationResult {
989 valid: true,
990 schema_version: 1,
991 task: crate::task_protocol::TaskKind::RegionExtract,
992 }
993 );
994 let response = GlassResponse {
995 protocol_version: GLASS_PROTOCOL_VERSION,
996 request_id: request.request_id.clone(),
997 correlation_id: None,
998 ok: true,
999 result: Some(serde_json::to_value(&result).unwrap()),
1000 error: None,
1001 };
1002 assert_eq!(response.decode_task_validation_result().unwrap(), result);
1003 assert_eq!(
1004 request.decode_task_validate().unwrap().task.task,
1005 crate::task_protocol::TaskKind::RegionExtract
1006 );
1007 }
1008
1009 #[test]
1010 fn task_compile_boundary_decodes_and_compiles_without_browser_state() {
1011 let task = serde_json::json!({
1012 "schemaVersion": 1,
1013 "task": "region.extract",
1014 "scope": {"regionName": "Checkout"},
1015 "limits": {"maxActions": 8, "timeoutMs": 5000, "maxItems": 32},
1016 "risk": "readOnly"
1017 });
1018 let request = GlassRequest {
1019 protocol_version: GLASS_PROTOCOL_VERSION,
1020 request_id: "compile-1".into(),
1021 correlation_id: None,
1022 session_id: None,
1023 mutation_lease: None,
1024 operation: TASK_COMPILE_OPERATION.into(),
1025 payload: serde_json::json!({"task": task}),
1026 deadline_ms: None,
1027 };
1028 let plan = compile_task_request(&request).unwrap();
1029 assert_eq!(plan.task, crate::task_protocol::TaskKind::RegionExtract);
1030 assert_eq!(plan.scope.region_name.as_deref(), Some("Checkout"));
1031 assert_eq!(plan.limits.max_actions, 8);
1032 assert_eq!(
1033 plan.revision,
1034 crate::task_protocol::TaskRevisionPolicy::Exact
1035 );
1036
1037 let mut wrong_operation = request.clone();
1038 wrong_operation.operation = "browser.observe".into();
1039 assert!(wrong_operation.decode_task_compile().is_err());
1040
1041 let mut unknown = request.clone();
1042 unknown.payload["futureField"] = true.into();
1043 assert!(unknown.decode_task_compile().is_err());
1044
1045 let mut invalid = request;
1046 invalid.payload["task"]["task"] = "form.fill".into();
1047 assert!(compile_task_request(&invalid).is_err());
1048 }
1049
1050 #[test]
1051 fn task_compile_result_round_trips_through_success_response() {
1052 let request = GlassRequest {
1053 protocol_version: GLASS_PROTOCOL_VERSION,
1054 request_id: "compile-2".into(),
1055 correlation_id: None,
1056 session_id: None,
1057 mutation_lease: None,
1058 operation: TASK_COMPILE_OPERATION.into(),
1059 payload: serde_json::json!({
1060 "task": {
1061 "schemaVersion": 1,
1062 "task": "field.read",
1063 "scope": {"entityKind": "field", "entityName": "Email"},
1064 "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 1},
1065 "risk": "readOnly"
1066 }
1067 }),
1068 deadline_ms: None,
1069 };
1070 let result = compile_task_result(&request).unwrap();
1071 let response = GlassResponse {
1072 protocol_version: GLASS_PROTOCOL_VERSION,
1073 request_id: request.request_id.clone(),
1074 correlation_id: None,
1075 ok: true,
1076 result: Some(serde_json::to_value(&result).unwrap()),
1077 error: None,
1078 };
1079 assert_eq!(response.decode_task_compile_result().unwrap(), result);
1080
1081 let mut unknown = response.clone();
1082 unknown.result.as_mut().unwrap()["futureField"] = true.into();
1083 assert!(unknown.decode_task_compile_result().is_err());
1084
1085 let mut failure = response;
1086 failure.ok = false;
1087 failure.result = None;
1088 failure.error = Some(GlassError {
1089 code: "task.invalid".into(),
1090 phase: ErrorPhase::Preflight,
1091 message: "invalid task".into(),
1092 mutation_possible: false,
1093 retry: RetryGuidance::default(),
1094 retryable: None,
1095 details: None,
1096 });
1097 assert!(failure.decode_task_compile_result().is_err());
1098 }
1099
1100 #[test]
1101 fn additive_response_fields_are_tolerated() {
1102 let response: GlassResponse = serde_json::from_value(serde_json::json!({
1103 "protocolVersion": 1,
1104 "requestId": "request-1",
1105 "ok": false,
1106 "error": {
1107 "code": "target.stale",
1108 "message": "stale",
1109 "retryable": true,
1110 "future": "ignored"
1111 },
1112 "future": true
1113 }))
1114 .unwrap();
1115 assert_eq!(
1116 response.error.unwrap().retry.classification,
1117 RetryClassification::SafeAfterReobserve
1118 );
1119 }
1120}