1use crate::task_protocol::{
7 GlassTask, MAX_INPUT_NAME_BYTES, MAX_INPUTS, MAX_POSTCONDITIONS, TASK_PROTOCOL_SCHEMA_VERSION,
8 TaskAmbiguityPolicy, TaskKind, TaskLimits, TaskPostcondition, TaskProtocolError,
9 TaskRevisionPolicy, TaskRiskClass, TaskScope,
10};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeSet;
13use std::error::Error;
14use std::fmt::{Display, Formatter};
15
16pub const TASK_PLAN_SCHEMA_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub enum TaskPlanOperation {
23 ObserveScope,
24 InspectForm,
25 FillInputs,
26 ValidateForm,
27 SubmitForm,
28 FollowNavigation,
29 SelectTab,
30 ExtractTable,
31 ExtractCollection,
32 ExtractRegion,
33 ReadField,
34 InspectDialog,
35 ConfirmDialog,
36 CancelDialog,
37 NextPage,
38 CollectPages,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase", deny_unknown_fields)]
44pub struct TaskPlanStep {
45 pub ordinal: u16,
46 pub operation: TaskPlanOperation,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub input_names: Vec<String>,
49 #[serde(default)]
50 pub requires_confirmation: bool,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase", deny_unknown_fields)]
56pub struct TaskExecutionPlan {
57 pub schema_version: u32,
58 pub task_schema_version: u32,
59 pub task: TaskKind,
60 pub scope: TaskScope,
61 pub limits: TaskLimits,
62 pub risk: TaskRiskClass,
63 pub ambiguity: TaskAmbiguityPolicy,
64 pub revision: TaskRevisionPolicy,
65 pub confirmation_required: bool,
66 pub steps: Vec<TaskPlanStep>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub postconditions: Vec<TaskPostcondition>,
69}
70
71impl TaskExecutionPlan {
72 pub fn validate(&self) -> Result<(), TaskCompilationError> {
74 if self.schema_version != TASK_PLAN_SCHEMA_VERSION {
75 return Err(TaskCompilationError::new(
76 "schemaVersion",
77 "unsupported execution-plan schema version",
78 ));
79 }
80 if self.task_schema_version != TASK_PROTOCOL_SCHEMA_VERSION {
81 return Err(TaskCompilationError::new(
82 "taskSchemaVersion",
83 "unsupported Task Protocol schema version",
84 ));
85 }
86 self.scope.validate().map_err(TaskCompilationError::from)?;
87 self.limits.validate().map_err(TaskCompilationError::from)?;
88 if self.postconditions.len() > MAX_POSTCONDITIONS {
89 return Err(TaskCompilationError::new(
90 "postconditions",
91 "postcondition count exceeds the Task Protocol bound",
92 ));
93 }
94 for (index, postcondition) in self.postconditions.iter().enumerate() {
95 postcondition
96 .validate_at(index)
97 .map_err(TaskCompilationError::from)?;
98 }
99 if self.steps.is_empty() {
100 return Err(TaskCompilationError::new(
101 "steps",
102 "execution plan must contain at least one step",
103 ));
104 }
105 if self.steps.len() > self.limits.max_actions as usize {
106 return Err(TaskCompilationError::new(
107 "steps",
108 "execution plan exceeds the maxActions bound",
109 ));
110 }
111 if self.steps[0].operation != TaskPlanOperation::ObserveScope {
112 return Err(TaskCompilationError::new(
113 "steps[0].operation",
114 "execution plan must begin with scope observation",
115 ));
116 }
117 if self
118 .steps
119 .iter()
120 .filter(|step| step.operation == TaskPlanOperation::ObserveScope)
121 .count()
122 != 1
123 {
124 return Err(TaskCompilationError::new(
125 "steps",
126 "execution plan must contain exactly one scope observation",
127 ));
128 }
129 let expected_operation = operation_for_task(self.task);
130 if self
131 .steps
132 .iter()
133 .filter(|step| step.operation == expected_operation)
134 .count()
135 != 1
136 {
137 return Err(TaskCompilationError::new(
138 "steps",
139 "execution plan must contain exactly one task operation",
140 ));
141 }
142 if confirmation_required_for(self.risk, self.ambiguity) && !self.confirmation_required {
143 return Err(TaskCompilationError::new(
144 "confirmationRequired",
145 "plan metadata must require confirmation for this task",
146 ));
147 }
148 for (index, step) in self.steps.iter().enumerate() {
149 let expected = u16::try_from(index + 1).map_err(|_| {
150 TaskCompilationError::new("steps", "execution plan has too many steps")
151 })?;
152 if step.ordinal != expected {
153 return Err(TaskCompilationError::new(
154 format!("steps[{index}].ordinal"),
155 "step ordinals must be contiguous and one-based",
156 ));
157 }
158 if step.operation == TaskPlanOperation::ObserveScope && step.requires_confirmation {
159 return Err(TaskCompilationError::new(
160 format!("steps[{index}].requiresConfirmation"),
161 "scope observation cannot require confirmation",
162 ));
163 }
164 if step.requires_confirmation && !self.confirmation_required {
165 return Err(TaskCompilationError::new(
166 format!("steps[{index}].requiresConfirmation"),
167 "a confirmation-gated step requires plan confirmation metadata",
168 ));
169 }
170 if self.confirmation_required
171 && step.operation != TaskPlanOperation::ObserveScope
172 && !step.requires_confirmation
173 {
174 return Err(TaskCompilationError::new(
175 format!("steps[{index}].requiresConfirmation"),
176 "task operations must be confirmation-gated when the plan requires it",
177 ));
178 }
179 if step.operation == TaskPlanOperation::FillInputs && step.input_names.is_empty() {
180 return Err(TaskCompilationError::new(
181 format!("steps[{index}].inputNames"),
182 "fill operations require at least one input name",
183 ));
184 }
185 if step.operation != TaskPlanOperation::FillInputs && !step.input_names.is_empty() {
186 return Err(TaskCompilationError::new(
187 format!("steps[{index}].inputNames"),
188 "input names are only valid for fill operations",
189 ));
190 }
191 let mut input_names = BTreeSet::new();
192 if step.input_names.len() > MAX_INPUTS {
193 return Err(TaskCompilationError::new(
194 format!("steps[{index}].inputNames"),
195 "input name count exceeds the Task Protocol bound",
196 ));
197 }
198 for input_name in &step.input_names {
199 if input_name.is_empty()
200 || input_name.len() > MAX_INPUT_NAME_BYTES
201 || input_name.chars().any(char::is_control)
202 || !input_names.insert(input_name)
203 {
204 return Err(TaskCompilationError::new(
205 format!("steps[{index}].inputNames"),
206 "input names must be unique, bounded, and free of control characters",
207 ));
208 }
209 }
210 }
211 Ok(())
212 }
213
214 pub fn to_canonical_json(&self) -> Result<String, TaskCompilationError> {
216 self.validate()?;
217 serde_json::to_string(self)
218 .map_err(|error| TaskCompilationError::new("$", error.to_string()))
219 }
220}
221
222pub fn compile_task(task: &GlassTask) -> Result<TaskExecutionPlan, TaskCompilationError> {
224 task.validate().map_err(TaskCompilationError::from)?;
225 let confirmation_required = confirmation_required_for(task.risk, task.ambiguity);
226 let operations = vec![
227 TaskPlanOperation::ObserveScope,
228 operation_for_task(task.task),
229 ];
230 let input_names = task.inputs.keys().cloned().collect::<Vec<_>>();
231 let steps = operations
232 .into_iter()
233 .enumerate()
234 .map(|(index, operation)| TaskPlanStep {
235 ordinal: u16::try_from(index + 1).expect("fixed operation sequence is bounded"),
236 operation,
237 input_names: if operation == TaskPlanOperation::FillInputs {
238 input_names.clone()
239 } else {
240 Vec::new()
241 },
242 requires_confirmation: confirmation_required
243 && operation != TaskPlanOperation::ObserveScope,
244 })
245 .collect();
246 let plan = TaskExecutionPlan {
247 schema_version: TASK_PLAN_SCHEMA_VERSION,
248 task_schema_version: task.schema_version,
249 task: task.task,
250 scope: task.scope.clone(),
251 limits: task.limits,
252 risk: task.risk,
253 ambiguity: task.ambiguity,
254 revision: task.revision,
255 confirmation_required,
256 steps,
257 postconditions: task.postconditions.clone(),
258 };
259 plan.validate()?;
260 Ok(plan)
261}
262
263fn operation_for_task(task: TaskKind) -> TaskPlanOperation {
264 match task {
265 TaskKind::FormInspect => TaskPlanOperation::InspectForm,
266 TaskKind::FormFill => TaskPlanOperation::FillInputs,
267 TaskKind::FormValidate => TaskPlanOperation::ValidateForm,
268 TaskKind::FormSubmit => TaskPlanOperation::SubmitForm,
269 TaskKind::NavigationFollow => TaskPlanOperation::FollowNavigation,
270 TaskKind::NavigationSelectTab => TaskPlanOperation::SelectTab,
271 TaskKind::TableExtract => TaskPlanOperation::ExtractTable,
272 TaskKind::CollectionExtract => TaskPlanOperation::ExtractCollection,
273 TaskKind::RegionExtract => TaskPlanOperation::ExtractRegion,
274 TaskKind::FieldRead => TaskPlanOperation::ReadField,
275 TaskKind::DialogInspect => TaskPlanOperation::InspectDialog,
276 TaskKind::DialogConfirm => TaskPlanOperation::ConfirmDialog,
277 TaskKind::DialogCancel => TaskPlanOperation::CancelDialog,
278 TaskKind::PaginationNext => TaskPlanOperation::NextPage,
279 TaskKind::PaginationCollect => TaskPlanOperation::CollectPages,
280 }
281}
282
283fn confirmation_required_for(risk: TaskRiskClass, ambiguity: TaskAmbiguityPolicy) -> bool {
284 matches!(
285 risk,
286 TaskRiskClass::RemoteIrreversible
287 | TaskRiskClass::Authentication
288 | TaskRiskClass::DataDisclosure
289 ) || matches!(ambiguity, TaskAmbiguityPolicy::RequireConfirmation)
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct TaskCompilationError {
295 pub path: String,
296 pub reason: String,
297}
298
299impl TaskCompilationError {
300 fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
301 Self {
302 path: path.into(),
303 reason: reason.into(),
304 }
305 }
306}
307
308impl From<TaskProtocolError> for TaskCompilationError {
309 fn from(error: TaskProtocolError) -> Self {
310 Self {
311 path: error.path,
312 reason: error.reason,
313 }
314 }
315}
316
317impl Display for TaskCompilationError {
318 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
319 write!(formatter, "{}: {}", self.path, self.reason)
320 }
321}
322
323impl Error for TaskCompilationError {}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::task_protocol::{TaskAmbiguityPolicy, TaskLimits, TaskPostconditionKind, TaskScope};
329 use std::collections::BTreeMap;
330
331 fn task(kind: TaskKind, risk: TaskRiskClass) -> GlassTask {
332 GlassTask {
333 schema_version: TASK_PROTOCOL_SCHEMA_VERSION,
334 task: kind,
335 scope: TaskScope {
336 region_name: Some("Checkout".into()),
337 ..TaskScope::default()
338 },
339 inputs: BTreeMap::from([(String::from("email"), String::from("a@example.test"))]),
340 limits: TaskLimits::default(),
341 risk,
342 ambiguity: TaskAmbiguityPolicy::Fail,
343 revision: Default::default(),
344 postconditions: vec![TaskPostcondition {
345 kind: TaskPostconditionKind::ValidationClear,
346 expected: None,
347 }],
348 }
349 }
350
351 #[test]
352 fn compiler_emits_stable_semantic_operations_without_values() {
353 let plan = compile_task(&task(TaskKind::FormFill, TaskRiskClass::LocalMutation)).unwrap();
354 assert_eq!(
355 plan.steps
356 .iter()
357 .map(|step| step.operation)
358 .collect::<Vec<_>>(),
359 vec![
360 TaskPlanOperation::ObserveScope,
361 TaskPlanOperation::FillInputs
362 ]
363 );
364 assert_eq!(plan.steps[1].input_names, vec!["email"]);
365 assert_eq!(plan.scope.region_name.as_deref(), Some("Checkout"));
366 assert_eq!(plan.limits, TaskLimits::default());
367 assert_eq!(plan.ambiguity, TaskAmbiguityPolicy::Fail);
368 assert_eq!(plan.revision, TaskRevisionPolicy::Exact);
369 assert!(!plan.to_canonical_json().unwrap().contains("a@example.test"));
370 let first = plan.to_canonical_json().unwrap();
371 let second = compile_task(&task(TaskKind::FormFill, TaskRiskClass::LocalMutation))
372 .unwrap()
373 .to_canonical_json()
374 .unwrap();
375 assert_eq!(first, second);
376 }
377
378 #[test]
379 fn compiler_preserves_non_default_execution_guards() {
380 let mut authored = task(TaskKind::NavigationFollow, TaskRiskClass::RemoteReversible);
381 authored.scope.region_name = Some("Shipping".into());
382 authored.limits = TaskLimits {
383 max_actions: 7,
384 timeout_ms: 2_500,
385 max_items: 9,
386 };
387 authored.ambiguity = TaskAmbiguityPolicy::RequireConfirmation;
388 authored.revision = TaskRevisionPolicy::Compatible;
389
390 let plan = compile_task(&authored).unwrap();
391
392 assert_eq!(plan.scope.region_name.as_deref(), Some("Shipping"));
393 assert_eq!(plan.limits, authored.limits);
394 assert_eq!(plan.ambiguity, authored.ambiguity);
395 assert_eq!(plan.revision, authored.revision);
396 }
397
398 #[test]
399 fn irreversible_and_explicit_confirmation_tasks_are_gated() {
400 let irreversible = compile_task(&task(
401 TaskKind::FormSubmit,
402 TaskRiskClass::RemoteIrreversible,
403 ))
404 .unwrap();
405 assert!(irreversible.confirmation_required);
406 assert!(irreversible.steps[1].requires_confirmation);
407 let mut explicit = task(TaskKind::NavigationFollow, TaskRiskClass::ReadOnly);
408 explicit.ambiguity = TaskAmbiguityPolicy::RequireConfirmation;
409 assert!(compile_task(&explicit).unwrap().confirmation_required);
410 }
411
412 #[test]
413 fn invalid_authored_tasks_fail_before_plan_emission() {
414 let mut invalid = task(TaskKind::FormFill, TaskRiskClass::LocalMutation);
415 invalid.inputs.clear();
416 let error = compile_task(&invalid).unwrap_err();
417 assert_eq!(error.path, "inputs");
418 }
419
420 #[test]
421 fn plan_validation_rejects_noncontiguous_ordinals() {
422 let mut plan =
423 compile_task(&task(TaskKind::RegionExtract, TaskRiskClass::ReadOnly)).unwrap();
424 plan.steps[1].ordinal = 3;
425 assert_eq!(plan.validate().unwrap_err().path, "steps[1].ordinal");
426 }
427 #[test]
428 fn plan_validation_rejects_steps_over_action_budget() {
429 let mut plan =
430 compile_task(&task(TaskKind::RegionExtract, TaskRiskClass::ReadOnly)).unwrap();
431 plan.limits.max_actions = 1;
432 assert_eq!(plan.validate().unwrap_err().path, "steps");
433 }
434
435 #[test]
436 fn plan_validation_rejects_task_operation_mismatch() {
437 let mut plan =
438 compile_task(&task(TaskKind::RegionExtract, TaskRiskClass::ReadOnly)).unwrap();
439 plan.steps[1].operation = TaskPlanOperation::InspectForm;
440 assert_eq!(plan.validate().unwrap_err().path, "steps");
441 }
442
443 #[test]
444 fn plan_validation_rejects_empty_fill_inputs() {
445 let mut plan =
446 compile_task(&task(TaskKind::FormFill, TaskRiskClass::LocalMutation)).unwrap();
447 plan.steps[1].input_names.clear();
448 assert_eq!(plan.validate().unwrap_err().path, "steps[1].inputNames");
449 }
450
451 #[test]
452 fn plan_validation_requires_confirmation_for_risky_tasks() {
453 let mut plan = compile_task(&task(
454 TaskKind::FormSubmit,
455 TaskRiskClass::RemoteIrreversible,
456 ))
457 .unwrap();
458 plan.confirmation_required = false;
459 plan.steps[1].requires_confirmation = false;
460 assert_eq!(plan.validate().unwrap_err().path, "confirmationRequired");
461 }
462 #[test]
463 fn plan_validation_rejects_unbounded_postconditions() {
464 let mut plan =
465 compile_task(&task(TaskKind::RegionExtract, TaskRiskClass::ReadOnly)).unwrap();
466 plan.postconditions[0].expected = Some("\u{0007}".into());
467 assert_eq!(
468 plan.validate().unwrap_err().path,
469 "postconditions[0].expected"
470 );
471
472 plan.postconditions = vec![plan.postconditions[0].clone(); MAX_POSTCONDITIONS + 1];
473 assert_eq!(plan.validate().unwrap_err().path, "postconditions");
474 }
475
476 #[test]
477 fn plan_validation_rejects_too_many_fill_inputs() {
478 let mut plan =
479 compile_task(&task(TaskKind::FormFill, TaskRiskClass::LocalMutation)).unwrap();
480 plan.steps[1].input_names = (0..=MAX_INPUTS)
481 .map(|index| format!("field-{index}"))
482 .collect();
483 assert_eq!(plan.validate().unwrap_err().path, "steps[1].inputNames");
484 }
485}