1use serde_json::json;
20
21use crate::execution_control::{CommandRunner, SystemCommandRunner};
22use crate::execution_resume::{ResumeHooks, SystemResumeHooks};
23use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
24use crate::output_blocks::escape_for_links_notation;
25use crate::session_probe::{probe_session, SessionProbe, SessionState};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ResumeAllAction {
30 Reattached,
31 Running,
32 Reconciled,
33 Unknown,
34}
35
36impl ResumeAllAction {
37 pub fn as_str(self) -> &'static str {
38 match self {
39 ResumeAllAction::Reattached => "reattached",
40 ResumeAllAction::Running => "running",
41 ResumeAllAction::Reconciled => "reconciled",
42 ResumeAllAction::Unknown => "unknown",
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ResumeAllEntry {
50 pub uuid: String,
51 pub backend: Option<String>,
52 pub session_name: Option<String>,
53 pub state: SessionState,
54 pub action: ResumeAllAction,
55 pub exit_code: Option<i32>,
56 pub message: String,
57}
58
59pub struct ExecutionResumeAllResult {
61 pub success: bool,
62 pub output: Option<String>,
63 pub error: Option<String>,
64}
65
66fn describe_record(
67 record: &ExecutionRecord,
68 probe: &SessionProbe,
69 action: ResumeAllAction,
70 message: String,
71 exit_code: Option<i32>,
72) -> ResumeAllEntry {
73 ResumeAllEntry {
74 uuid: record.uuid.clone(),
75 backend: probe.backend.clone().or_else(|| {
76 record
77 .options
78 .get("isolated")
79 .and_then(|value| value.as_str())
80 .map(str::to_string)
81 }),
82 session_name: probe.session_name.clone().or_else(|| {
83 record
84 .options
85 .get("sessionName")
86 .and_then(|value| value.as_str())
87 .map(str::to_string)
88 }),
89 state: probe.state,
90 action,
91 exit_code,
92 message,
93 }
94}
95
96fn format_as_links_notation(entries: &[ResumeAllEntry]) -> String {
97 let mut lines = vec![
98 "executionResumeAll".to_string(),
99 format!(" count {}", entries.len()),
100 ];
101 for entry in entries {
102 lines.push(" execution".to_string());
103 lines.push(format!(
104 " uuid {}",
105 escape_for_links_notation(&entry.uuid)
106 ));
107 lines.push(format!(
108 " backend {}",
109 escape_for_links_notation(entry.backend.as_deref().unwrap_or(""))
110 ));
111 lines.push(format!(
112 " sessionName {}",
113 escape_for_links_notation(entry.session_name.as_deref().unwrap_or(""))
114 ));
115 lines.push(format!(
116 " state {}",
117 escape_for_links_notation(entry.state.as_str())
118 ));
119 lines.push(format!(
120 " action {}",
121 escape_for_links_notation(entry.action.as_str())
122 ));
123 if let Some(exit_code) = entry.exit_code {
124 lines.push(format!(" exitCode {}", exit_code));
125 }
126 lines.push(format!(
127 " message {}",
128 escape_for_links_notation(&entry.message)
129 ));
130 }
131 lines.join("\n")
132}
133
134fn format_as_text(entries: &[ResumeAllEntry]) -> String {
135 if entries.is_empty() {
136 return "No executions are currently marked as running.".to_string();
137 }
138 let mut lines = vec![
139 format!("Executions still marked running: {}", entries.len()),
140 String::new(),
141 ];
142 for entry in entries {
143 lines.push(format!(
144 "{} {}",
145 entry.action.as_str().to_uppercase(),
146 entry.uuid
147 ));
148 lines.push(format!(
149 " Backend: {}",
150 entry.backend.as_deref().unwrap_or("")
151 ));
152 lines.push(format!(
153 " Session Name: {}",
154 entry.session_name.as_deref().unwrap_or("")
155 ));
156 lines.push(format!(" State: {}", entry.state.as_str()));
157 lines.push(format!(" {}", entry.message));
158 lines.push(String::new());
159 }
160 lines.join("\n").trim_end().to_string()
161}
162
163fn format_as_json(entries: &[ResumeAllEntry]) -> String {
164 let executions: Vec<_> = entries
165 .iter()
166 .map(|entry| {
167 json!({
168 "uuid": entry.uuid,
169 "backend": entry.backend,
170 "sessionName": entry.session_name,
171 "state": entry.state.as_str(),
172 "action": entry.action.as_str(),
173 "exitCode": entry.exit_code,
174 "message": entry.message,
175 })
176 })
177 .collect();
178 serde_json::to_string_pretty(&json!({
179 "count": entries.len(),
180 "executions": executions,
181 }))
182 .unwrap_or_else(|_| "{}".to_string())
183}
184
185pub fn format_resume_all(entries: &[ResumeAllEntry], output_format: Option<&str>) -> String {
187 match output_format {
188 Some("json") => format_as_json(entries),
189 Some("text") => format_as_text(entries),
190 _ => format_as_links_notation(entries),
191 }
192}
193
194pub fn resume_all_executions(
196 store: Option<&ExecutionStore>,
197 output_format: Option<&str>,
198) -> ExecutionResumeAllResult {
199 resume_all_executions_with(
200 store,
201 output_format,
202 &SystemCommandRunner,
203 &SystemResumeHooks,
204 )
205}
206
207pub fn resume_all_executions_with<R: CommandRunner, H: ResumeHooks>(
209 store: Option<&ExecutionStore>,
210 output_format: Option<&str>,
211 runner: &R,
212 hooks: &H,
213) -> ExecutionResumeAllResult {
214 let Some(store) = store else {
215 return ExecutionResumeAllResult {
216 success: false,
217 output: None,
218 error: Some("Execution tracking is disabled.".to_string()),
219 };
220 };
221
222 let mut entries: Vec<ResumeAllEntry> = Vec::new();
223
224 for record in store.get_executing() {
225 let probe = probe_session(&record, runner);
226 let session_name = probe.session_name.clone().unwrap_or_default();
227
228 if probe.alive && probe.backend.as_deref() == Some("docker") {
229 hooks.start_watcher(&session_name, &record);
230 entries.push(describe_record(
231 &record,
232 &probe,
233 ResumeAllAction::Reattached,
234 format!(
235 "Re-attached completion watcher to running container: {}",
236 session_name
237 ),
238 None,
239 ));
240 continue;
241 }
242
243 if probe.alive {
244 entries.push(describe_record(
245 &record,
246 &probe,
247 ResumeAllAction::Running,
248 format!("Session is still running: {}", session_name),
249 None,
250 ));
251 continue;
252 }
253
254 if probe.state == SessionState::Unknown {
255 entries.push(describe_record(
256 &record,
257 &probe,
258 ResumeAllAction::Unknown,
259 format!(
260 "Session liveness cannot be probed locally: {}",
261 if session_name.is_empty() {
262 record.uuid.clone()
263 } else {
264 session_name.clone()
265 }
266 ),
267 None,
268 ));
269 continue;
270 }
271
272 let reconciled = hooks.reconcile(&record);
273 if reconciled.status == ExecutionStatus::Executed {
274 let exit_code = reconciled.exit_code;
275 let _ = store.save(&reconciled);
276 entries.push(describe_record(
277 &record,
278 &probe,
279 ResumeAllAction::Reconciled,
280 format!(
281 "Session ended while unsupervised; record finalized. Use `$ --resume {}` to continue it.",
282 record.uuid
283 ),
284 exit_code,
285 ));
286 continue;
287 }
288
289 entries.push(describe_record(
290 &record,
291 &probe,
292 ResumeAllAction::Unknown,
293 format!(
294 "Session is not running but no terminal result could be resolved: {}",
295 if session_name.is_empty() {
296 record.uuid.clone()
297 } else {
298 session_name.clone()
299 }
300 ),
301 None,
302 ));
303 }
304
305 ExecutionResumeAllResult {
306 success: true,
307 output: Some(format_resume_all(&entries, output_format)),
308 error: None,
309 }
310}
311
312#[cfg(test)]
313#[path = "execution_resume_all_cases.rs"]
314mod tests;