1use crate::args_parser::WrapperOptions;
15use crate::execution_attempt::{
16 archive_attempt, create_attempt, current_attempt_number, next_attempt_number, ExecutionAttempt,
17};
18use crate::execution_env::{
19 build_execution_env, inherited_attempt, is_nested_launch_of, prefix_with_execution_env,
20 ExecutionEnv, ATTEMPT_ENV, EXECUTION_ID_ENV,
21};
22use crate::execution_identity::find_record;
23use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
24use crate::isolation::isolation_log::{append_log_file, write_log_file};
25use crate::launch_owner::{has_active_launch, process_alive};
26use chrono::Utc;
27use serde_json::{json, Value};
28use std::fs;
29use std::path::{Path, PathBuf};
30
31const LOG_HEADER: &str = "=== Start Command Log ===";
32
33#[derive(Debug, Clone)]
35pub struct LaunchPlan {
36 pub append: bool,
38 pub previous: Option<ExecutionRecord>,
40 pub attempt_number: u64,
41 pub log_path: PathBuf,
42 pub nested: bool,
44 pub checked: bool,
46 pub continues: Option<String>,
48}
49
50impl LaunchPlan {
51 pub fn fresh(log_path: PathBuf) -> Self {
53 LaunchPlan {
54 append: false,
55 previous: None,
56 attempt_number: 1,
57 log_path,
58 nested: false,
59 checked: false,
60 continues: None,
61 }
62 }
63}
64
65#[derive(Debug)]
67pub enum ClaimError {
68 Refused(String),
70 Store(String),
72}
73
74pub fn already_exists_message(uuid: &str) -> String {
75 format!(
76 "execution {} already exists; use $ --resume {} -- <command> to continue it, or pass --append-attempt",
77 uuid, uuid
78 )
79}
80
81pub fn is_execution_live(record: &ExecutionRecord) -> bool {
83 if has_active_launch(record) {
84 return true;
85 }
86 if record.status != ExecutionStatus::Executing {
87 return false;
88 }
89 if record
90 .options
91 .get("isolated")
92 .and_then(Value::as_str)
93 .is_some()
94 {
95 return crate::session_probe::probe_session(
96 record,
97 &crate::execution_control::SystemCommandRunner,
98 )
99 .alive;
100 }
101 record.pid.is_some_and(|pid| process_alive(u64::from(pid)))
102}
103
104pub fn count_logged_attempts(log_path: &Path) -> u64 {
106 fs::read_to_string(log_path)
107 .map(|content| content.matches(LOG_HEADER).count() as u64)
108 .unwrap_or(0)
109}
110
111fn log_size(log_path: &Path) -> u64 {
112 fs::metadata(log_path).map(|m| m.len()).unwrap_or(0)
113}
114
115pub struct PreflightParams<'a> {
117 pub store: Option<&'a ExecutionStore>,
118 pub session_id: &'a str,
119 pub explicit_session_id: bool,
120 pub append_attempt: bool,
121 pub log_path: PathBuf,
122 pub env_execution_id: Option<String>,
124 pub env_attempt: Option<String>,
125}
126
127pub fn preflight_launch(params: PreflightParams<'_>) -> Result<LaunchPlan, String> {
129 let session_id = params.session_id;
130 let fresh = LaunchPlan::fresh(params.log_path.clone());
131 if !params.explicit_session_id {
132 return Ok(fresh);
133 }
134 if is_nested_launch_of(session_id, params.env_execution_id.as_deref()) {
135 return Ok(LaunchPlan {
136 nested: true,
137 attempt_number: inherited_attempt(params.env_attempt.as_deref()),
138 ..fresh
139 });
140 }
141 let existing = params.store.and_then(|store| store.get(session_id));
142 if let Some(existing) = existing {
143 if existing.uuid != session_id {
144 return Err(format!(
145 "execution id {} is already used as a name of execution {}; pick another --session-id",
146 session_id, existing.uuid
147 ));
148 }
149 if !params.append_attempt {
150 return Err(already_exists_message(session_id));
151 }
152 if is_execution_live(&existing) {
153 return Err(format!(
154 "execution {} is still running; wait for it to finish or use $ --stop {} before --append-attempt",
155 session_id, session_id
156 ));
157 }
158 let log_path = if existing.log_path.is_empty() {
159 params.log_path
160 } else {
161 PathBuf::from(&existing.log_path)
162 };
163 return Ok(LaunchPlan {
164 append: true,
165 attempt_number: next_attempt_number(&existing),
166 previous: Some(existing),
167 log_path,
168 checked: true,
169 ..fresh
170 });
171 }
172 if params.log_path.exists() {
174 if !params.append_attempt {
175 return Err(already_exists_message(session_id));
176 }
177 return Ok(LaunchPlan {
178 append: true,
179 attempt_number: count_logged_attempts(¶ms.log_path) + 1,
180 checked: true,
181 ..fresh
182 });
183 }
184 Ok(LaunchPlan {
185 checked: true,
186 ..fresh
187 })
188}
189
190pub fn prepare_launch(
193 store: Option<&ExecutionStore>,
194 options: &WrapperOptions,
195 session_id: &str,
196 log_path: PathBuf,
197) -> Result<LaunchPlan, String> {
198 let mut launch = preflight_launch(PreflightParams {
199 store,
200 session_id,
201 explicit_session_id: options.session_id.is_some(),
202 append_attempt: options.append_attempt,
203 log_path,
204 env_execution_id: std::env::var(EXECUTION_ID_ENV).ok(),
205 env_attempt: std::env::var(ATTEMPT_ENV).ok(),
206 })?;
207 if let Some(identifier) = &options.continues {
208 launch.continues = Some(crate::execution_chain::resolve_continues(
209 store, identifier, session_id,
210 )?);
211 }
212 Ok(launch)
213}
214
215fn option_str(record: &ExecutionRecord, key: &str) -> Option<String> {
216 record
217 .options
218 .get(key)
219 .and_then(Value::as_str)
220 .map(str::to_string)
221}
222
223pub fn merge_appended_attempt(record: &mut ExecutionRecord, previous: &ExecutionRecord) {
226 let mut prior = previous.clone();
227 if prior.attempt.is_none() && current_attempt_number(&prior) == 1 {
228 prior.attempt = Some(ExecutionAttempt {
230 number: 1,
231 started_at: prior.start_time.clone(),
232 log_offset: Some(0),
233 last_output_at: None,
234 launch_accepted_at: None,
235 watcher_attached_at: None,
236 mode: String::new(),
237 previous_session_name: None,
238 session_name: None,
239 watcher_error: None,
240 });
241 }
242 let session_name = option_str(record, "sessionName");
243 let mut attempt = create_attempt(
244 &prior,
245 "append-attempt",
246 session_name.as_deref().unwrap_or(""),
247 );
248 attempt.session_name = session_name.clone();
249 archive_attempt(&mut prior);
250 let mut history: Vec<String> = prior
251 .options
252 .get("sessionNameHistory")
253 .and_then(Value::as_array)
254 .map(|items| {
255 items
256 .iter()
257 .filter_map(Value::as_str)
258 .map(str::to_string)
259 .collect()
260 })
261 .unwrap_or_default();
262 if let Some(old) = option_str(&prior, "sessionName") {
263 if Some(&old) != session_name.as_ref() {
264 history.push(old);
265 }
266 }
267 let mut unique: Vec<String> = Vec::new();
268 for name in history {
269 if !unique.contains(&name) {
270 unique.push(name);
271 }
272 }
273 record.attempt = Some(attempt);
274 record.attempt_history = prior.attempt_history.clone();
275 record.start_time = prior.start_time.clone();
276 if !prior.log_path.is_empty() {
277 record.log_path = prior.log_path.clone();
278 }
279 if record.continues.is_none() {
280 record.continues = prior.continues.clone();
281 }
282 if !unique.is_empty() {
283 record
284 .options
285 .insert("sessionNameHistory".into(), json!(unique));
286 }
287 for key in ["aliases", "name", "resumeCount"] {
288 if let Some(value) = prior.options.get(key).filter(|v| !is_empty_value(v)) {
289 record.options.insert(key.into(), value.clone());
290 }
291 }
292}
293
294fn is_empty_value(value: &Value) -> bool {
295 match value {
296 Value::Null => true,
297 Value::Bool(b) => !b,
298 Value::String(s) => s.is_empty(),
299 Value::Number(n) => n.as_f64() == Some(0.0),
300 _ => false,
301 }
302}
303
304pub fn apply_launch(record: &mut ExecutionRecord, launch: &LaunchPlan) {
307 if let Some(continues) = &launch.continues {
308 record.continues = Some(continues.clone());
309 }
310 if let Some(previous) = &launch.previous {
311 merge_appended_attempt(record, previous);
312 } else if launch.attempt_number > 1 {
313 record.attempt = Some(ExecutionAttempt {
315 number: launch.attempt_number,
316 started_at: Utc::now().to_rfc3339(),
317 log_offset: Some(if launch.append {
318 log_size(&launch.log_path)
319 } else {
320 0
321 }),
322 last_output_at: None,
323 launch_accepted_at: None,
324 watcher_attached_at: None,
325 mode: if launch.nested {
326 "nested".into()
327 } else {
328 "append-attempt".into()
329 },
330 previous_session_name: None,
331 session_name: option_str(record, "sessionName"),
332 watcher_error: None,
333 });
334 }
335}
336
337pub fn claim_launch(
341 store: &ExecutionStore,
342 record: &ExecutionRecord,
343 launch: &LaunchPlan,
344) -> Result<(), ClaimError> {
345 if !launch.checked {
346 return store.save(record).map_err(ClaimError::Store);
347 }
348 let mut refused: Option<String> = None;
349 let result = store.with_lock(|records| {
350 let index = records.iter().position(|r| r.uuid == record.uuid);
351 if let Some(previous) = &launch.previous {
352 let attempt_of = |r: &ExecutionRecord| r.attempt.as_ref().map_or(0, |a| a.number);
353 let claimable = index.is_some_and(|i| {
354 !has_active_launch(&records[i]) && attempt_of(&records[i]) == attempt_of(previous)
355 });
356 if !claimable {
357 let message = format!("execution {} was claimed by another launch", record.uuid);
358 refused = Some(message.clone());
359 return Err(message);
360 }
361 records[index.unwrap()] = record.clone();
362 } else {
363 if find_record(records, &record.uuid).is_some() {
364 let message = already_exists_message(&record.uuid);
365 refused = Some(message.clone());
366 return Err(message);
367 }
368 records.push(record.clone());
369 }
370 Ok(Some(record.clone()))
371 });
372 match (result, refused) {
373 (Ok(_), _) => Ok(()),
374 (Err(_), Some(message)) => Err(ClaimError::Refused(message)),
375 (Err(error), None) => Err(ClaimError::Store(error)),
376 }
377}
378
379pub fn launch_log_content(
383 content: &str,
384 launch: &LaunchPlan,
385 record: Option<&ExecutionRecord>,
386) -> String {
387 if !launch.append {
388 return content.to_string();
389 }
390 let mut lifecycle = json!({
391 "event": "attempt-appended",
392 "at": Utc::now().to_rfc3339(),
393 "uuid": record.map(|r| r.uuid.clone()),
394 "attemptNumber": launch.attempt_number,
395 });
396 if let Some(Value::Object(attempt)) = record
397 .and_then(|r| r.attempt.as_ref())
398 .and_then(|a| serde_json::to_value(a).ok())
399 {
400 if let Value::Object(map) = &mut lifecycle {
401 map.extend(attempt);
402 }
403 }
404 format!("\n[Start Command Lifecycle] {}\n{}", lifecycle, content)
405}
406
407pub fn write_launch_log(
410 log_path: &PathBuf,
411 content: &str,
412 launch: &LaunchPlan,
413 record: Option<&ExecutionRecord>,
414) -> bool {
415 let text = launch_log_content(content, launch, record);
416 if launch.append {
417 append_log_file(log_path, &text)
418 } else {
419 write_log_file(log_path, &text)
420 }
421}
422
423pub fn direct_execution_env(session_id: &str, launch: &LaunchPlan) -> ExecutionEnv {
426 build_execution_env(Some(session_id), Some(""), launch.attempt_number)
427}
428
429pub fn isolated_user_command(command: &str, session_id: &str, launch: &LaunchPlan) -> String {
432 prefix_with_execution_env(command, &direct_execution_env(session_id, launch))
433}