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