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