1use serde::{Deserialize, Serialize};
7
8pub mod activity;
9pub mod control_log;
10pub mod display;
11pub mod handoff;
12pub mod history;
13pub mod keys;
14pub mod log_health;
15pub mod log_integrity;
16pub mod reports;
17pub mod selector;
18pub mod signal_session;
19pub mod snapshot;
20pub mod state;
21pub mod stop;
22pub mod target;
23pub mod timing;
24pub mod trace;
25pub mod wait_control;
26
27pub use keys::{Key, KeySpec, parse_key_expr};
28pub use snapshot::{
29 ElementOut, INTERACTIVE_ROLES, Projection, SnapshotBuilder, SnapshotOutput, UiNode,
30 is_interactive_role, new_snapshot_id,
31};
32pub use target::{Target, parse_target};
33
34pub const ENVELOPE_VERSION: &str = "1";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
40pub enum ErrorCode {
41 StaleRef,
43 NotFound,
45 PermDenied,
47 Timeout,
49 Ambiguous,
51 NotActionable,
53 AssertionFailed,
55 Protocol,
57 Internal,
59 Aborted,
61}
62
63impl ErrorCode {
64 pub fn recovery_hint(self) -> &'static str {
66 match self {
67 Self::StaleRef => "run `snapshot` to refresh refs, then retry",
68 Self::NotFound => "relax the selector or run `find` to inspect candidates",
69 Self::PermDenied => {
70 "the target runs at a higher integrity level; ask the user to relaunch actl elevated — do not retry"
71 }
72 Self::Timeout => "re-evaluate preconditions; the UI may be busy or slow",
73 Self::Ambiguous => "tighten the selector or pick by index",
74 Self::NotActionable => {
75 "scroll or focus the element first; for value writes consider `set-value`"
76 }
77 Self::AssertionFailed => {
78 "inspect the prior step's result; the expected state did not materialize"
79 }
80 Self::Protocol => "fix the command arguments (see `actl <cmd> -h`)",
81 Self::Internal => {
82 "retry once; if it persists, report an issue with the `evidence` payload"
83 }
84 Self::Aborted => {
85 "user requested stop; explicitly release emergency stop in actl-signal, inspect prior results, then explicitly continue; never replay automatically"
86 }
87 }
88 }
89}
90
91#[derive(Debug, thiserror::Error)]
94#[error("{code:?}: {message}")]
95pub struct CtlError {
96 pub code: ErrorCode,
97 pub message: String,
98 pub evidence: Option<serde_json::Value>,
101}
102
103impl CtlError {
104 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
105 Self {
106 code,
107 message: message.into(),
108 evidence: None,
109 }
110 }
111
112 pub fn with_evidence(
113 code: ErrorCode,
114 message: impl Into<String>,
115 evidence: serde_json::Value,
116 ) -> Self {
117 Self {
118 code,
119 message: message.into(),
120 evidence: Some(evidence),
121 }
122 }
123
124 pub fn protocol(message: impl Into<String>) -> Self {
125 Self::new(ErrorCode::Protocol, message)
126 }
127
128 pub fn internal(message: impl Into<String>) -> Self {
129 Self::new(ErrorCode::Internal, message)
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct Envelope<T> {
137 pub version: String,
138 pub ok: bool,
139 pub command: String,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub data: Option<T>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub snapshot_id: Option<String>,
144 pub duration_ms: u64,
145}
146
147impl<T> Envelope<T> {
148 pub fn success(command: impl Into<String>, data: Option<T>, duration_ms: u64) -> Self {
149 Self {
150 version: ENVELOPE_VERSION.to_owned(),
151 ok: true,
152 command: command.into(),
153 data,
154 snapshot_id: None,
155 duration_ms,
156 }
157 }
158
159 pub fn with_snapshot_id(mut self, id: impl Into<String>) -> Self {
160 self.snapshot_id = Some(id.into());
161 self
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct ErrorEnvelope {
168 pub version: String,
169 pub ok: bool,
170 pub command: String,
171 pub error: ErrorBody,
172 pub duration_ms: u64,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub struct ErrorBody {
177 pub code: ErrorCode,
178 pub message: String,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub hint: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub evidence: Option<serde_json::Value>,
185}
186
187impl ErrorEnvelope {
188 pub fn new(command: impl Into<String>, err: &CtlError, duration_ms: u64) -> Self {
189 Self {
190 version: ENVELOPE_VERSION.to_owned(),
191 ok: false,
192 command: command.into(),
193 error: ErrorBody {
194 code: err.code,
195 message: err.message.clone(),
196 hint: Some(if err.evidence.as_ref().is_some_and(|e| e["stage"] == "handoff") {
197 "wait for explicit user handoff; inspect partial results before retrying; never clear emergency stop automatically"
198 } else { err.code.recovery_hint() }.to_owned()),
199 evidence: err.evidence.clone(),
200 },
201 duration_ms,
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use serde_json::json;
210
211 #[test]
212 fn error_codes_serialize_as_screaming_snake_case() {
213 assert_eq!(
214 serde_json::to_string(&ErrorCode::StaleRef).unwrap(),
215 r#""STALE_REF""#
216 );
217 assert_eq!(
218 serde_json::to_string(&ErrorCode::AssertionFailed).unwrap(),
219 r#""ASSERTION_FAILED""#
220 );
221 assert_eq!(
222 serde_json::to_string(&ErrorCode::Internal).unwrap(),
223 r#""INTERNAL""#
224 );
225 }
226
227 #[test]
228 fn every_error_code_has_a_recovery_hint() {
229 let all = [
230 ErrorCode::StaleRef,
231 ErrorCode::NotFound,
232 ErrorCode::PermDenied,
233 ErrorCode::Timeout,
234 ErrorCode::Ambiguous,
235 ErrorCode::NotActionable,
236 ErrorCode::AssertionFailed,
237 ErrorCode::Protocol,
238 ErrorCode::Aborted,
239 ErrorCode::Internal,
240 ];
241 for c in all {
242 assert!(!c.recovery_hint().is_empty(), "{c:?} missing hint");
243 }
244 }
245
246 #[test]
247 fn success_envelope_shape_matches_contract() {
248 let data = json!({ "action": "click", "ref": "@e3" });
249 let env = Envelope::success("click", Some(&data), 42);
250 let json: serde_json::Value = serde_json::to_value(&env).unwrap();
251 assert_eq!(json["version"], "1");
252 assert_eq!(json["ok"], true);
253 assert_eq!(json["command"], "click");
254 assert_eq!(json["duration_ms"], 42);
255 assert_eq!(json["data"]["action"], "click");
256 }
257
258 #[test]
259 fn none_data_and_snapshot_id_are_omitted() {
260 let env = Envelope::success("snapshot", None::<u8>, 5);
261 let json = serde_json::to_string(&env).unwrap();
262 assert!(!json.contains("\"data\""));
263 assert!(!json.contains("\"snapshot_id\""));
264 }
265
266 #[test]
267 fn snapshot_id_round_trips() {
268 let env = Envelope::success("snapshot", None::<u8>, 5).with_snapshot_id("s8f3k2p9");
269 let json: serde_json::Value = serde_json::to_value(&env).unwrap();
270 assert_eq!(json["snapshot_id"], "s8f3k2p9");
271 }
272
273 #[test]
274 fn error_envelope_shape_matches_contract() {
275 let err = CtlError::new(ErrorCode::StaleRef, "element @e3 no longer resolves");
276 let env = ErrorEnvelope::new("click", &err, 17);
277 let json: serde_json::Value = serde_json::to_value(&env).unwrap();
278 assert_eq!(json["ok"], false);
279 assert_eq!(json["command"], "click");
280 assert_eq!(json["error"]["code"], "STALE_REF");
281 assert!(json["error"]["hint"].as_str().unwrap().contains("snapshot"));
282 assert!(json.get("evidence").is_none());
283 }
284}