Skip to main content

actl_core/
state.rs

1//! 显示信号协议(docs/11 §10 / docs/13 §4.1):actl 侧状态源。
2//!
3//! 原语四件套:①`state.json` 命令级状态快照(开始/结束/batch 步间写入);
4//! ②`stop-requested` 停止标志(存在即停止,粘性——只有显式动作清除);
5//! ③输入占用锁(actl-uia 的命名互斥体,"正在操控"的权威信号,本模块只约定
6//! 不实现);④时间戳心跳(消费端判失联)。
7//! 消费端(actl-signal 等)全部拉取、零订阅——"事件当触发器,拉取当真相源"
8//! (docs/13 的事故价目表:常驻事件订阅路线不可取)。
9//! 路径可注入(`SignalPaths::at`)以便测试;`default()` 用 %LOCALAPPDATA%。
10
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13
14/// 命令生命周期相位。
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Phase {
18    /// 命令开始(batch:第 i/N 步)
19    Running,
20    /// 命令成功结束
21    Done,
22    /// 命令失败结束
23    Error,
24    /// 用户停止中止
25    Stopped,
26}
27
28/// 一次命令执行的状态快照(单写者:当前 actl 进程;多读者)。
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SessionState {
31    pub pid: u32,
32    pub command: String,
33    /// 目标窗口摘要(--app 原样;无则 None)
34    pub app: Option<String>,
35    /// batch 步序 "i/N";单命令 None
36    pub step: Option<String>,
37    pub phase: Phase,
38    /// unix 毫秒时间戳(心跳)
39    pub ts_ms: u64,
40}
41
42impl SessionState {
43    pub fn now(
44        pid: u32,
45        command: &str,
46        app: Option<&str>,
47        step: Option<String>,
48        phase: Phase,
49    ) -> Self {
50        Self {
51            pid,
52            command: command.to_string(),
53            app: app.map(str::to_string),
54            step,
55            phase,
56            ts_ms: unix_ms(),
57        }
58    }
59}
60
61pub fn unix_ms() -> u64 {
62    std::time::SystemTime::now()
63        .duration_since(std::time::UNIX_EPOCH)
64        .map(|d| d.as_millis() as u64)
65        .unwrap_or(0)
66}
67
68/// 信号目录/文件的解析。默认 %LOCALAPPDATA%\actl\。
69#[derive(Debug, Clone)]
70pub struct SignalPaths {
71    pub dir: PathBuf,
72}
73
74impl Default for SignalPaths {
75    fn default() -> Self {
76        let dir = std::env::var_os("LOCALAPPDATA")
77            .map(PathBuf::from)
78            .unwrap_or_else(std::env::temp_dir)
79            .join("actl");
80        Self { dir }
81    }
82}
83
84impl SignalPaths {
85    pub fn at(dir: impl Into<PathBuf>) -> Self {
86        Self { dir: dir.into() }
87    }
88
89    pub fn state_file(&self) -> PathBuf {
90        self.dir.join("state.json")
91    }
92
93    pub fn stop_file(&self) -> PathBuf {
94        self.dir.join("stop-requested")
95    }
96
97    /// 状态快照落盘(单文件原子替换;失败静默——状态通道绝不能影响命令本身)。
98    pub fn write_state(&self, st: &SessionState) {
99        let _ = std::fs::create_dir_all(&self.dir);
100        let tmp = self.dir.join("state.json.tmp");
101        if serde_json::to_string(st)
102            .map(|s| std::fs::write(&tmp, s))
103            .is_ok()
104        {
105            let _ = std::fs::rename(&tmp, self.state_file());
106        }
107    }
108
109    pub fn read_state(&self) -> Option<SessionState> {
110        let text = std::fs::read_to_string(self.state_file()).ok()?;
111        serde_json::from_str(&text).ok()
112    }
113
114    /// 写入停止标志(粘性:清除是显式动作)。
115    pub fn request_stop(&self) {
116        let _ = std::fs::create_dir_all(&self.dir);
117        let _ = std::fs::write(self.stop_file(), unix_ms().to_string());
118    }
119
120    pub fn stop_requested(&self) -> bool {
121        self.stop_file().is_file()
122    }
123
124    /// 显式清除停止标志(actl-signal 的"清除"按钮调用)。
125    pub fn clear_stop(&self) {
126        let _ = std::fs::remove_file(self.stop_file());
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn tmp() -> SignalPaths {
135        static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
136        let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
137        let dir = std::env::temp_dir().join(format!("actl-state-test-{}-{n}", std::process::id()));
138        let _ = std::fs::remove_dir_all(&dir);
139        SignalPaths::at(&dir)
140    }
141
142    #[test]
143    fn state_roundtrip_and_fields() {
144        let p = tmp();
145        let st = SessionState::now(
146            42,
147            "press",
148            Some("记事本"),
149            Some("3/8".into()),
150            Phase::Running,
151        );
152        p.write_state(&st);
153        let back = p.read_state().expect("read back");
154        assert_eq!(back.command, "press");
155        assert_eq!(back.app.as_deref(), Some("记事本"));
156        assert_eq!(back.step.as_deref(), Some("3/8"));
157        assert_eq!(back.phase, Phase::Running);
158        assert!(back.ts_ms > 0);
159        let _ = std::fs::remove_dir_all(&p.dir);
160    }
161
162    #[test]
163    fn stop_flag_is_sticky_until_cleared() {
164        let p = tmp();
165        assert!(!p.stop_requested());
166        p.request_stop();
167        assert!(p.stop_requested());
168        p.clear_stop();
169        assert!(!p.stop_requested());
170        let _ = std::fs::remove_dir_all(&p.dir);
171    }
172
173    #[test]
174    fn phase_serializes_snake_case() {
175        assert_eq!(
176            serde_json::to_string(&Phase::Stopped).unwrap(),
177            r#""stopped""#
178        );
179    }
180}