ssh_mcp/background/
job.rs1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6use tokio::sync::Mutex;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum JobStatus {
12 Running,
13 Completed,
14 Failed,
15 StateLost,
16}
17
18impl JobStatus {
19 pub const fn as_str(self) -> &'static str {
20 match self {
21 Self::Running => "running",
22 Self::Completed => "completed",
23 Self::Failed => "failed",
24 Self::StateLost => "state_lost",
25 }
26 }
27}
28
29pub type SharedJobState = Arc<Mutex<JobState>>;
31
32#[derive(Debug, Clone)]
34pub struct JobState {
35 pub job_id: String,
37
38 pub pid: u32,
40
41 pub log_path: PathBuf,
43
44 pub exit_code: Option<i32>,
46
47 pub status: JobStatus,
49
50 pub state_reason: Option<String>,
52
53 pub start_time: SystemTime,
55
56 pub completed_at: Option<SystemTime>,
58
59 pub command: String,
61
62 pub connection_id: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub(crate) struct PersistedJobState {
68 pub version: u8,
69 pub job_id: String,
70 pub pid: u32,
71 pub log_path: String,
72 pub exit_code: Option<i32>,
73 pub status: JobStatus,
74 pub state_reason: Option<String>,
75 pub start_time_unix_ms: u64,
76 pub completed_at_unix_ms: Option<u64>,
77 pub command: String,
78 pub connection_id: String,
79}
80
81#[derive(Debug, Clone)]
82pub struct NewRunningJob {
83 pub job_id: String,
84 pub pid: u32,
85 pub log_path: PathBuf,
86 pub command: String,
87 pub connection_id: String,
88}
89
90fn format_ps_elapsed(elapsed: Duration) -> String {
91 let total_secs = elapsed.as_secs();
92 let days = total_secs / 86_400;
93 let hours = (total_secs % 86_400) / 3_600;
94 let minutes = (total_secs % 3_600) / 60;
95 let seconds = total_secs % 60;
96
97 if days > 0 {
98 format!("{days}-{hours:02}:{minutes:02}:{seconds:02}")
99 } else if hours > 0 {
100 format!("{hours:02}:{minutes:02}:{seconds:02}")
101 } else {
102 format!("{minutes:02}:{seconds:02}")
103 }
104}
105
106impl JobState {
107 pub fn new_running(opts: NewRunningJob) -> Self {
108 Self {
109 job_id: opts.job_id,
110 pid: opts.pid,
111 log_path: opts.log_path,
112 exit_code: None,
113 status: JobStatus::Running,
114 state_reason: None,
115 start_time: SystemTime::now(),
116 completed_at: None,
117 command: opts.command,
118 connection_id: opts.connection_id,
119 }
120 }
121
122 pub fn is_terminal(&self) -> bool {
123 matches!(
124 self.status,
125 JobStatus::Completed | JobStatus::Failed | JobStatus::StateLost
126 )
127 }
128
129 pub fn elapsed_time(&self) -> String {
130 let end_time = self.completed_at.unwrap_or_else(SystemTime::now);
131 let elapsed = end_time.duration_since(self.start_time).unwrap_or_default();
132 format_ps_elapsed(elapsed)
133 }
134
135 pub fn mark_exit(&mut self, exit_code: i32) {
136 self.exit_code = Some(exit_code);
137 self.completed_at.get_or_insert_with(SystemTime::now);
138 self.status = if exit_code == 0 {
139 JobStatus::Completed
140 } else {
141 JobStatus::Failed
142 };
143 self.state_reason = None;
144 }
145
146 pub fn mark_state_lost(&mut self, reason: impl Into<String>) {
147 self.exit_code = None;
148 self.completed_at.get_or_insert_with(SystemTime::now);
149 self.status = JobStatus::StateLost;
150 self.state_reason = Some(reason.into());
151 }
152
153 pub(crate) fn to_persisted(&self) -> PersistedJobState {
154 PersistedJobState {
155 version: 1,
156 job_id: self.job_id.clone(),
157 pid: self.pid,
158 log_path: self.log_path.to_string_lossy().to_string(),
159 exit_code: self.exit_code,
160 status: self.status,
161 state_reason: self.state_reason.clone(),
162 start_time_unix_ms: system_time_to_unix_ms(self.start_time),
163 completed_at_unix_ms: self.completed_at.map(system_time_to_unix_ms),
164 command: self.command.clone(),
165 connection_id: self.connection_id.clone(),
166 }
167 }
168
169 pub(crate) fn from_persisted(
170 persisted: PersistedJobState,
171 ) -> std::result::Result<Self, &'static str> {
172 if persisted.version != 1 {
173 return Err("unsupported persisted job state version");
174 }
175
176 Ok(Self {
177 job_id: persisted.job_id,
178 pid: persisted.pid,
179 log_path: PathBuf::from(persisted.log_path),
180 exit_code: persisted.exit_code,
181 status: persisted.status,
182 state_reason: persisted.state_reason,
183 start_time: unix_ms_to_system_time(persisted.start_time_unix_ms),
184 completed_at: persisted.completed_at_unix_ms.map(unix_ms_to_system_time),
185 command: persisted.command,
186 connection_id: persisted.connection_id,
187 })
188 }
189}
190
191fn system_time_to_unix_ms(value: SystemTime) -> u64 {
192 value
193 .duration_since(UNIX_EPOCH)
194 .unwrap_or_default()
195 .as_millis()
196 .min(u64::MAX as u128) as u64
197}
198
199fn unix_ms_to_system_time(value: u64) -> SystemTime {
200 UNIX_EPOCH + Duration::from_millis(value)
201}