Skip to main content

actl_core/
handoff.rs

1//! Task consent is volatile: restart, external input, or expiry revokes it.
2use serde::{Deserialize, Serialize};
3
4#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum Approval {
7    #[default]
8    Expiring,
9    OncePerRun,
10}
11/// 误触自动恢复(pardon):授权被外部输入撤销后,若撤销源与后续输入均为
12/// "良性"(纯鼠标移动,无任何按键/点击/滚轮),静默窗内无新输入且前台未变,
13/// 显示端可自动重新授权,预算内不需要用户点击。任何键盘事件永久不赦免。
14/// 流程可在 handoff.pardon_input 覆盖全局默认(逐流程优先)。
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields, default)]
17pub struct PardonPolicy {
18    pub enabled: bool,
19    /// 静默窗:最后一次外部输入后需安静的毫秒数(1000..=10000)。
20    pub quiet_ms: u64,
21    /// 每任务自动恢复次数(1..=5);耗尽后回到显式确认。
22    pub budget: u32,
23    /// 恢复前要求前台窗口与出卡时一致。
24    pub require_foreground: bool,
25    /// 仅当待执行动作非键盘通道时赦免(type/press 等注入键盘的动作永不自动恢复)。
26    pub keyboard_safe_only: bool,
27}
28impl Default for PardonPolicy {
29    fn default() -> Self {
30        Self {
31            enabled: true,
32            quiet_ms: 4000,
33            budget: 2,
34            require_foreground: true,
35            keyboard_safe_only: true,
36        }
37    }
38}
39impl PardonPolicy {
40    pub fn valid(&self) -> bool {
41        (1000..=10000).contains(&self.quiet_ms) && (1..=5).contains(&self.budget)
42    }
43    fn is_default(&self) -> bool {
44        *self == Self::default()
45    }
46}
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub struct PardonInputs {
49    pub now_ms: u64,
50    /// 撤销授权的那次输入是否为按键/点击/滚轮(硬输入)。
51    pub revoker_hard: bool,
52    /// 最后一次外部输入时间;无输入则 None。
53    pub last_input_ms: Option<u64>,
54    /// 撤销之后是否出现过硬输入;有则永不赦免。
55    pub hard_input_after_revoke: bool,
56    /// 前台窗口与出卡时是否一致。
57    pub foreground_same: bool,
58    /// 待执行动作是否为键盘通道。
59    pub keyboard_action: bool,
60    pub budget_left: u32,
61}
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum PardonDecision {
64    /// 条件满足,自动重新授权。
65    Pardon,
66    /// 条件未满足但可继续等待(静默窗未满等)。
67    Wait,
68    /// 结构性不满足:出卡等显式确认,本任务不再自动恢复。
69    Never,
70}
71pub fn pardon_decision(inputs: PardonInputs, policy: &PardonPolicy) -> PardonDecision {
72    if !policy.enabled || inputs.revoker_hard || inputs.hard_input_after_revoke {
73        return PardonDecision::Never;
74    }
75    if policy.keyboard_safe_only && inputs.keyboard_action {
76        return PardonDecision::Never;
77    }
78    if inputs.budget_left == 0 {
79        return PardonDecision::Never;
80    }
81    if policy.require_foreground && !inputs.foreground_same {
82        return PardonDecision::Never;
83    }
84    match inputs.last_input_ms {
85        None => PardonDecision::Wait,
86        Some(last) if inputs.now_ms.saturating_sub(last) < policy.quiet_ms => PardonDecision::Wait,
87        Some(_) => PardonDecision::Pardon,
88    }
89}
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct Policy {
93    pub approval: Approval,
94    #[serde(default, skip_serializing_if = "PardonPolicy::is_default")]
95    pub pardon_input: PardonPolicy,
96}
97thread_local! { static POLICY: std::cell::Cell<Approval> = const { std::cell::Cell::new(Approval::Expiring) }; }
98pub fn configure(policy: Approval) {
99    POLICY.with(|p| p.set(policy));
100}
101pub fn configured() -> Approval {
102    POLICY.with(|p| p.get())
103}
104
105pub const INPUT_TAG: usize = 0x4143544c;
106pub const REQUEST_MESSAGE: u32 = 0x8000 + 46;
107pub const CHECK_MESSAGE: u32 = 0x8000 + 47;
108pub const RECOVER_MESSAGE: u32 = 0x8000 + 48;
109pub const FOREGROUND_MESSAGE: u32 = 0x8000 + 51;
110pub const PENDING: usize = 1;
111pub const ALLOWED: usize = 2;
112pub const DEFERRED: usize = 3;
113pub const YIELDED: usize = 4;
114pub const PANEL_UNAVAILABLE: usize = 5;
115pub const MONITOR_UNAVAILABLE: usize = 6;
116pub const USER_PAUSED: usize = 7;
117pub const CANCELLED: usize = 8;
118
119#[derive(Clone, Debug, Serialize, Deserialize)]
120pub struct Request {
121    pub approval: Approval,
122    pub generation: String,
123    pub call: String,
124    pub task: String,
125    pub target: String,
126    pub ts_ms: u64,
127    /// 动作种类(click/type/press…);旧执行器请求缺失时按未知处理。
128    #[serde(default)]
129    pub action: String,
130}
131impl Request {
132    pub fn fresh(&self, now: u64) -> bool {
133        self.ts_ms <= now
134            && now - self.ts_ms < 2000
135            && !self.call.is_empty()
136            && !self.task.is_empty()
137            && !self.generation.is_empty()
138    }
139}
140
141#[derive(Default)]
142pub struct Consent {
143    grant: Option<(String, u64, u64, u64)>,
144    approval: Approval,
145    reason: Option<&'static str>,
146}
147/// 授权动作自身余韵的宽限窗:授权(点击"开始"或快捷键)后的物理输入在
148/// 此窗内视为 settle(按键/鼠标抬起、手部余动),刷新基线而非撤销。
149/// 没有它,严格相等判定会让授权点击自己的 mouse-up 必然杀死授权
150/// (实测:human_takeover_or_grant_expired 于授权后第一次 check 即现)。
151pub const GRACE_MS: u64 = 750;
152impl Consent {
153    /// Called only by the companion's explicit user Start/Continue action
154    /// (button click or Ctrl+Alt+Y hotkey).
155    pub fn allow(&mut self, task: &str, input_seq: u64, now: u64) {
156        self.allow_with_policy(task, input_seq, now, Approval::Expiring);
157    }
158    pub fn allow_with_policy(&mut self, task: &str, input_seq: u64, now: u64, approval: Approval) {
159        self.approval = approval;
160        self.grant = Some((task.into(), input_seq, now, now));
161        self.reason = Some("allowed");
162    }
163    pub fn revoke(&mut self) {
164        self.revoke_with_reason("revoked");
165    }
166    pub fn revoke_with_reason(&mut self, reason: &'static str) {
167        if self.grant.take().is_some() || self.reason.is_none() {
168            self.reason = Some(reason);
169        }
170    }
171    pub fn reason(&self) -> &'static str {
172        self.reason.unwrap_or("no_grant")
173    }
174    pub fn check(&mut self, task: &str, input_seq: u64, now: u64) -> bool {
175        let Some((owner, seq, started, touched)) = self.grant.as_mut() else {
176            return false;
177        };
178        let expired = if now < *touched {
179            Some("clock_reversed")
180        } else if self.approval == Approval::Expiring && now - *started >= 600_000 {
181            Some("duration_expired")
182        } else if self.approval == Approval::Expiring && now - *touched >= 60_000 {
183            Some("idle_expired")
184        } else {
185            None
186        };
187        if let Some(reason) = expired {
188            self.revoke_with_reason(reason);
189            return false;
190        }
191        if *seq != input_seq {
192            if now - *started < GRACE_MS {
193                // 宽限窗内:授权动作的余韵,重设基线继续放行
194                *seq = input_seq;
195            } else {
196                self.revoke_with_reason("external_input");
197                return false;
198            }
199        }
200        if owner != task {
201            self.reason = Some("different_task");
202            return false;
203        }
204        *touched = now;
205        true
206    }
207    pub fn check_policy(
208        &mut self,
209        task: &str,
210        input_seq: u64,
211        now: u64,
212        approval: Approval,
213    ) -> bool {
214        if self.grant.is_some() && self.approval != approval {
215            self.reason = Some("policy_changed");
216            return false;
217        }
218        self.check(task, input_seq, now)
219    }
220}
221
222/// Ignore only events tagged by this injector and marked injected by Windows.
223pub fn external_input(injected: bool, tag: usize) -> bool {
224    !injected || tag != INPUT_TAG
225}
226
227pub fn error(reason: &str) -> crate::CtlError {
228    crate::CtlError::with_evidence(
229        crate::ErrorCode::NotActionable,
230        "desktop handoff required; user must choose Start/Continue in actl, then re-observe before retrying interrupted work",
231        serde_json::json!({"stage":"handoff", "reason":reason, "retry_input":false}),
232    )
233}
234
235pub fn foreground_error(expected: &str, actual: &str) -> crate::CtlError {
236    crate::CtlError::with_evidence(
237        crate::ErrorCode::NotActionable,
238        "physical foreground does not match the target; re-observe the window before retrying",
239        serde_json::json!({"stage":"foreground_verify", "reason":"foreground_mismatch", "expected":expected, "actual":actual}),
240    )
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    #[test]
247    fn injected_events_from_other_tools_still_yield() {
248        assert!(!external_input(true, INPUT_TAG));
249        assert!(external_input(false, INPUT_TAG));
250        assert!(external_input(false, 0));
251        assert!(external_input(true, 0));
252    }
253    #[test]
254    fn foreground_mismatch_never_recommends_elevation() {
255        let e = foreground_error("target", "");
256        assert_eq!(e.code, crate::ErrorCode::NotActionable);
257        assert!(!e.code.recovery_hint().contains("elevated"));
258    }
259    #[test]
260    fn consent_is_explicit_and_task_scoped() {
261        let mut c = Consent::default();
262        assert!(!c.check("a", 10, 100));
263        c.allow("a", 10, 100);
264        assert!(c.check("a", 10, 101));
265        assert!(!c.check("b", 10, 102));
266        assert!(c.check("a", 10, 103));
267    }
268    #[test]
269    fn same_field_input_revokes_even_without_focus_change() {
270        let mut c = Consent::default();
271        c.allow("a", 10, 10_000); // 宽限窗之外的时间原点
272        assert!(!c.check("a", 11, 10_000 + GRACE_MS + 1));
273        assert!(!c.check("a", 10, 10_000 + GRACE_MS + 2));
274        c.allow("a", 12, 10_000 + GRACE_MS + 3);
275        assert!(c.check("a", 12, 10_000 + GRACE_MS + 4));
276    }
277
278    #[test]
279    fn grant_settle_grace_absorbs_then_goes_strict() {
280        let mut c = Consent::default();
281        c.allow("a", 10, 1_000);
282        // 授权 mouse-up 与手部余动(宽限窗内):刷新基线,不撤销
283        assert!(c.check("a", 12, 1_050));
284        assert!(c.check("a", 15, 1_700));
285        // 窗外的新输入 = 外部接管,撤销
286        assert!(!c.check("a", 16, 1_000 + GRACE_MS + 5));
287        // 撤销后不可复活
288        assert!(!c.check("a", 16, 1_000 + GRACE_MS + 10));
289    }
290
291    #[test]
292    fn grace_does_not_change_task_scope_or_expiry() {
293        let mut c = Consent::default();
294        c.allow("a", 10, 1_000);
295        assert!(!c.check("b", 11, 1_100), "余韵不改任务归属");
296        assert!(c.check("a", 11, 1_100));
297        // idle/expiry 上限照旧
298        assert!(!c.check("a", 11, 1_100 + 60_000));
299    }
300    #[test]
301    fn expiry_restart_and_clock_reversal_fail_closed() {
302        let mut c = Consent::default();
303        c.allow("a", 0, 100);
304        assert!(!c.check("a", 0, 60_100));
305        c.allow("a", 0, 100);
306        assert!(!c.check("a", 0, 99));
307        c.allow("a", 0, 100);
308        for t in (101..600_100).step_by(1000) {
309            assert!(c.check("a", 0, t));
310        }
311        assert!(!c.check("a", 0, 600_100));
312        assert!(!Consent::default().check("a", 0, 101));
313    }
314
315    fn inputs(now: u64, last: Option<u64>) -> PardonInputs {
316        PardonInputs {
317            now_ms: now,
318            revoker_hard: false,
319            last_input_ms: last,
320            hard_input_after_revoke: false,
321            foreground_same: true,
322            keyboard_action: false,
323            budget_left: 2,
324        }
325    }
326    #[test]
327    fn pardon_waits_for_quiet_then_recovers_within_budget() {
328        let policy = PardonPolicy::default();
329        assert_eq!(
330            pardon_decision(inputs(10_000, Some(9_000)), &policy),
331            PardonDecision::Wait
332        );
333        assert_eq!(
334            pardon_decision(inputs(10_000, Some(10_000 - policy.quiet_ms)), &policy),
335            PardonDecision::Pardon
336        );
337        let drained = PardonInputs {
338            budget_left: 0,
339            ..inputs(10_000, Some(0))
340        };
341        assert_eq!(pardon_decision(drained, &policy), PardonDecision::Never);
342    }
343    #[test]
344    fn hard_input_keyboards_and_focus_change_never_pardon() {
345        let policy = PardonPolicy::default();
346        let hard_revoker = PardonInputs {
347            revoker_hard: true,
348            ..inputs(10_000, Some(0))
349        };
350        assert_eq!(
351            pardon_decision(hard_revoker, &policy),
352            PardonDecision::Never
353        );
354        let hard_after = PardonInputs {
355            hard_input_after_revoke: true,
356            ..inputs(10_000, Some(0))
357        };
358        assert_eq!(pardon_decision(hard_after, &policy), PardonDecision::Never);
359        let typing = PardonInputs {
360            keyboard_action: true,
361            ..inputs(10_000, Some(0))
362        };
363        assert_eq!(pardon_decision(typing, &policy), PardonDecision::Never);
364        let moved_focus = PardonInputs {
365            foreground_same: false,
366            ..inputs(10_000, Some(0))
367        };
368        assert_eq!(pardon_decision(moved_focus, &policy), PardonDecision::Never);
369        let relaxed = PardonPolicy {
370            require_foreground: false,
371            keyboard_safe_only: false,
372            ..policy
373        };
374        assert_eq!(
375            pardon_decision(
376                PardonInputs {
377                    keyboard_action: true,
378                    foreground_same: false,
379                    ..inputs(10_000, Some(0))
380                },
381                &relaxed
382            ),
383            PardonDecision::Pardon
384        );
385    }
386    #[test]
387    fn disabled_policy_and_unknown_ranges_fail_closed() {
388        assert!(
389            !PardonPolicy {
390                quiet_ms: 999,
391                ..PardonPolicy::default()
392            }
393            .valid()
394        );
395        assert!(
396            !PardonPolicy {
397                budget: 0,
398                ..PardonPolicy::default()
399            }
400            .valid()
401        );
402        let off = PardonPolicy {
403            enabled: false,
404            ..PardonPolicy::default()
405        };
406        assert_eq!(
407            pardon_decision(inputs(10_000, Some(0)), &off),
408            PardonDecision::Never
409        );
410        assert_eq!(
411            serde_json::from_str::<Policy>("{\"approval\":\"once_per_run\"}").unwrap(),
412            Policy {
413                approval: Approval::OncePerRun,
414                pardon_input: PardonPolicy::default()
415            }
416        );
417    }
418}