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