Skip to main content

glass/
task_protocol.rs

1//! Strict, bounded authored tasks for deterministic Web IR compilation.
2//!
3//! This module defines the input boundary only. Validation is side-effect free;
4//! compilation and browser execution remain separate follow-on layers.
5
6use crate::web_ir::DraftEntityKind;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::error::Error;
10use std::fmt::{Display, Formatter};
11
12/// Version of the authored Task Protocol contract.
13pub const TASK_PROTOCOL_SCHEMA_VERSION: u32 = 1;
14pub(crate) const MAX_INPUTS: usize = 64;
15pub(crate) const MAX_INPUT_NAME_BYTES: usize = 64;
16const MAX_INPUT_VALUE_BYTES: usize = 4_096;
17pub(crate) const MAX_POSTCONDITIONS: usize = 32;
18pub(crate) const MAX_EXPECTATION_BYTES: usize = 256;
19const MAX_ACTIONS: u32 = 256;
20const MAX_TIMEOUT_MS: u64 = 120_000;
21const MAX_ITEMS: u32 = 4_096;
22
23/// Supported deterministic task families.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum TaskKind {
26    #[serde(rename = "form.inspect")]
27    FormInspect,
28    #[serde(rename = "form.fill")]
29    FormFill,
30    #[serde(rename = "form.validate")]
31    FormValidate,
32    #[serde(rename = "form.submit")]
33    FormSubmit,
34    #[serde(rename = "navigation.follow")]
35    NavigationFollow,
36    #[serde(rename = "navigation.selectTab")]
37    NavigationSelectTab,
38    #[serde(rename = "table.extract")]
39    TableExtract,
40    #[serde(rename = "collection.extract")]
41    CollectionExtract,
42    #[serde(rename = "region.extract")]
43    RegionExtract,
44    #[serde(rename = "field.read")]
45    FieldRead,
46    #[serde(rename = "dialog.inspect")]
47    DialogInspect,
48    #[serde(rename = "dialog.confirm")]
49    DialogConfirm,
50    #[serde(rename = "dialog.cancel")]
51    DialogCancel,
52    #[serde(rename = "pagination.next")]
53    PaginationNext,
54    #[serde(rename = "pagination.collect")]
55    PaginationCollect,
56}
57
58/// Effect class declared by the task author.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub enum TaskRiskClass {
62    ReadOnly,
63    LocalMutation,
64    RemoteReversible,
65    RemoteIrreversible,
66    Authentication,
67    DataDisclosure,
68    UnknownRisk,
69}
70
71/// Behavior when semantic resolution returns more than one candidate.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
73#[serde(rename_all = "camelCase")]
74pub enum TaskAmbiguityPolicy {
75    #[default]
76    Fail,
77    RequireConfirmation,
78}
79
80/// Revision policy requested by the task author.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(rename_all = "camelCase")]
83pub enum TaskRevisionPolicy {
84    #[default]
85    Exact,
86    Compatible,
87    Reextract,
88}
89
90/// Typed postcondition families reserved for the compiler and verifier.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub enum TaskPostconditionKind {
94    PageKind,
95    RegionPresent,
96    EntityState,
97    NavigationOccurred,
98    DialogClosed,
99    ValidationClear,
100    RecordsExtracted,
101}
102
103/// Semantic task scope; it contains no selectors or browser handles.
104#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase", deny_unknown_fields)]
106pub struct TaskScope {
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub region_name: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub entity_kind: Option<DraftEntityKind>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub entity_name: Option<String>,
113}
114
115/// Bounded execution limits declared before compilation.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase", deny_unknown_fields)]
118pub struct TaskLimits {
119    pub max_actions: u32,
120    pub timeout_ms: u64,
121    pub max_items: u32,
122}
123
124impl Default for TaskLimits {
125    fn default() -> Self {
126        Self {
127            max_actions: 16,
128            timeout_ms: 15_000,
129            max_items: 128,
130        }
131    }
132}
133
134/// One typed success condition for a compiled task.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct TaskPostcondition {
138    pub kind: TaskPostconditionKind,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub expected: Option<String>,
141}
142
143impl TaskPostcondition {
144    pub(crate) fn validate_at(&self, index: usize) -> Result<(), TaskProtocolError> {
145        if let Some(expected) = &self.expected
146            && (expected.len() > MAX_EXPECTATION_BYTES || expected.chars().any(char::is_control))
147        {
148            return Err(TaskProtocolError::new(
149                format!("postconditions[{index}].expected"),
150                "expected value exceeds its bound or contains a control character",
151            ));
152        }
153        Ok(())
154    }
155}
156
157/// Versioned declarative task input accepted by Glass.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub struct GlassTask {
161    pub schema_version: u32,
162    pub task: TaskKind,
163    pub scope: TaskScope,
164    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
165    pub inputs: BTreeMap<String, String>,
166    pub limits: TaskLimits,
167    pub risk: TaskRiskClass,
168    #[serde(default)]
169    pub ambiguity: TaskAmbiguityPolicy,
170    #[serde(default)]
171    pub revision: TaskRevisionPolicy,
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub postconditions: Vec<TaskPostcondition>,
174}
175
176impl GlassTask {
177    /// Parse strict authored JSON into a task.
178    pub fn from_json(input: &str) -> Result<Self, TaskProtocolError> {
179        let task: Self = serde_json::from_str(input)
180            .map_err(|error| TaskProtocolError::new("$", error.to_string()))?;
181        task.validate()?;
182        Ok(task)
183    }
184
185    /// Validate the authored task without browser access or mutation.
186    pub fn validate(&self) -> Result<(), TaskProtocolError> {
187        if self.schema_version != TASK_PROTOCOL_SCHEMA_VERSION {
188            return Err(TaskProtocolError::new(
189                "schemaVersion",
190                "unsupported Task Protocol schema version",
191            ));
192        }
193        self.scope.validate()?;
194        self.limits.validate()?;
195        if self.inputs.len() > MAX_INPUTS {
196            return Err(TaskProtocolError::new(
197                "inputs",
198                "input count exceeds the Task Protocol bound",
199            ));
200        }
201        for (name, value) in &self.inputs {
202            validate_text("inputs.name", name, MAX_INPUT_NAME_BYTES)?;
203            if value.len() > MAX_INPUT_VALUE_BYTES || value.chars().any(char::is_control) {
204                return Err(TaskProtocolError::new(
205                    format!("inputs.{name}"),
206                    "input value exceeds its bound or contains a control character",
207                ));
208            }
209        }
210        if self.postconditions.len() > MAX_POSTCONDITIONS {
211            return Err(TaskProtocolError::new(
212                "postconditions",
213                "postcondition count exceeds the Task Protocol bound",
214            ));
215        }
216        for (index, postcondition) in self.postconditions.iter().enumerate() {
217            postcondition.validate_at(index)?;
218        }
219        if matches!(self.task, TaskKind::FormFill) && self.inputs.is_empty() {
220            return Err(TaskProtocolError::new(
221                "inputs",
222                "form.fill requires at least one bounded input",
223            ));
224        }
225        Ok(())
226    }
227
228    /// Serialize a validated task deterministically.
229    pub fn to_canonical_json(&self) -> Result<String, TaskProtocolError> {
230        self.validate()?;
231        serde_json::to_string(self).map_err(|error| TaskProtocolError::new("$", error.to_string()))
232    }
233}
234
235impl TaskLimits {
236    pub(crate) fn validate(&self) -> Result<(), TaskProtocolError> {
237        if !(1..=MAX_ACTIONS).contains(&self.max_actions) {
238            return Err(TaskProtocolError::new(
239                "limits.maxActions",
240                "maxActions must be between 1 and 256",
241            ));
242        }
243        if !(1..=MAX_TIMEOUT_MS).contains(&self.timeout_ms) {
244            return Err(TaskProtocolError::new(
245                "limits.timeoutMs",
246                "timeoutMs must be between 1 and 120000",
247            ));
248        }
249        if !(1..=MAX_ITEMS).contains(&self.max_items) {
250            return Err(TaskProtocolError::new(
251                "limits.maxItems",
252                "maxItems must be between 1 and 4096",
253            ));
254        }
255        Ok(())
256    }
257}
258
259impl TaskScope {
260    pub(crate) fn validate(&self) -> Result<(), TaskProtocolError> {
261        if self.region_name.is_none() && self.entity_kind.is_none() && self.entity_name.is_none() {
262            return Err(TaskProtocolError::new(
263                "scope",
264                "scope requires a semantic region or entity constraint",
265            ));
266        }
267        if let Some(region_name) = &self.region_name {
268            validate_text("scope.regionName", region_name, 128)?;
269        }
270        if let Some(entity_name) = &self.entity_name {
271            validate_text("scope.entityName", entity_name, 128)?;
272        }
273        if self.entity_name.is_some() && self.entity_kind.is_none() {
274            return Err(TaskProtocolError::new(
275                "scope.entityKind",
276                "entityName requires entityKind",
277            ));
278        }
279        Ok(())
280    }
281}
282
283fn validate_text(path: &str, value: &str, max_bytes: usize) -> Result<(), TaskProtocolError> {
284    if value.is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
285        return Err(TaskProtocolError::new(
286            path,
287            "value must be non-empty, bounded, and free of control characters",
288        ));
289    }
290    Ok(())
291}
292
293/// Path-aware Task Protocol validation failure.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct TaskProtocolError {
296    pub path: String,
297    pub reason: String,
298}
299
300impl TaskProtocolError {
301    fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
302        Self {
303            path: path.into(),
304            reason: reason.into(),
305        }
306    }
307}
308
309impl Display for TaskProtocolError {
310    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
311        write!(formatter, "{}: {}", self.path, self.reason)
312    }
313}
314
315impl Error for TaskProtocolError {}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use serde_json::json;
321
322    fn task() -> GlassTask {
323        GlassTask {
324            schema_version: TASK_PROTOCOL_SCHEMA_VERSION,
325            task: TaskKind::FormFill,
326            scope: TaskScope {
327                region_name: Some("Shipping address".into()),
328                entity_kind: Some(DraftEntityKind::Form),
329                entity_name: None,
330            },
331            inputs: BTreeMap::from([(String::from("city"), String::from("Kuching"))]),
332            limits: TaskLimits::default(),
333            risk: TaskRiskClass::LocalMutation,
334            ambiguity: TaskAmbiguityPolicy::Fail,
335            revision: TaskRevisionPolicy::Exact,
336            postconditions: vec![TaskPostcondition {
337                kind: TaskPostconditionKind::ValidationClear,
338                expected: None,
339            }],
340        }
341    }
342
343    #[test]
344    fn valid_task_round_trips_canonically() {
345        let task = task();
346        let first = task.to_canonical_json().unwrap();
347        let second = GlassTask::from_json(&first)
348            .unwrap()
349            .to_canonical_json()
350            .unwrap();
351        assert_eq!(first, second);
352        assert!(first.contains("form.fill"));
353        assert!(first.contains("Shipping"));
354    }
355
356    #[test]
357    fn authored_json_rejects_unknown_fields() {
358        let mut value = serde_json::to_value(task()).unwrap();
359        value["futureField"] = json!(true);
360        let error = GlassTask::from_json(&value.to_string()).unwrap_err();
361        assert_eq!(error.path, "$");
362    }
363
364    #[test]
365    fn validation_rejects_empty_scope_and_unbounded_limits() {
366        let mut invalid = task();
367        invalid.scope = TaskScope::default();
368        assert_eq!(invalid.validate().unwrap_err().path, "scope");
369        invalid = task();
370        invalid.limits.max_actions = 0;
371        assert_eq!(invalid.validate().unwrap_err().path, "limits.maxActions");
372        invalid = task();
373        invalid.limits.timeout_ms = MAX_TIMEOUT_MS + 1;
374        assert_eq!(invalid.validate().unwrap_err().path, "limits.timeoutMs");
375    }
376
377    #[test]
378    fn form_fill_requires_inputs_and_entity_names_require_kinds() {
379        let mut invalid = task();
380        invalid.inputs.clear();
381        assert_eq!(invalid.validate().unwrap_err().path, "inputs");
382        invalid = task();
383        invalid.scope.entity_name = Some("Email".into());
384        invalid.scope.entity_kind = None;
385        assert_eq!(invalid.validate().unwrap_err().path, "scope.entityKind");
386    }
387}