1use crate::task_protocol::{
7 GlassTask, TASK_PROTOCOL_SCHEMA_VERSION, TaskAmbiguityPolicy, TaskKind, TaskLimits,
8 TaskPostcondition, TaskProtocolError, TaskRevisionPolicy, TaskRiskClass, TaskScope,
9};
10use serde::{Deserialize, Serialize};
11use std::error::Error;
12use std::fmt::{Display, Formatter};
13
14pub const TASK_PLAN_SCHEMA_VERSION: u32 = 1;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub enum TaskPlanOperation {
21 ObserveScope,
22 InspectForm,
23 FillInputs,
24 ValidateForm,
25 SubmitForm,
26 FollowNavigation,
27 SelectTab,
28 ExtractTable,
29 ExtractCollection,
30 ExtractRegion,
31 ReadField,
32 InspectDialog,
33 ConfirmDialog,
34 CancelDialog,
35 NextPage,
36 CollectPages,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct TaskPlanStep {
43 pub ordinal: u16,
44 pub operation: TaskPlanOperation,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub input_names: Vec<String>,
47 #[serde(default)]
48 pub requires_confirmation: bool,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase", deny_unknown_fields)]
54pub struct TaskExecutionPlan {
55 pub schema_version: u32,
56 pub task_schema_version: u32,
57 pub task: TaskKind,
58 pub scope: TaskScope,
59 pub limits: TaskLimits,
60 pub risk: TaskRiskClass,
61 pub ambiguity: TaskAmbiguityPolicy,
62 pub revision: TaskRevisionPolicy,
63 pub confirmation_required: bool,
64 pub steps: Vec<TaskPlanStep>,
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub postconditions: Vec<TaskPostcondition>,
67}
68
69impl TaskExecutionPlan {
70 pub fn validate(&self) -> Result<(), TaskCompilationError> {
72 if self.schema_version != TASK_PLAN_SCHEMA_VERSION {
73 return Err(TaskCompilationError::new(
74 "schemaVersion",
75 "unsupported execution-plan schema version",
76 ));
77 }
78 if self.task_schema_version != TASK_PROTOCOL_SCHEMA_VERSION {
79 return Err(TaskCompilationError::new(
80 "taskSchemaVersion",
81 "unsupported Task Protocol schema version",
82 ));
83 }
84 if self.steps.is_empty() {
85 return Err(TaskCompilationError::new(
86 "steps",
87 "execution plan must contain at least one step",
88 ));
89 }
90 for (index, step) in self.steps.iter().enumerate() {
91 let expected = u16::try_from(index + 1).map_err(|_| {
92 TaskCompilationError::new("steps", "execution plan has too many steps")
93 })?;
94 if step.ordinal != expected {
95 return Err(TaskCompilationError::new(
96 format!("steps[{index}].ordinal"),
97 "step ordinals must be contiguous and one-based",
98 ));
99 }
100 if step.requires_confirmation && !self.confirmation_required {
101 return Err(TaskCompilationError::new(
102 format!("steps[{index}].requiresConfirmation"),
103 "a confirmation-gated step requires plan confirmation metadata",
104 ));
105 }
106 }
107 Ok(())
108 }
109
110 pub fn to_canonical_json(&self) -> Result<String, TaskCompilationError> {
112 self.validate()?;
113 serde_json::to_string(self)
114 .map_err(|error| TaskCompilationError::new("$", error.to_string()))
115 }
116}
117
118pub fn compile_task(task: &GlassTask) -> Result<TaskExecutionPlan, TaskCompilationError> {
120 task.validate().map_err(TaskCompilationError::from)?;
121 let confirmation_required = matches!(
122 task.risk,
123 TaskRiskClass::RemoteIrreversible
124 | TaskRiskClass::Authentication
125 | TaskRiskClass::DataDisclosure
126 ) || matches!(
127 task.ambiguity,
128 crate::task_protocol::TaskAmbiguityPolicy::RequireConfirmation
129 );
130 let mut operations = vec![TaskPlanOperation::ObserveScope];
131 operations.push(match task.task {
132 TaskKind::FormInspect => TaskPlanOperation::InspectForm,
133 TaskKind::FormFill => TaskPlanOperation::FillInputs,
134 TaskKind::FormValidate => TaskPlanOperation::ValidateForm,
135 TaskKind::FormSubmit => TaskPlanOperation::SubmitForm,
136 TaskKind::NavigationFollow => TaskPlanOperation::FollowNavigation,
137 TaskKind::NavigationSelectTab => TaskPlanOperation::SelectTab,
138 TaskKind::TableExtract => TaskPlanOperation::ExtractTable,
139 TaskKind::CollectionExtract => TaskPlanOperation::ExtractCollection,
140 TaskKind::RegionExtract => TaskPlanOperation::ExtractRegion,
141 TaskKind::FieldRead => TaskPlanOperation::ReadField,
142 TaskKind::DialogInspect => TaskPlanOperation::InspectDialog,
143 TaskKind::DialogConfirm => TaskPlanOperation::ConfirmDialog,
144 TaskKind::DialogCancel => TaskPlanOperation::CancelDialog,
145 TaskKind::PaginationNext => TaskPlanOperation::NextPage,
146 TaskKind::PaginationCollect => TaskPlanOperation::CollectPages,
147 });
148 let input_names = task.inputs.keys().cloned().collect::<Vec<_>>();
149 let steps = operations
150 .into_iter()
151 .enumerate()
152 .map(|(index, operation)| TaskPlanStep {
153 ordinal: u16::try_from(index + 1).expect("fixed operation sequence is bounded"),
154 operation,
155 input_names: if operation == TaskPlanOperation::FillInputs {
156 input_names.clone()
157 } else {
158 Vec::new()
159 },
160 requires_confirmation: confirmation_required
161 && operation != TaskPlanOperation::ObserveScope,
162 })
163 .collect();
164 let plan = TaskExecutionPlan {
165 schema_version: TASK_PLAN_SCHEMA_VERSION,
166 task_schema_version: task.schema_version,
167 task: task.task,
168 scope: task.scope.clone(),
169 limits: task.limits,
170 risk: task.risk,
171 ambiguity: task.ambiguity,
172 revision: task.revision,
173 confirmation_required,
174 steps,
175 postconditions: task.postconditions.clone(),
176 };
177 plan.validate()?;
178 Ok(plan)
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct TaskCompilationError {
184 pub path: String,
185 pub reason: String,
186}
187
188impl TaskCompilationError {
189 fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
190 Self {
191 path: path.into(),
192 reason: reason.into(),
193 }
194 }
195}
196
197impl From<TaskProtocolError> for TaskCompilationError {
198 fn from(error: TaskProtocolError) -> Self {
199 Self {
200 path: error.path,
201 reason: error.reason,
202 }
203 }
204}
205
206impl Display for TaskCompilationError {
207 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
208 write!(formatter, "{}: {}", self.path, self.reason)
209 }
210}
211
212impl Error for TaskCompilationError {}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::task_protocol::{TaskAmbiguityPolicy, TaskLimits, TaskPostconditionKind, TaskScope};
218 use std::collections::BTreeMap;
219
220 fn task(kind: TaskKind, risk: TaskRiskClass) -> GlassTask {
221 GlassTask {
222 schema_version: TASK_PROTOCOL_SCHEMA_VERSION,
223 task: kind,
224 scope: TaskScope {
225 region_name: Some("Checkout".into()),
226 ..TaskScope::default()
227 },
228 inputs: BTreeMap::from([(String::from("email"), String::from("a@example.test"))]),
229 limits: TaskLimits::default(),
230 risk,
231 ambiguity: TaskAmbiguityPolicy::Fail,
232 revision: Default::default(),
233 postconditions: vec![TaskPostcondition {
234 kind: TaskPostconditionKind::ValidationClear,
235 expected: None,
236 }],
237 }
238 }
239
240 #[test]
241 fn compiler_emits_stable_semantic_operations_without_values() {
242 let plan = compile_task(&task(TaskKind::FormFill, TaskRiskClass::LocalMutation)).unwrap();
243 assert_eq!(
244 plan.steps
245 .iter()
246 .map(|step| step.operation)
247 .collect::<Vec<_>>(),
248 vec![
249 TaskPlanOperation::ObserveScope,
250 TaskPlanOperation::FillInputs
251 ]
252 );
253 assert_eq!(plan.steps[1].input_names, vec!["email"]);
254 assert_eq!(plan.scope.region_name.as_deref(), Some("Checkout"));
255 assert_eq!(plan.limits, TaskLimits::default());
256 assert_eq!(plan.ambiguity, TaskAmbiguityPolicy::Fail);
257 assert_eq!(plan.revision, TaskRevisionPolicy::Exact);
258 assert!(!plan.to_canonical_json().unwrap().contains("a@example.test"));
259 let first = plan.to_canonical_json().unwrap();
260 let second = compile_task(&task(TaskKind::FormFill, TaskRiskClass::LocalMutation))
261 .unwrap()
262 .to_canonical_json()
263 .unwrap();
264 assert_eq!(first, second);
265 }
266
267 #[test]
268 fn compiler_preserves_non_default_execution_guards() {
269 let mut authored = task(TaskKind::NavigationFollow, TaskRiskClass::RemoteReversible);
270 authored.scope.region_name = Some("Shipping".into());
271 authored.limits = TaskLimits {
272 max_actions: 7,
273 timeout_ms: 2_500,
274 max_items: 9,
275 };
276 authored.ambiguity = TaskAmbiguityPolicy::RequireConfirmation;
277 authored.revision = TaskRevisionPolicy::Compatible;
278
279 let plan = compile_task(&authored).unwrap();
280
281 assert_eq!(plan.scope.region_name.as_deref(), Some("Shipping"));
282 assert_eq!(plan.limits, authored.limits);
283 assert_eq!(plan.ambiguity, authored.ambiguity);
284 assert_eq!(plan.revision, authored.revision);
285 }
286
287 #[test]
288 fn irreversible_and_explicit_confirmation_tasks_are_gated() {
289 let irreversible = compile_task(&task(
290 TaskKind::FormSubmit,
291 TaskRiskClass::RemoteIrreversible,
292 ))
293 .unwrap();
294 assert!(irreversible.confirmation_required);
295 assert!(irreversible.steps[1].requires_confirmation);
296 let mut explicit = task(TaskKind::NavigationFollow, TaskRiskClass::ReadOnly);
297 explicit.ambiguity = TaskAmbiguityPolicy::RequireConfirmation;
298 assert!(compile_task(&explicit).unwrap().confirmation_required);
299 }
300
301 #[test]
302 fn invalid_authored_tasks_fail_before_plan_emission() {
303 let mut invalid = task(TaskKind::FormFill, TaskRiskClass::LocalMutation);
304 invalid.inputs.clear();
305 let error = compile_task(&invalid).unwrap_err();
306 assert_eq!(error.path, "inputs");
307 }
308
309 #[test]
310 fn plan_validation_rejects_noncontiguous_ordinals() {
311 let mut plan =
312 compile_task(&task(TaskKind::RegionExtract, TaskRiskClass::ReadOnly)).unwrap();
313 plan.steps[1].ordinal = 3;
314 assert_eq!(plan.validate().unwrap_err().path, "steps[1].ordinal");
315 }
316}