1use crate::web_ir::DraftEntityKind;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::error::Error;
10use std::fmt::{Display, Formatter};
11
12pub 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#[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#[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#[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#[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#[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#[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#[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#[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#[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 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 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 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#[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}