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, 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
14/// Version of the deterministic execution-plan contract.
15pub const TASK_PLAN_SCHEMA_VERSION: u32 = 1;
16
17/// Intent-level operations emitted by the Task Protocol compiler.
18#[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/// One stable, typed step in an execution plan.
40#[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/// Browser-free output of Task Protocol compilation.
52#[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    /// Validate plan invariants before handing the plan to a runtime.
71    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    /// Serialize a validated execution plan deterministically.
111    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
118/// Compile an authored task without browser access or side effects.
119pub 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/// Path-aware compiler or plan validation failure.
182#[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}