1use super::store::RoutineStore;
2use super::{Routine, RoutineError, RunCause, RunRecord, RunStatus, MAX_RUNS};
3use serde_json::Value;
4use std::fs::{self, File};
5use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
6use std::os::fd::AsRawFd;
7use std::os::unix::fs::PermissionsExt;
8use std::os::unix::process::CommandExt;
9use std::process::{Command, Stdio};
10use std::thread;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13const FINAL_OUTPUT_BYTES: usize = 16 * 1024;
14const STRUCTURED_LINE_BYTES: usize = 64 * 1024;
15const MAX_RUN_LOG_BYTES: usize = 8 * 1024 * 1024;
16const RUN_LOG_TAIL_BYTES: usize = 128 * 1024;
17const TRUNCATION_MARKER: &[u8] = b"\n[asched: middle output truncated]\n";
18
19pub fn expanded_argv(routine: &Routine) -> (Vec<String>, Option<&[u8]>) {
20 if routine.command.iter().any(|arg| arg == "{prompt}") {
21 (
22 routine
23 .command
24 .iter()
25 .map(|arg| {
26 if arg == "{prompt}" {
27 routine.prompt.clone()
28 } else {
29 arg.clone()
30 }
31 })
32 .collect(),
33 None,
34 )
35 } else {
36 (routine.command.clone(), Some(routine.prompt.as_bytes()))
37 }
38}
39
40pub fn execute(
41 store: &RoutineStore,
42 routine: &Routine,
43 scheduled: Option<i64>,
44) -> Result<RunRecord, RoutineError> {
45 execute_supervised(store, routine, scheduled, |_| {})
46}
47
48pub fn execute_supervised(
49 store: &RoutineStore,
50 routine: &Routine,
51 scheduled: Option<i64>,
52 on_started: impl FnOnce(&RunRecord),
53) -> Result<RunRecord, RoutineError> {
54 if routine.command.is_empty() {
55 return Err(RoutineError::Validation(
56 "command must contain at least one argv item".into(),
57 ));
58 }
59 let cause = scheduled.map_or(RunCause::Manual, |scheduled_epoch_minute| RunCause::Cron {
60 scheduled_epoch_minute,
61 });
62 let record = prepare_run_record(store, routine, cause);
63 append_record(store, record.clone())?;
64 execute_prepared_supervised(store, routine, record, None, on_started)
65}
66
67pub(crate) fn prepare_run_record(
68 store: &RoutineStore,
69 routine: &Routine,
70 cause: RunCause,
71) -> RunRecord {
72 let started = now_epoch();
73 let id = format!("{}-{}", started, now_nanos());
74 let run_dir = store.logs_dir().join(hex_name(&routine.name)).join(&id);
75 let scheduled_epoch_minute = match cause {
76 RunCause::Cron {
77 scheduled_epoch_minute,
78 } => Some(scheduled_epoch_minute),
79 _ => None,
80 };
81 RunRecord {
82 id,
83 routine: routine.name.clone(),
84 started_epoch: started,
85 finished_epoch: None,
86 cause,
87 scheduled_epoch_minute,
88 status: RunStatus::Running,
89 exit_code: None,
90 pid: None,
91 process_start: None,
92 final_output: String::new(),
93 stdout_path: run_dir.join("stdout.log"),
94 stderr_path: run_dir.join("stderr.log"),
95 }
96}
97
98pub(crate) fn execute_prepared_supervised(
99 store: &RoutineStore,
100 routine: &Routine,
101 mut record: RunRecord,
102 event_payload: Option<String>,
103 on_started: impl FnOnce(&RunRecord),
104) -> Result<RunRecord, RoutineError> {
105 if routine.command.is_empty() {
106 return Err(RoutineError::Validation(
107 "command argv must not be empty".into(),
108 ));
109 }
110 let lock_dir = store.logs_dir().join("locks");
111 fs::create_dir_all(&lock_dir)?;
112 fs::set_permissions(&lock_dir, fs::Permissions::from_mode(0o700))?;
113 let run_lock = std::fs::OpenOptions::new()
114 .read(true)
115 .write(true)
116 .create(true)
117 .truncate(false)
118 .open(lock_dir.join(hex_name(&routine.name)))?;
119 run_lock.set_permissions(fs::Permissions::from_mode(0o600))?;
120 if unsafe { libc::flock(run_lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
121 return Err(RoutineError::AlreadyRunning(routine.name.clone()));
122 }
123 let run_dir = record
124 .stdout_path
125 .parent()
126 .ok_or_else(|| RoutineError::Corrupt("run output path has no parent".into()))?;
127 fs::create_dir_all(run_dir)?;
128 fs::set_permissions(run_dir, fs::Permissions::from_mode(0o700))?;
129 let stdout_path = record.stdout_path.clone();
130 let stderr_path = record.stderr_path.clone();
131
132 let (argv, stdin_bytes) = expanded_argv(routine);
133 let mut command = Command::new(&argv[0]);
134 command.args(&argv[1..]).current_dir(store.project());
135 if let Some(payload) = event_payload {
136 command.env("ASCHED_EVENT_PAYLOAD", payload);
137 }
138 command
139 .stdin(Stdio::piped())
140 .stdout(Stdio::piped())
141 .stderr(Stdio::piped());
142 command.process_group(0);
143 let mut child = match command.spawn() {
144 Ok(child) => child,
145 Err(error) => {
146 let stdout_file = File::create(&stdout_path)?;
147 stdout_file.set_permissions(fs::Permissions::from_mode(0o600))?;
148 let mut stderr_file = File::create(&stderr_path)?;
149 stderr_file.set_permissions(fs::Permissions::from_mode(0o600))?;
150 stderr_file.write_all(error.to_string().as_bytes())?;
151 stderr_file.sync_all()?;
152 record.status = RunStatus::SpawnFailed;
153 record.finished_epoch = Some(now_epoch());
154 record.final_output = error.to_string();
155 replace_record(store, record.clone())?;
156 return Ok(record);
157 }
158 };
159 record.pid = Some(child.id() as i32);
160 record.process_start = process_start(child.id() as i32);
161 if let Err(error) = replace_record(store, record.clone()) {
162 return fail_started_run(
163 store,
164 child,
165 record,
166 &format!("failed to persist spawned process: {error}"),
167 None,
168 None,
169 );
170 }
171 on_started(&record);
172 let stdout = match child.stdout.take() {
173 Some(stdout) => stdout,
174 None => return fail_started_run(store, child, record, "stdout pipe missing", None, None),
175 };
176 let stderr = match child.stderr.take() {
177 Some(stderr) => stderr,
178 None => return fail_started_run(store, child, record, "stderr pipe missing", None, None),
179 };
180 let out_thread = drain(stdout, stdout_path.clone());
181 let err_thread = drain(stderr, stderr_path.clone());
182 if let Some(mut stdin) = child.stdin.take() {
183 if let Some(bytes) = stdin_bytes {
184 if let Err(error) = stdin.write_all(bytes) {
185 return fail_started_run(
186 store,
187 child,
188 record,
189 &format!("prompt delivery failed: {error}"),
190 Some(out_thread),
191 Some(err_thread),
192 );
193 }
194 }
195 }
196 let pid = child.id() as i32;
197 let status = child.wait()?;
198 terminate_remaining_process_group(pid);
199 out_thread
200 .join()
201 .map_err(|_| RoutineError::Io("stdout reader panicked".into()))??;
202 err_thread
203 .join()
204 .map_err(|_| RoutineError::Io("stderr reader panicked".into()))??;
205 record.finished_epoch = Some(now_epoch());
206 record.exit_code = status.code();
207 let cancelled = store
208 .load_runtime()
209 .ok()
210 .and_then(|state| {
211 state
212 .runs
213 .get(&routine.name)
214 .and_then(|runs| runs.iter().find(|run| run.id == record.id))
215 .cloned()
216 })
217 .is_some_and(|run| run.status == RunStatus::Cancelled);
218 record.status = if cancelled {
219 RunStatus::Cancelled
220 } else if status.success() {
221 RunStatus::Succeeded
222 } else {
223 RunStatus::Failed
224 };
225 record.pid = None;
226 record.process_start = None;
227 record.final_output = extract_final_output_files(&stdout_path, &stderr_path);
228 replace_record(store, record.clone())?;
229 Ok(record)
230}
231
232fn fail_started_run(
233 store: &RoutineStore,
234 mut child: std::process::Child,
235 mut record: RunRecord,
236 message: &str,
237 out_thread: Option<thread::JoinHandle<Result<(), RoutineError>>>,
238 err_thread: Option<thread::JoinHandle<Result<(), RoutineError>>>,
239) -> Result<RunRecord, RoutineError> {
240 let pid = child.id() as i32;
241 unsafe {
242 libc::kill(-pid, libc::SIGTERM);
243 }
244 let mut status = None;
245 for _ in 0..20 {
246 if status.is_none() {
247 match child.try_wait() {
248 Ok(Some(value)) => status = Some(value),
249 Ok(None) => {}
250 Err(_) => break,
251 }
252 }
253 if !process_group_exists(pid) {
254 break;
255 }
256 thread::sleep(std::time::Duration::from_millis(100));
257 }
258 if status.is_none() || process_group_exists(pid) {
259 unsafe {
260 libc::kill(-pid, libc::SIGKILL);
261 }
262 if status.is_none() {
263 status = child.wait().ok();
264 }
265 }
266 if let Some(handle) = out_thread {
267 let _ = handle.join();
268 }
269 if let Some(handle) = err_thread {
270 let _ = handle.join();
271 }
272 record.finished_epoch = Some(now_epoch());
273 record.status = RunStatus::Failed;
274 record.exit_code = status.and_then(|status| status.code());
275 record.pid = None;
276 record.process_start = None;
277 record.final_output = message.to_string();
278 replace_record(store, record)?;
279 Err(RoutineError::Io(message.to_string()))
280}
281
282fn process_group_exists(pgid: i32) -> bool {
283 if unsafe { libc::kill(-pgid, 0) } == 0 {
284 return true;
285 }
286 std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
287}
288
289fn terminate_remaining_process_group(pgid: i32) {
290 if !process_group_exists(pgid) {
291 return;
292 }
293 unsafe {
294 libc::kill(-pgid, libc::SIGTERM);
295 }
296 for _ in 0..20 {
297 if !process_group_exists(pgid) {
298 return;
299 }
300 thread::sleep(std::time::Duration::from_millis(100));
301 }
302 for _ in 0..20 {
305 unsafe {
306 libc::kill(-pgid, libc::SIGKILL);
307 }
308 if !process_group_exists(pgid) {
309 return;
310 }
311 thread::sleep(std::time::Duration::from_millis(100));
312 }
313}
314
315pub(crate) fn process_start(pid: i32) -> Option<String> {
316 #[cfg(target_os = "macos")]
317 {
318 let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
319 let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
320 let read = unsafe {
321 libc::proc_pidinfo(
322 pid,
323 libc::PROC_PIDTBSDINFO,
324 0,
325 info.as_mut_ptr().cast(),
326 size,
327 )
328 };
329 if read != size {
330 return None;
331 }
332 let info = unsafe { info.assume_init() };
333 Some(format!(
334 "{}:{}",
335 info.pbi_start_tvsec, info.pbi_start_tvusec
336 ))
337 }
338 #[cfg(not(target_os = "macos"))]
339 {
340 let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
341 let after_name = stat.rsplit_once(") ")?.1;
342 after_name.split_whitespace().nth(19).map(str::to_string)
343 }
344}
345
346fn drain(
347 mut source: impl Read + Send + 'static,
348 path: std::path::PathBuf,
349) -> thread::JoinHandle<Result<(), RoutineError>> {
350 thread::spawn(move || {
351 let mut file = File::create(path)?;
352 file.set_permissions(fs::Permissions::from_mode(0o600))?;
353 copy_bounded_log(&mut source, &mut file)?;
354 file.sync_all()?;
355 Ok(())
356 })
357}
358
359fn copy_bounded_log(mut source: impl Read, mut target: impl Write) -> Result<(), RoutineError> {
360 let prefix_limit = MAX_RUN_LOG_BYTES - RUN_LOG_TAIL_BYTES - TRUNCATION_MARKER.len();
361 let mut prefix_written = 0;
362 let mut tail = Vec::with_capacity(RUN_LOG_TAIL_BYTES);
363 let mut truncated = false;
364 let mut buffer = [0_u8; 16 * 1024];
365 loop {
366 let read = source.read(&mut buffer)?;
367 if read == 0 {
368 break;
369 }
370 let mut chunk = &buffer[..read];
371 if prefix_written < prefix_limit {
372 let keep = chunk.len().min(prefix_limit - prefix_written);
373 target.write_all(&chunk[..keep])?;
374 prefix_written += keep;
375 chunk = &chunk[keep..];
376 }
377 if chunk.is_empty() {
378 continue;
379 }
380 if tail.len() + chunk.len() > RUN_LOG_TAIL_BYTES {
381 truncated = true;
382 let discard = (tail.len() + chunk.len()) - RUN_LOG_TAIL_BYTES;
383 if discard >= tail.len() {
384 tail.clear();
385 let start = chunk.len().saturating_sub(RUN_LOG_TAIL_BYTES);
386 tail.extend_from_slice(&chunk[start..]);
387 } else {
388 tail.drain(..discard);
389 tail.extend_from_slice(chunk);
390 }
391 } else {
392 tail.extend_from_slice(chunk);
393 }
394 }
395 if truncated {
396 target.write_all(TRUNCATION_MARKER)?;
397 }
398 target.write_all(&tail)?;
399 Ok(())
400}
401
402fn append_record(store: &RoutineStore, record: RunRecord) -> Result<(), RoutineError> {
403 store.modify_runtime(|state| {
404 state
405 .runs
406 .entry(record.routine.clone())
407 .or_default()
408 .push(record);
409 Ok(())
410 })
411}
412
413fn replace_record(store: &RoutineStore, record: RunRecord) -> Result<(), RoutineError> {
414 let routine = record.routine.clone();
415 let removed = store.modify_runtime(|state| {
416 let runs = state.runs.entry(record.routine.clone()).or_default();
417 if let Some(existing) = runs.iter_mut().find(|run| run.id == record.id) {
418 *existing = record;
419 }
420 Ok(if runs.len() > MAX_RUNS {
421 runs.drain(0..runs.len() - MAX_RUNS).collect::<Vec<_>>()
422 } else {
423 vec![]
424 })
425 })?;
426 for old in removed {
427 prune_run_logs(store, &routine, &old);
428 }
429 Ok(())
430}
431
432pub(crate) fn prune_run_logs(store: &RoutineStore, routine: &str, run: &RunRecord) {
433 if run.routine != routine || !is_safe_run_id(&run.id) {
436 return;
437 }
438 let dir = store.logs_dir().join(hex_name(routine)).join(&run.id);
439 let _ = fs::remove_dir_all(dir);
440}
441
442fn is_safe_run_id(id: &str) -> bool {
443 let Some((seconds, nanos)) = id.split_once('-') else {
444 return false;
445 };
446 !seconds.is_empty()
447 && !nanos.is_empty()
448 && seconds.bytes().all(|byte| byte.is_ascii_digit())
449 && nanos.bytes().all(|byte| byte.is_ascii_digit())
450}
451
452pub fn extract_final_output(stdout: &str, stderr: &str) -> String {
453 extract_final_output_bytes(stdout.as_bytes(), stderr.as_bytes())
454}
455
456fn extract_final_output_bytes(stdout: &[u8], stderr: &[u8]) -> String {
457 let stdout = String::from_utf8_lossy(stdout);
458 let stderr = String::from_utf8_lossy(stderr);
459 let mut codex = None;
460 let mut claude = None;
461 for line in stdout
462 .lines()
463 .filter(|line| line.len() <= STRUCTURED_LINE_BYTES)
464 {
465 let Ok(value) = serde_json::from_str::<Value>(line) else {
466 continue;
467 };
468 if value.get("type").and_then(Value::as_str) == Some("item.completed") {
469 let item = &value["item"];
470 if item.get("type").and_then(Value::as_str) == Some("agent_message") {
471 codex = item
472 .get("text")
473 .and_then(Value::as_str)
474 .map(|text| text_tail(text, FINAL_OUTPUT_BYTES));
475 }
476 }
477 if value.get("type").and_then(Value::as_str) == Some("result") {
478 claude = value
479 .get("result")
480 .and_then(Value::as_str)
481 .map(|text| text_tail(text, FINAL_OUTPUT_BYTES));
482 } else if value.get("type").and_then(Value::as_str) == Some("assistant") {
483 if let Some(text) = claude_assistant_text(&value) {
484 claude = Some(text_tail(&text, FINAL_OUTPUT_BYTES));
485 }
486 }
487 }
488 codex
489 .or(claude)
490 .filter(|s| !s.trim().is_empty())
491 .unwrap_or_else(|| {
492 if !stdout.trim().is_empty() {
493 text_tail(stdout.trim(), FINAL_OUTPUT_BYTES)
494 } else {
495 text_tail(stderr.trim(), FINAL_OUTPUT_BYTES)
496 }
497 })
498}
499
500fn extract_final_output_files(
501 stdout_path: &std::path::Path,
502 stderr_path: &std::path::Path,
503) -> String {
504 let (codex, claude) = File::open(stdout_path)
505 .ok()
506 .map(scan_structured_output)
507 .unwrap_or_default();
508 codex
509 .or(claude)
510 .filter(|output| !output.trim().is_empty())
511 .unwrap_or_else(|| {
512 let stdout = bounded_file_tail(stdout_path);
513 if stdout.is_empty() {
514 bounded_file_tail(stderr_path)
515 } else {
516 stdout
517 }
518 })
519}
520
521fn scan_structured_output(file: File) -> (Option<String>, Option<String>) {
522 let mut codex = None;
523 let mut claude = None;
524 let mut reader = BufReader::new(file);
525 loop {
526 let Some(line) = read_bounded_line(&mut reader, STRUCTURED_LINE_BYTES) else {
527 break;
528 };
529 let Some(line) = line else {
530 continue;
531 };
532 let Ok(value) = serde_json::from_slice::<Value>(&line) else {
533 continue;
534 };
535 if value.get("type").and_then(Value::as_str) == Some("item.completed") {
536 let item = &value["item"];
537 if item.get("type").and_then(Value::as_str) == Some("agent_message") {
538 codex = item
539 .get("text")
540 .and_then(Value::as_str)
541 .map(|text| text_tail(text, FINAL_OUTPUT_BYTES));
542 }
543 }
544 if value.get("type").and_then(Value::as_str) == Some("result") {
545 claude = value
546 .get("result")
547 .and_then(Value::as_str)
548 .map(|text| text_tail(text, FINAL_OUTPUT_BYTES));
549 } else if value.get("type").and_then(Value::as_str) == Some("assistant") {
550 if let Some(text) = claude_assistant_text(&value) {
551 claude = Some(text_tail(&text, FINAL_OUTPUT_BYTES));
552 }
553 }
554 }
555 (codex, claude)
556}
557
558fn read_bounded_line(reader: &mut impl BufRead, max_bytes: usize) -> Option<Option<Vec<u8>>> {
559 let mut line = Vec::new();
560 let mut oversized = false;
561 loop {
562 let buffer = reader.fill_buf().ok()?;
563 if buffer.is_empty() {
564 return (!line.is_empty() || oversized).then_some((!oversized).then_some(line));
565 }
566 let newline = buffer.iter().position(|byte| *byte == b'\n');
567 let consumed = newline.map_or(buffer.len(), |index| index + 1);
568 let content_len = newline.unwrap_or(buffer.len());
569 if !oversized && line.len().saturating_add(content_len) <= max_bytes {
570 line.extend_from_slice(&buffer[..content_len]);
571 } else {
572 oversized = true;
573 line.clear();
574 }
575 reader.consume(consumed);
576 if newline.is_some() {
577 return Some((!oversized).then_some(line));
578 }
579 }
580}
581
582fn bounded_file_tail(path: &std::path::Path) -> String {
583 const READ_CHUNK_BYTES: usize = 4 * 1024;
584
585 let Ok(mut file) = File::open(path) else {
586 return String::new();
587 };
588 let Ok(mut position) = file.seek(SeekFrom::End(0)) else {
589 return String::new();
590 };
591 let mut tail = Vec::with_capacity(FINAL_OUTPUT_BYTES);
592 let mut found_content = false;
593 while position > 0 && tail.len() < FINAL_OUTPUT_BYTES {
594 let chunk_len = usize::try_from(position.min(READ_CHUNK_BYTES as u64)).unwrap_or(0);
595 position -= chunk_len as u64;
596 if file.seek(SeekFrom::Start(position)).is_err() {
597 break;
598 }
599 let mut chunk = vec![0; chunk_len];
600 if file.read_exact(&mut chunk).is_err() {
601 break;
602 }
603 if !found_content {
604 while chunk.last().is_some_and(u8::is_ascii_whitespace) {
605 chunk.pop();
606 }
607 found_content = !chunk.is_empty();
608 }
609 if found_content {
610 let remaining = FINAL_OUTPUT_BYTES - tail.len();
611 let start = chunk.len().saturating_sub(remaining);
612 chunk.drain(..start);
613 chunk.extend_from_slice(&tail);
614 tail = chunk;
615 }
616 }
617 String::from_utf8_lossy(&tail).trim().to_string()
618}
619
620fn claude_assistant_text(value: &Value) -> Option<String> {
621 let content = value.get("message")?.get("content")?.as_array()?;
622 let text = content
623 .iter()
624 .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
625 .filter_map(|block| block.get("text").and_then(Value::as_str))
626 .collect::<Vec<_>>()
627 .join("\n");
628 (!text.trim().is_empty()).then_some(text)
629}
630
631fn text_tail(text: &str, max_bytes: usize) -> String {
632 if text.len() <= max_bytes {
633 return text.to_string();
634 }
635 let mut start = text.len() - max_bytes;
636 while !text.is_char_boundary(start) {
637 start += 1;
638 }
639 text[start..].to_string()
640}
641
642fn hex_name(name: &str) -> String {
643 name.as_bytes().iter().map(|b| format!("{b:02x}")).collect()
644}
645fn now_epoch() -> i64 {
646 SystemTime::now()
647 .duration_since(UNIX_EPOCH)
648 .unwrap_or_default()
649 .as_secs() as i64
650}
651fn now_nanos() -> u128 {
652 SystemTime::now()
653 .duration_since(UNIX_EPOCH)
654 .unwrap_or_default()
655 .as_nanos()
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use crate::routine::Trigger;
662 use std::cell::Cell;
663 use std::io::Cursor;
664 use std::path::PathBuf;
665 use std::rc::Rc;
666 use std::sync::atomic::{AtomicI32, AtomicU64, Ordering};
667
668 static NEXT: AtomicU64 = AtomicU64::new(0);
669
670 struct CountingReader {
671 source: Cursor<Vec<u8>>,
672 consumed: Rc<Cell<usize>>,
673 }
674
675 impl Read for CountingReader {
676 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
677 let read = self.source.read(buffer)?;
678 self.consumed.set(self.consumed.get() + read);
679 Ok(read)
680 }
681 }
682
683 #[test]
684 fn given_log_within_cap_when_copied_then_every_byte_is_preserved_without_marker() {
685 let input = b"begin\ncomplete tail".to_vec();
686 let consumed = Rc::new(Cell::new(0));
687 let reader = CountingReader {
688 source: Cursor::new(input.clone()),
689 consumed: Rc::clone(&consumed),
690 };
691 let mut output = Vec::new();
692
693 copy_bounded_log(reader, &mut output).unwrap();
694
695 assert_eq!(
696 (
697 output == input,
698 output
699 .windows(TRUNCATION_MARKER.len())
700 .any(|window| window == TRUNCATION_MARKER),
701 consumed.get(),
702 ),
703 (true, false, input.len())
704 );
705 }
706
707 #[test]
708 fn given_log_over_cap_when_copied_then_prefix_and_tail_are_bounded_and_input_is_consumed() {
709 let mut input = b"BEGIN".to_vec();
710 input.resize(MAX_RUN_LOG_BYTES + 4_096, b'x');
711 input.extend_from_slice(b"FINAL-TAIL");
712 let consumed = Rc::new(Cell::new(0));
713 let reader = CountingReader {
714 source: Cursor::new(input.clone()),
715 consumed: Rc::clone(&consumed),
716 };
717 let mut output = Vec::new();
718
719 copy_bounded_log(reader, &mut output).unwrap();
720 let markers = output
721 .windows(TRUNCATION_MARKER.len())
722 .filter(|window| *window == TRUNCATION_MARKER)
723 .count();
724
725 assert_eq!(
726 (
727 output.len() <= MAX_RUN_LOG_BYTES,
728 output.starts_with(b"BEGIN"),
729 output.ends_with(b"FINAL-TAIL"),
730 markers,
731 consumed.get(),
732 ),
733 (true, true, true, 1, input.len())
734 );
735 }
736
737 #[test]
738 fn exact_prompt_is_replaced_otherwise_stdin_is_used() {
739 let replaced = Routine {
740 name: "x".into(),
741 trigger: Trigger::Cron("* * * * *".into()),
742 command: vec!["echo".into(), "{prompt}".into()],
743 prompt: "hi".into(),
744 enabled: true,
745 };
746 assert_eq!(expanded_argv(&replaced).0, vec!["echo", "hi"]);
747 assert!(expanded_argv(&replaced).1.is_none());
748 let stdin = Routine {
749 command: vec!["cat".into()],
750 ..replaced
751 };
752 assert_eq!(expanded_argv(&stdin).1, Some("hi".as_bytes()));
753 }
754
755 #[test]
756 fn extracts_codex_claude_and_fallback_output() {
757 assert_eq!(extract_final_output("{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"done\"}}\n", ""), "done");
758 assert_eq!(
759 extract_final_output("{\"type\":\"result\",\"result\":\"answer\"}\n", ""),
760 "answer"
761 );
762 assert_eq!(extract_final_output("plain\n", "err"), "plain");
763 assert_eq!(extract_final_output("", "err\n"), "err");
764 }
765
766 #[test]
767 fn plain_fallback_is_bounded_to_the_log_tail() {
768 let output = format!("HEAD\n{}TAIL", "progress\n".repeat(4_000));
769 let extracted = extract_final_output(&output, "");
770 assert!(extracted.len() <= 16 * 1024);
771 assert!(extracted.ends_with("TAIL"));
772 assert!(!extracted.contains("HEAD"));
773 }
774
775 #[test]
776 fn claude_extraction_ignores_nested_tool_payload_text() {
777 let output = concat!(
778 "{\"type\":\"assistant\",\"message\":{\"content\":[",
779 "{\"type\":\"text\",\"text\":\"final answer\"},",
780 "{\"type\":\"tool_use\",\"input\":{\"text\":\"tool payload\"}}]}}\n"
781 );
782 assert_eq!(extract_final_output(output, ""), "final answer");
783 }
784
785 #[test]
786 fn tool_only_structured_output_uses_bounded_plain_fallback() {
787 let output = concat!(
788 "{\"type\":\"assistant\",\"message\":{\"content\":[",
789 "{\"type\":\"tool_use\",\"input\":{\"text\":\"not an answer\"}}]}}\n"
790 );
791 assert_eq!(extract_final_output(output, ""), output.trim());
792 }
793
794 #[test]
795 fn invalid_utf8_is_preserved_in_plain_fallback() {
796 assert_eq!(
797 extract_final_output_bytes(b"before\xffafter\n", b""),
798 "before\u{fffd}after"
799 );
800 }
801
802 #[test]
803 fn file_extraction_streams_structured_output_and_bounds_plain_tail() {
804 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
805 .join("../../target/routine-execution-tests")
806 .join(format!(
807 "{}-{}",
808 std::process::id(),
809 NEXT.fetch_add(1, Ordering::Relaxed)
810 ));
811 fs::create_dir_all(&root).unwrap();
812 let stdout_path = root.join("stdout.log");
813 let stderr_path = root.join("stderr.log");
814 let mut stdout = File::create(&stdout_path).unwrap();
815 for _ in 0..10_000 {
816 writeln!(stdout, "progress output that must not be retained").unwrap();
817 }
818 writeln!(
819 stdout,
820 "{{\"type\":\"result\",\"result\":\"final answer\"}}"
821 )
822 .unwrap();
823 fs::write(&stderr_path, "error").unwrap();
824
825 assert_eq!(
826 extract_final_output_files(&stdout_path, &stderr_path),
827 "final answer"
828 );
829
830 fs::write(
831 &stdout_path,
832 format!("HEAD\n{}TAIL\n", "progress\n".repeat(4_000)),
833 )
834 .unwrap();
835 let fallback = extract_final_output_files(&stdout_path, &stderr_path);
836 assert!(fallback.len() <= 16 * 1024);
837 assert!(fallback.ends_with("TAIL"));
838 assert!(!fallback.contains("HEAD"));
839 let _ = fs::remove_dir_all(root);
840 }
841
842 #[test]
843 fn oversized_structured_line_is_discarded_without_hiding_later_result() {
844 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
845 .join("../../target/routine-execution-tests")
846 .join(format!(
847 "{}-{}",
848 std::process::id(),
849 NEXT.fetch_add(1, Ordering::Relaxed)
850 ));
851 fs::create_dir_all(&root).unwrap();
852 let stdout_path = root.join("stdout.log");
853 let stderr_path = root.join("stderr.log");
854 let mut stdout = File::create(&stdout_path).unwrap();
855 writeln!(
856 stdout,
857 "{{\"type\":\"result\",\"result\":\"{}\"}}",
858 "x".repeat(STRUCTURED_LINE_BYTES)
859 )
860 .unwrap();
861 writeln!(stdout, "{{\"type\":\"result\",\"result\":\"bounded\"}}").unwrap();
862 fs::write(&stderr_path, "").unwrap();
863
864 assert_eq!(
865 extract_final_output_files(&stdout_path, &stderr_path),
866 "bounded"
867 );
868 let _ = fs::remove_dir_all(root);
869 }
870
871 #[test]
872 fn empty_public_command_returns_validation_instead_of_panicking() {
873 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
874 .join("../../target/routine-execution-tests")
875 .join(format!(
876 "{}-{}",
877 std::process::id(),
878 NEXT.fetch_add(1, Ordering::Relaxed)
879 ));
880 let project =
881 fs::canonicalize(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")).unwrap();
882 let store = RoutineStore::new(root.clone(), &project).unwrap();
883 let routine = Routine {
884 name: "empty".into(),
885 trigger: Trigger::Cron("* * * * *".into()),
886 command: Vec::new(),
887 prompt: String::new(),
888 enabled: true,
889 };
890
891 assert!(matches!(
892 execute(&store, &routine, None),
893 Err(RoutineError::Validation(_))
894 ));
895 assert!(!store.load_runtime().unwrap().runs.contains_key("empty"));
896 let _ = fs::remove_dir_all(root);
897 }
898
899 #[test]
900 fn successful_leader_exit_does_not_leave_pipe_holding_descendants() {
901 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
902 .join("../../target/routine-execution-tests")
903 .join(format!(
904 "{}-{}",
905 std::process::id(),
906 NEXT.fetch_add(1, Ordering::Relaxed)
907 ));
908 let project =
909 fs::canonicalize(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")).unwrap();
910 let store = RoutineStore::new(root.clone(), &project).unwrap();
911 let routine = Routine {
912 name: "descendant".into(),
913 trigger: Trigger::Cron("* * * * *".into()),
914 command: vec![
915 "/bin/sh".into(),
916 "-c".into(),
917 "(trap '' TERM; sleep 30) & exit 0".into(),
918 ],
919 prompt: String::new(),
920 enabled: true,
921 };
922 let pid = AtomicI32::new(0);
923
924 let record = execute_supervised(&store, &routine, None, |record| {
925 pid.store(record.pid.unwrap(), Ordering::SeqCst);
926 })
927 .unwrap();
928
929 assert_eq!(record.status, RunStatus::Succeeded);
930 assert_eq!(unsafe { libc::kill(-pid.load(Ordering::SeqCst), 0) }, -1);
931 let _ = fs::remove_dir_all(root);
932 }
933
934 #[test]
935 fn retention_never_deletes_a_directory_from_persisted_output_paths() {
936 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
937 .join("../../target/routine-execution-tests")
938 .join(format!(
939 "{}-{}",
940 std::process::id(),
941 NEXT.fetch_add(1, Ordering::Relaxed)
942 ));
943 let project =
944 fs::canonicalize(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")).unwrap();
945 let store = RoutineStore::new(root.clone(), &project).unwrap();
946 let victim = root.join("must-survive");
947 fs::create_dir_all(&victim).unwrap();
948 fs::write(victim.join("marker"), "present").unwrap();
949 let record = RunRecord {
950 id: "../must-survive".into(),
951 routine: "safe".into(),
952 started_epoch: 1,
953 finished_epoch: Some(2),
954 scheduled_epoch_minute: None,
955 cause: RunCause::Manual,
956 status: RunStatus::Succeeded,
957 exit_code: Some(0),
958 pid: None,
959 process_start: None,
960 final_output: String::new(),
961 stdout_path: victim.join("stdout.log"),
962 stderr_path: victim.join("stderr.log"),
963 };
964
965 prune_run_logs(&store, "safe", &record);
966
967 assert!(victim.join("marker").exists());
968 let _ = fs::remove_dir_all(root);
969 }
970
971 #[test]
972 fn prompt_write_failure_terminates_and_reaps_process_group() {
973 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
974 .join("../../target/routine-execution-tests")
975 .join(format!(
976 "{}-{}",
977 std::process::id(),
978 NEXT.fetch_add(1, Ordering::Relaxed)
979 ));
980 let project =
981 fs::canonicalize(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")).unwrap();
982 let store = RoutineStore::new(root.clone(), &project).unwrap();
983 let routine = Routine {
984 name: "broken-stdin".into(),
985 trigger: Trigger::Cron("* * * * *".into()),
986 command: vec![
987 "/bin/sh".into(),
988 "-c".into(),
989 "exec 0<&-; trap '' TERM; sleep 30".into(),
990 ],
991 prompt: "x".repeat(1024 * 1024),
992 enabled: true,
993 };
994 let pid = AtomicI32::new(0);
995 let result = execute_supervised(&store, &routine, None, |record| {
996 pid.store(record.pid.unwrap(), Ordering::SeqCst);
997 });
998 assert!(matches!(result, Err(RoutineError::Io(_))));
999 let pid = pid.load(Ordering::SeqCst);
1000 assert!(pid > 0);
1001 assert_eq!(unsafe { libc::kill(-pid, 0) }, -1);
1002 let record = store.load_runtime().unwrap().runs["broken-stdin"]
1003 .last()
1004 .unwrap()
1005 .clone();
1006 assert_eq!(record.status, RunStatus::Failed);
1007 assert!(record.pid.is_none());
1008 let _ = fs::remove_dir_all(root);
1009 }
1010}