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
4pub const INPUT_TAG: usize = 0x4143544c;
5pub const REQUEST_MESSAGE: u32 = 0x8000 + 46;
6pub const CHECK_MESSAGE: u32 = 0x8000 + 47;
7pub const RECOVER_MESSAGE: u32 = 0x8000 + 48;
8pub const PENDING: usize = 1;
9pub const ALLOWED: usize = 2;
10pub const DEFERRED: usize = 3;
11pub const YIELDED: usize = 4;
12pub const PANEL_UNAVAILABLE: usize = 5;
13pub const MONITOR_UNAVAILABLE: usize = 6;
14pub const USER_PAUSED: usize = 7;
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct Request {
18    pub call: String,
19    pub task: String,
20    pub target: String,
21    pub ts_ms: u64,
22}
23impl Request {
24    pub fn fresh(&self, now: u64) -> bool {
25        self.ts_ms <= now
26            && now - self.ts_ms < 2000
27            && !self.call.is_empty()
28            && !self.task.is_empty()
29    }
30}
31
32#[derive(Default)]
33pub struct Consent {
34    grant: Option<(String, u64, u64, u64)>,
35}
36/// 授权动作自身余韵的宽限窗:授权(点击"开始"或快捷键)后的物理输入在
37/// 此窗内视为 settle(按键/鼠标抬起、手部余动),刷新基线而非撤销。
38/// 没有它,严格相等判定会让授权点击自己的 mouse-up 必然杀死授权
39/// (实测:human_takeover_or_grant_expired 于授权后第一次 check 即现)。
40pub const GRACE_MS: u64 = 750;
41impl Consent {
42    /// Called only by the companion's explicit user Start/Continue action
43    /// (button click or Ctrl+Alt+Y hotkey).
44    pub fn allow(&mut self, task: &str, input_seq: u64, now: u64) {
45        self.grant = Some((task.into(), input_seq, now, now));
46    }
47    pub fn revoke(&mut self) {
48        self.grant = None;
49    }
50    pub fn check(&mut self, task: &str, input_seq: u64, now: u64) -> bool {
51        let Some((owner, seq, started, touched)) = self.grant.as_mut() else {
52            return false;
53        };
54        if now < *touched || now - *touched >= 60_000 || now - *started >= 600_000 {
55            self.revoke();
56            return false;
57        }
58        if *seq != input_seq {
59            if now - *started < GRACE_MS {
60                // 宽限窗内:授权动作的余韵,重设基线继续放行
61                *seq = input_seq;
62            } else {
63                self.revoke();
64                return false;
65            }
66        }
67        if owner != task {
68            return false;
69        }
70        *touched = now;
71        true
72    }
73}
74
75/// Ignore only events tagged by this injector and marked injected by Windows.
76pub fn external_input(injected: bool, tag: usize) -> bool {
77    !injected || tag != INPUT_TAG
78}
79
80pub fn error(reason: &str) -> crate::CtlError {
81    crate::CtlError::with_evidence(
82        crate::ErrorCode::NotActionable,
83        "desktop handoff required; user must choose Start/Continue in actl, then re-observe before retrying interrupted work",
84        serde_json::json!({"stage":"handoff", "reason":reason, "retry_input":false}),
85    )
86}
87
88pub fn foreground_error(expected: &str, actual: &str) -> crate::CtlError {
89    crate::CtlError::with_evidence(
90        crate::ErrorCode::NotActionable,
91        "physical foreground does not match the target; re-observe the window before retrying",
92        serde_json::json!({"stage":"foreground_verify", "reason":"foreground_mismatch", "expected":expected, "actual":actual}),
93    )
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    #[test]
100    fn injected_events_from_other_tools_still_yield() {
101        assert!(!external_input(true, INPUT_TAG));
102        assert!(external_input(false, INPUT_TAG));
103        assert!(external_input(false, 0));
104        assert!(external_input(true, 0));
105    }
106    #[test]
107    fn foreground_mismatch_never_recommends_elevation() {
108        let e = foreground_error("target", "");
109        assert_eq!(e.code, crate::ErrorCode::NotActionable);
110        assert!(!e.code.recovery_hint().contains("elevated"));
111    }
112    #[test]
113    fn consent_is_explicit_and_task_scoped() {
114        let mut c = Consent::default();
115        assert!(!c.check("a", 10, 100));
116        c.allow("a", 10, 100);
117        assert!(c.check("a", 10, 101));
118        assert!(!c.check("b", 10, 102));
119        assert!(c.check("a", 10, 103));
120    }
121    #[test]
122    fn same_field_input_revokes_even_without_focus_change() {
123        let mut c = Consent::default();
124        c.allow("a", 10, 10_000); // 宽限窗之外的时间原点
125        assert!(!c.check("a", 11, 10_000 + GRACE_MS + 1));
126        assert!(!c.check("a", 10, 10_000 + GRACE_MS + 2));
127        c.allow("a", 12, 10_000 + GRACE_MS + 3);
128        assert!(c.check("a", 12, 10_000 + GRACE_MS + 4));
129    }
130
131    #[test]
132    fn grant_settle_grace_absorbs_then_goes_strict() {
133        let mut c = Consent::default();
134        c.allow("a", 10, 1_000);
135        // 授权 mouse-up 与手部余动(宽限窗内):刷新基线,不撤销
136        assert!(c.check("a", 12, 1_050));
137        assert!(c.check("a", 15, 1_700));
138        // 窗外的新输入 = 外部接管,撤销
139        assert!(!c.check("a", 16, 1_000 + GRACE_MS + 5));
140        // 撤销后不可复活
141        assert!(!c.check("a", 16, 1_000 + GRACE_MS + 10));
142    }
143
144    #[test]
145    fn grace_does_not_change_task_scope_or_expiry() {
146        let mut c = Consent::default();
147        c.allow("a", 10, 1_000);
148        assert!(!c.check("b", 11, 1_100), "余韵不改任务归属");
149        assert!(c.check("a", 11, 1_100));
150        // idle/expiry 上限照旧
151        assert!(!c.check("a", 11, 1_100 + 60_000));
152    }
153    #[test]
154    fn expiry_restart_and_clock_reversal_fail_closed() {
155        let mut c = Consent::default();
156        c.allow("a", 0, 100);
157        assert!(!c.check("a", 0, 60_100));
158        c.allow("a", 0, 100);
159        assert!(!c.check("a", 0, 99));
160        c.allow("a", 0, 100);
161        for t in (101..600_100).step_by(1000) {
162            assert!(c.check("a", 0, t));
163        }
164        assert!(!c.check("a", 0, 600_100));
165        assert!(!Consent::default().check("a", 0, 101));
166    }
167}