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;
14const MAX_INPUTS: usize = 64;
15const MAX_INPUT_NAME_BYTES: usize = 64;
16const MAX_INPUT_VALUE_BYTES: usize = 4_096;
17const MAX_POSTCONDITIONS: usize = 32;
18const 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
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase", deny_unknown_fields)]
146pub struct GlassTask {
147 pub schema_version: u32,
148 pub task: TaskKind,
149 pub scope: TaskScope,
150 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
151 pub inputs: BTreeMap<String, String>,
152 pub limits: TaskLimits,
153 pub risk: TaskRiskClass,
154 #[serde(default)]
155 pub ambiguity: TaskAmbiguityPolicy,
156 #[serde(default)]
157 pub revision: TaskRevisionPolicy,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub postconditions: Vec<TaskPostcondition>,
160}
161
162impl GlassTask {
163 pub fn from_json(input: &str) -> Result<Self, TaskProtocolError> {
165 let task: Self = serde_json::from_str(input)
166 .map_err(|error| TaskProtocolError::new("$", error.to_string()))?;
167 task.validate()?;
168 Ok(task)
169 }
170
171 pub fn validate(&self) -> Result<(), TaskProtocolError> {
173 if self.schema_version != TASK_PROTOCOL_SCHEMA_VERSION {
174 return Err(TaskProtocolError::new(
175 "schemaVersion",
176 "unsupported Task Protocol schema version",
177 ));
178 }
179 self.scope.validate()?;
180 if self.inputs.len() > MAX_INPUTS {
181 return Err(TaskProtocolError::new(
182 "inputs",
183 "input count exceeds the Task Protocol bound",
184 ));
185 }
186 for (name, value) in &self.inputs {
187 validate_text("inputs.name", name, MAX_INPUT_NAME_BYTES)?;
188 if value.len() > MAX_INPUT_VALUE_BYTES || value.chars().any(char::is_control) {
189 return Err(TaskProtocolError::new(
190 format!("inputs.{name}"),
191 "input value exceeds its bound or contains a control character",
192 ));
193 }
194 }
195 if !(1..=MAX_ACTIONS).contains(&self.limits.max_actions) {
196 return Err(TaskProtocolError::new(
197 "limits.maxActions",
198 "maxActions must be between 1 and 256",
199 ));
200 }
201 if !(1..=MAX_TIMEOUT_MS).contains(&self.limits.timeout_ms) {
202 return Err(TaskProtocolError::new(
203 "limits.timeoutMs",
204 "timeoutMs must be between 1 and 120000",
205 ));
206 }
207 if !(1..=MAX_ITEMS).contains(&self.limits.max_items) {
208 return Err(TaskProtocolError::new(
209 "limits.maxItems",
210 "maxItems must be between 1 and 4096",
211 ));
212 }
213 if self.postconditions.len() > MAX_POSTCONDITIONS {
214 return Err(TaskProtocolError::new(
215 "postconditions",
216 "postcondition count exceeds the Task Protocol bound",
217 ));
218 }
219 for (index, postcondition) in self.postconditions.iter().enumerate() {
220 if let Some(expected) = &postcondition.expected
221 && (expected.len() > MAX_EXPECTATION_BYTES
222 || expected.chars().any(char::is_control))
223 {
224 return Err(TaskProtocolError::new(
225 format!("postconditions[{index}].expected"),
226 "expected value exceeds its bound or contains a control character",
227 ));
228 }
229 }
230 if matches!(self.task, TaskKind::FormFill) && self.inputs.is_empty() {
231 return Err(TaskProtocolError::new(
232 "inputs",
233 "form.fill requires at least one bounded input",
234 ));
235 }
236 Ok(())
237 }
238
239 pub fn to_canonical_json(&self) -> Result<String, TaskProtocolError> {
241 self.validate()?;
242 serde_json::to_string(self).map_err(|error| TaskProtocolError::new("$", error.to_string()))
243 }
244}
245
246impl TaskScope {
247 fn validate(&self) -> Result<(), TaskProtocolError> {
248 if self.region_name.is_none() && self.entity_kind.is_none() && self.entity_name.is_none() {
249 return Err(TaskProtocolError::new(
250 "scope",
251 "scope requires a semantic region or entity constraint",
252 ));
253 }
254 if let Some(region_name) = &self.region_name {
255 validate_text("scope.regionName", region_name, 128)?;
256 }
257 if let Some(entity_name) = &self.entity_name {
258 validate_text("scope.entityName", entity_name, 128)?;
259 }
260 if self.entity_name.is_some() && self.entity_kind.is_none() {
261 return Err(TaskProtocolError::new(
262 "scope.entityKind",
263 "entityName requires entityKind",
264 ));
265 }
266 Ok(())
267 }
268}
269
270fn validate_text(path: &str, value: &str, max_bytes: usize) -> Result<(), TaskProtocolError> {
271 if value.is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
272 return Err(TaskProtocolError::new(
273 path,
274 "value must be non-empty, bounded, and free of control characters",
275 ));
276 }
277 Ok(())
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct TaskProtocolError {
283 pub path: String,
284 pub reason: String,
285}
286
287impl TaskProtocolError {
288 fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
289 Self {
290 path: path.into(),
291 reason: reason.into(),
292 }
293 }
294}
295
296impl Display for TaskProtocolError {
297 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
298 write!(formatter, "{}: {}", self.path, self.reason)
299 }
300}
301
302impl Error for TaskProtocolError {}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use serde_json::json;
308
309 fn task() -> GlassTask {
310 GlassTask {
311 schema_version: TASK_PROTOCOL_SCHEMA_VERSION,
312 task: TaskKind::FormFill,
313 scope: TaskScope {
314 region_name: Some("Shipping address".into()),
315 entity_kind: Some(DraftEntityKind::Form),
316 entity_name: None,
317 },
318 inputs: BTreeMap::from([(String::from("city"), String::from("Kuching"))]),
319 limits: TaskLimits::default(),
320 risk: TaskRiskClass::LocalMutation,
321 ambiguity: TaskAmbiguityPolicy::Fail,
322 revision: TaskRevisionPolicy::Exact,
323 postconditions: vec![TaskPostcondition {
324 kind: TaskPostconditionKind::ValidationClear,
325 expected: None,
326 }],
327 }
328 }
329
330 #[test]
331 fn valid_task_round_trips_canonically() {
332 let task = task();
333 let first = task.to_canonical_json().unwrap();
334 let second = GlassTask::from_json(&first)
335 .unwrap()
336 .to_canonical_json()
337 .unwrap();
338 assert_eq!(first, second);
339 assert!(first.contains("form.fill"));
340 assert!(first.contains("Shipping"));
341 }
342
343 #[test]
344 fn authored_json_rejects_unknown_fields() {
345 let mut value = serde_json::to_value(task()).unwrap();
346 value["futureField"] = json!(true);
347 let error = GlassTask::from_json(&value.to_string()).unwrap_err();
348 assert_eq!(error.path, "$");
349 }
350
351 #[test]
352 fn validation_rejects_empty_scope_and_unbounded_limits() {
353 let mut invalid = task();
354 invalid.scope = TaskScope::default();
355 assert_eq!(invalid.validate().unwrap_err().path, "scope");
356 invalid = task();
357 invalid.limits.max_actions = 0;
358 assert_eq!(invalid.validate().unwrap_err().path, "limits.maxActions");
359 invalid = task();
360 invalid.limits.timeout_ms = MAX_TIMEOUT_MS + 1;
361 assert_eq!(invalid.validate().unwrap_err().path, "limits.timeoutMs");
362 }
363
364 #[test]
365 fn form_fill_requires_inputs_and_entity_names_require_kinds() {
366 let mut invalid = task();
367 invalid.inputs.clear();
368 assert_eq!(invalid.validate().unwrap_err().path, "inputs");
369 invalid = task();
370 invalid.scope.entity_name = Some("Email".into());
371 invalid.scope.entity_kind = None;
372 assert_eq!(invalid.validate().unwrap_err().path, "scope.entityKind");
373 }
374}