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