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 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
34/// envelope 协议版本,与 CLI semver 解耦(ADR-006)。
35pub const ENVELOPE_VERSION: &str = "1";
36
37/// 机器可读错误码(docs/06 §4 错误码表,全集变更属 `spec` 提交)。
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
40pub enum ErrorCode {
41    /// ref 失效(fallback 链已穷尽)→ agent 应重新 snapshot
42    StaleRef,
43    /// 选择器无匹配 → 用 find 放宽条件
44    NotFound,
45    /// 权限不足(UIPI/管理员窗口)→ 提示用户,勿重试
46    PermDenied,
47    /// wait/谓词超时 → 重估前置状态
48    Timeout,
49    /// 匹配到多个元素 → 收紧条件
50    Ambiguous,
51    /// 元素存在但不可操作(disabled/offscreen)→ 先 scroll/focus;hint 会指向 `set-value`
52    NotActionable,
53    /// verify/--expect 断言未成立 → 检查前置步骤
54    AssertionFailed,
55    /// 协议/参数错误(不可恢复)→ 修正命令参数
56    Protocol,
57    /// 内部/UIA/系统层错误 → 重试一次;持续出现附 evidence 报 issue
58    Internal,
59    /// 用户主动停止:显式解除后仍须核验结果,禁止盲目重放。
60    Aborted,
61}
62
63impl ErrorCode {
64    /// 恢复建议(envelope 面向 agent,输出英文;docs 中文档为中文)。
65    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/// 统一错误类型(ADR-004):单一枚举,`code()` 映射与恢复建议的唯一处。
92/// 变体随命令实现扩充;运行路径禁止裸 unwrap/expect。
93#[derive(Debug, thiserror::Error)]
94#[error("{code:?}: {message}")]
95pub struct CtlError {
96    pub code: ErrorCode,
97    pub message: String,
98    /// 结构化证据(如 AMBIGUOUS 的候选列表,docs/12 §4 协议评审项):
99    /// agent 可免重取 snapshot 直接裁决。
100    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/// 统一成功 envelope。stdout 只允许出现本结构(或其错误变体)的序列化结果(ADR-002;
134/// 唯一例外 `--help/--version` 走人类文本,docs/06 §3.5)。
135#[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/// 失败 envelope(docs/06 §4):错误码 + 消息 + 恢复建议 + evidence 引用。
166#[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    /// 缺省取 `ErrorCode::recovery_hint()`;显式覆盖用于上下文更准的提示
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub hint: Option<String>,
182    /// 失败留证:出错时的局部元素树/截图引用(RPA 借鉴,docs/03 §3)
183    #[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}