Skip to main content

ssh_mcp/background/
job.rs

1use 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/// Status of a background job as tracked locally.
9#[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
29/// Shared, async-mutable job state handle.
30pub type SharedJobState = Arc<Mutex<JobState>>;
31
32/// Local representation of a background job running on a remote host.
33#[derive(Debug, Clone)]
34pub struct JobState {
35    /// Unique job id returned to callers.
36    pub job_id: String,
37
38    /// Remote process id.
39    pub pid: u32,
40
41    /// Local log path (spooled on the MCP server host).
42    pub log_path: PathBuf,
43
44    /// Exit code captured when the remote process completes.
45    pub exit_code: Option<i32>,
46
47    /// Current status.
48    pub status: JobStatus,
49
50    /// Why the state became untrustworthy, if applicable.
51    pub state_reason: Option<String>,
52
53    /// When job execution started.
54    pub start_time: SystemTime,
55
56    /// When job execution completed (if terminal).
57    pub completed_at: Option<SystemTime>,
58
59    /// Original command string.
60    pub command: String,
61
62    /// Opaque connection identifier (resolved by an external manager).
63    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}