Skip to main content

actl_core/
lib.rs

1//! actl-core —— 协议层:JSON envelope、错误码、错误映射。
2//!
3//! 本 crate 是纯逻辑层,禁止依赖任何 Windows 相关 crate(AGENTS.md §5.5)。
4//! 契约的权威定义见 docs/06-design-blueprint.md §4/§5;变更必须走 `spec` 提交。
5
6use serde::{Deserialize, Serialize};
7
8pub mod keys;
9pub mod snapshot;
10pub mod state;
11pub mod target;
12pub mod timing;
13pub mod trace;
14
15pub use keys::{Key, KeySpec, parse_key_expr};
16pub use snapshot::{
17    ElementOut, INTERACTIVE_ROLES, SnapshotBuilder, SnapshotOutput, UiNode, is_interactive_role,
18    new_snapshot_id,
19};
20pub use target::{Target, parse_target};
21
22/// envelope 协议版本,与 CLI semver 解耦(ADR-006)。
23pub const ENVELOPE_VERSION: &str = "1";
24
25/// 机器可读错误码(docs/06 §4 错误码表,全集变更属 `spec` 提交)。
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
28pub enum ErrorCode {
29    /// ref 失效(fallback 链已穷尽)→ agent 应重新 snapshot
30    StaleRef,
31    /// 选择器无匹配 → 用 find 放宽条件
32    NotFound,
33    /// 权限不足(UIPI/管理员窗口)→ 提示用户,勿重试
34    PermDenied,
35    /// wait/谓词超时 → 重估前置状态
36    Timeout,
37    /// 匹配到多个元素 → 收紧条件
38    Ambiguous,
39    /// 元素存在但不可操作(disabled/offscreen)→ 先 scroll/focus;hint 会指向 `set-value`
40    NotActionable,
41    /// verify/--expect 断言未成立 → 检查前置步骤
42    AssertionFailed,
43    /// 协议/参数错误(不可恢复)→ 修正命令参数
44    Protocol,
45    /// 内部/UIA/系统层错误 → 重试一次;持续出现附 evidence 报 issue
46    Internal,
47    /// 用户主动停止(紧急热键/停止文件)→ 清除停止标志后可安全重试
48    Aborted,
49}
50
51impl ErrorCode {
52    /// 恢复建议(envelope 面向 agent,输出英文;docs 中文档为中文)。
53    pub fn recovery_hint(self) -> &'static str {
54        match self {
55            Self::StaleRef => "run `snapshot` to refresh refs, then retry",
56            Self::NotFound => "relax the selector or run `find` to inspect candidates",
57            Self::PermDenied => {
58                "the target runs at a higher integrity level; ask the user to relaunch actl elevated — do not retry"
59            }
60            Self::Timeout => "re-evaluate preconditions; the UI may be busy or slow",
61            Self::Ambiguous => "tighten the selector or pick by index",
62            Self::NotActionable => {
63                "scroll or focus the element first; for value writes consider `set-value`"
64            }
65            Self::AssertionFailed => {
66                "inspect the prior step's result; the expected state did not materialize"
67            }
68            Self::Protocol => "fix the command arguments (see `actl <cmd> -h`)",
69            Self::Internal => {
70                "retry once; if it persists, report an issue with the `evidence` payload"
71            }
72            Self::Aborted => {
73                "user requested stop; clear the stop flag (actl-signal UI or delete the file) and retry"
74            }
75        }
76    }
77}
78
79/// 统一错误类型(ADR-004):单一枚举,`code()` 映射与恢复建议的唯一处。
80/// 变体随命令实现扩充;运行路径禁止裸 unwrap/expect。
81#[derive(Debug, thiserror::Error)]
82#[error("{code:?}: {message}")]
83pub struct CtlError {
84    pub code: ErrorCode,
85    pub message: String,
86    /// 结构化证据(如 AMBIGUOUS 的候选列表,docs/12 §4 协议评审项):
87    /// agent 可免重取 snapshot 直接裁决。
88    pub evidence: Option<serde_json::Value>,
89}
90
91impl CtlError {
92    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
93        Self {
94            code,
95            message: message.into(),
96            evidence: None,
97        }
98    }
99
100    pub fn with_evidence(
101        code: ErrorCode,
102        message: impl Into<String>,
103        evidence: serde_json::Value,
104    ) -> Self {
105        Self {
106            code,
107            message: message.into(),
108            evidence: Some(evidence),
109        }
110    }
111
112    pub fn protocol(message: impl Into<String>) -> Self {
113        Self::new(ErrorCode::Protocol, message)
114    }
115
116    pub fn internal(message: impl Into<String>) -> Self {
117        Self::new(ErrorCode::Internal, message)
118    }
119}
120
121/// 统一成功 envelope。stdout 只允许出现本结构(或其错误变体)的序列化结果(ADR-002;
122/// 唯一例外 `--help/--version` 走人类文本,docs/06 §3.5)。
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct Envelope<T> {
125    pub version: String,
126    pub ok: bool,
127    pub command: String,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub data: Option<T>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub snapshot_id: Option<String>,
132    pub duration_ms: u64,
133}
134
135impl<T> Envelope<T> {
136    pub fn success(command: impl Into<String>, data: Option<T>, duration_ms: u64) -> Self {
137        Self {
138            version: ENVELOPE_VERSION.to_owned(),
139            ok: true,
140            command: command.into(),
141            data,
142            snapshot_id: None,
143            duration_ms,
144        }
145    }
146
147    pub fn with_snapshot_id(mut self, id: impl Into<String>) -> Self {
148        self.snapshot_id = Some(id.into());
149        self
150    }
151}
152
153/// 失败 envelope(docs/06 §4):错误码 + 消息 + 恢复建议 + evidence 引用。
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct ErrorEnvelope {
156    pub version: String,
157    pub ok: bool,
158    pub command: String,
159    pub error: ErrorBody,
160    pub duration_ms: u64,
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164pub struct ErrorBody {
165    pub code: ErrorCode,
166    pub message: String,
167    /// 缺省取 `ErrorCode::recovery_hint()`;显式覆盖用于上下文更准的提示
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub hint: Option<String>,
170    /// 失败留证:出错时的局部元素树/截图引用(RPA 借鉴,docs/03 §3)
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub evidence: Option<serde_json::Value>,
173}
174
175impl ErrorEnvelope {
176    pub fn new(command: impl Into<String>, err: &CtlError, duration_ms: u64) -> Self {
177        Self {
178            version: ENVELOPE_VERSION.to_owned(),
179            ok: false,
180            command: command.into(),
181            error: ErrorBody {
182                code: err.code,
183                message: err.message.clone(),
184                hint: Some(err.code.recovery_hint().to_owned()),
185                evidence: err.evidence.clone(),
186            },
187            duration_ms,
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use serde_json::json;
196
197    #[test]
198    fn error_codes_serialize_as_screaming_snake_case() {
199        assert_eq!(
200            serde_json::to_string(&ErrorCode::StaleRef).unwrap(),
201            r#""STALE_REF""#
202        );
203        assert_eq!(
204            serde_json::to_string(&ErrorCode::AssertionFailed).unwrap(),
205            r#""ASSERTION_FAILED""#
206        );
207        assert_eq!(
208            serde_json::to_string(&ErrorCode::Internal).unwrap(),
209            r#""INTERNAL""#
210        );
211    }
212
213    #[test]
214    fn every_error_code_has_a_recovery_hint() {
215        let all = [
216            ErrorCode::StaleRef,
217            ErrorCode::NotFound,
218            ErrorCode::PermDenied,
219            ErrorCode::Timeout,
220            ErrorCode::Ambiguous,
221            ErrorCode::NotActionable,
222            ErrorCode::AssertionFailed,
223            ErrorCode::Protocol,
224            ErrorCode::Aborted,
225            ErrorCode::Internal,
226        ];
227        for c in all {
228            assert!(!c.recovery_hint().is_empty(), "{c:?} missing hint");
229        }
230    }
231
232    #[test]
233    fn success_envelope_shape_matches_contract() {
234        let data = json!({ "action": "click", "ref": "@e3" });
235        let env = Envelope::success("click", Some(&data), 42);
236        let json: serde_json::Value = serde_json::to_value(&env).unwrap();
237        assert_eq!(json["version"], "1");
238        assert_eq!(json["ok"], true);
239        assert_eq!(json["command"], "click");
240        assert_eq!(json["duration_ms"], 42);
241        assert_eq!(json["data"]["action"], "click");
242    }
243
244    #[test]
245    fn none_data_and_snapshot_id_are_omitted() {
246        let env = Envelope::success("snapshot", None::<u8>, 5);
247        let json = serde_json::to_string(&env).unwrap();
248        assert!(!json.contains("\"data\""));
249        assert!(!json.contains("\"snapshot_id\""));
250    }
251
252    #[test]
253    fn snapshot_id_round_trips() {
254        let env = Envelope::success("snapshot", None::<u8>, 5).with_snapshot_id("s8f3k2p9");
255        let json: serde_json::Value = serde_json::to_value(&env).unwrap();
256        assert_eq!(json["snapshot_id"], "s8f3k2p9");
257    }
258
259    #[test]
260    fn error_envelope_shape_matches_contract() {
261        let err = CtlError::new(ErrorCode::StaleRef, "element @e3 no longer resolves");
262        let env = ErrorEnvelope::new("click", &err, 17);
263        let json: serde_json::Value = serde_json::to_value(&env).unwrap();
264        assert_eq!(json["ok"], false);
265        assert_eq!(json["command"], "click");
266        assert_eq!(json["error"]["code"], "STALE_REF");
267        assert!(json["error"]["hint"].as_str().unwrap().contains("snapshot"));
268        assert!(json.get("evidence").is_none());
269    }
270}