Skip to main content

glass/
task_compiler.rs

1//! Browser-free compilation from authored tasks to bounded execution plans.
2//!
3//! The compiler emits intent-level operations only. A later runtime may resolve
4//! those operations against a validated Web IR revision and apply policy gates.
5
6use 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
16/// Version of the deterministic execution-plan contract.
17pub const TASK_PLAN_SCHEMA_VERSION: u32 = 1;
18
19/// Intent-level operations emitted by the Task Protocol compiler.
20#[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/// One stable, typed step in an execution plan.
42#[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/// Browser-free output of Task Protocol compilation.
54#[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    /// Validate plan invariants before handing the plan to a runtime.
73    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    /// Serialize a validated execution plan deterministically.
215    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
222/// Compile an authored task without browser access or side effects.
223pub 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/// Path-aware compiler or plan validation failure.
293#[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}