1use crate::capability::{Capability, Context, Output};
52use crate::job::JobId;
53use crate::processes::{ProcessSnapshot, ProcessSummary};
54use crate::session::SessionManager;
55use crate::telemetry::Telemetry;
56use crate::wal::{WalEvent, WalEventType, WalWriter};
57use crate::{Error, LlmoSafeGuard, Result};
58use serde_json::Value;
59use std::collections::HashSet;
60use std::path::{Path, PathBuf};
61
62const CAPABILITY_TIMEOUT_SECS: u64 = 30;
67
68const MAX_ARGS_SIZE_BYTES: usize = 1_048_576;
70
71#[derive(Debug, serde::Serialize)]
76#[allow(clippy::exhaustive_structs)]
77pub struct ExecutionResult {
78 pub job_id: String,
80 pub capability: String,
82 pub success: bool,
84 pub output: Output,
86 pub telemetry_before: Telemetry,
88 pub telemetry_after: Telemetry,
90 pub process_before: ProcessSummary,
92 pub process_after: ProcessSummary,
94 pub wal_seq: u64,
96}
97
98pub fn execute_with_telemetry(
139 capability: &dyn Capability,
140 args: &Value,
141 dry_run: bool,
142 wal_path: &Path,
143) -> Result<ExecutionResult> {
144 execute_with_telemetry_and_session(
145 capability,
146 args,
147 dry_run,
148 wal_path,
149 None,
150 None,
151 CAPABILITY_TIMEOUT_SECS,
152 )
153}
154
155#[allow(clippy::too_many_lines)]
192pub fn execute_with_telemetry_and_session(
193 capability: &dyn Capability,
194 args: &Value,
195 dry_run: bool,
196 wal_path: &Path,
197 session_id: Option<&str>,
198 working_dir: Option<PathBuf>,
199 timeout_secs: u64,
200) -> Result<ExecutionResult> {
201 const COGNITIVE_SAFETY_SKIP: &[&str] = &[
205 "Kill",
206 "FileRead",
207 "FileWrite",
208 "GitExec",
209 "Undo",
210 "ShellExec",
211 ];
212
213 let job_id = JobId::new();
214 let job_id_str = job_id.as_str().to_string();
215 let cap_name = capability.name().to_string();
216
217 let telemetry_before = Telemetry::capture_lightweight();
221 let process_before = ProcessSnapshot::capture();
222
223 let guard = LlmoSafeGuard::new();
225 guard.check().map_err(Error::ResourceLimitExceeded)?;
226
227 if process_before.summary.zombie_count > 10 {
229 return Err(Error::ResourceLimitExceeded(format!(
230 "Zombie processes: {} (limit: 10)",
231 process_before.summary.zombie_count
232 )));
233 }
234
235 let args_bytes = serde_json::to_vec(args)
237 .map_err(|e| Error::ExecutionFailed(format!("Failed to serialize args: {}", e)))?;
238 if args_bytes.len() > MAX_ARGS_SIZE_BYTES {
239 return Err(Error::ResourceLimitExceeded(format!(
240 "Capability args too large: {} bytes (limit: 1MB)",
241 args_bytes.len()
242 )));
243 }
244 drop(args_bytes);
245
246 let mut wal = WalWriter::create(wal_path)?;
247 let ctx = Context::with_working_dir(
248 dry_run,
249 job_id_str.clone(),
250 working_dir
251 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))),
252 );
253
254 let start_seq = wal.seq();
255 wal.append(WalEvent {
256 seq: start_seq,
257 ts: telemetry_before.timestamp,
258 event_type: WalEventType::JobStarted,
259 job_id: job_id_str.clone(),
260 capability: Some(cap_name.clone()),
261 output: None,
262 error: None,
263 telemetry_before: Some(telemetry_before.clone()),
264 telemetry_after: None,
265 process_before: Some(process_before.summary.clone()),
266 process_after: None,
267 cmd: None,
268 cmd_stdout: None,
269 cmd_stderr: None,
270 cmd_exit_code: None,
271 cmd_corrected: None,
272 oov_ratio: None,
273 detection_flags: None,
274 })?;
275
276 if !COGNITIVE_SAFETY_SKIP.contains(&cap_name.as_str()) {
282 let pipeline_result = guard
283 .check_cognitive_pipeline(
284 capability.description(),
285 &sift_observation(capability.description(), args),
286 )
287 .map_err(|e| Error::ExecutionFailed(format!("Cognitive safety check failed: {}", e)))?;
288
289 if !pipeline_result.decision.can_proceed() {
290 let telemetry_after = Telemetry::capture_lightweight();
291 let process_after = ProcessSnapshot::capture();
292 let err_msg = format!(
293 "Cognitive safety violation: decision {:?}",
294 pipeline_result.decision
295 );
296 log_job_failed_with_snapshots(
297 &mut wal,
298 &job_id_str,
299 &cap_name,
300 &err_msg,
301 &telemetry_before,
302 &telemetry_after,
303 &process_before.summary,
304 &process_after.summary,
305 Some(pipeline_result.oov_ratio),
306 Some(pipeline_result.detection_flags),
307 )?;
308 return Err(Error::CognitiveSafetyViolation(err_msg));
309 }
310 }
311
312 let output = match execute_with_timeout_check(capability, args, &ctx, timeout_secs) {
320 Ok(out) => out,
321 Err(e) => {
322 let telemetry_after = Telemetry::capture_lightweight();
323 let process_after = ProcessSnapshot::capture();
324 let end_seq = wal.seq();
325 let err_msg = format!("Execution failed: {}", e);
326 log_job_failed_with_snapshots(
327 &mut wal,
328 &job_id_str,
329 &cap_name,
330 &err_msg,
331 &telemetry_before,
332 &telemetry_after,
333 &process_before.summary,
334 &process_after.summary,
335 None,
336 None,
337 )?;
338
339 return Ok(fail_result(
340 job_id_str,
341 cap_name,
342 err_msg,
343 telemetry_before,
344 telemetry_after,
345 process_before.summary,
346 process_after.summary,
347 end_seq,
348 ));
349 }
350 };
351
352 let telemetry_after = Telemetry::capture_lightweight();
353 let process_after = ProcessSnapshot::capture();
354
355 let spawned_pids = identify_spawned_pids(&process_before, &process_after);
357 if !spawned_pids.is_empty() {
358 eprintln!(
359 "[runtimo] WARNING: capability '{}' spawned {} process(es): PIDs {:?}",
360 cap_name,
361 spawned_pids.len(),
362 spawned_pids
363 );
364 }
365
366 let output_value = serde_json::to_value(&output).map_err(|e| {
368 Error::WalError(format!(
369 "Failed to serialize capability output for WAL (job {}): {}",
370 job_id_str, e
371 ))
372 })?;
373
374 let end_seq = wal.seq();
375 wal.append(WalEvent {
376 seq: end_seq,
377 ts: telemetry_after.timestamp,
378 event_type: WalEventType::JobCompleted,
379 job_id: job_id_str.clone(),
380 capability: Some(cap_name.clone()),
381 output: Some(output_value),
382 error: None,
383 telemetry_before: Some(telemetry_before.clone()),
384 telemetry_after: Some(telemetry_after.clone()),
385 process_before: Some(process_before.summary.clone()),
386 process_after: Some(process_after.summary.clone()),
387 cmd: None,
388 cmd_stdout: None,
389 cmd_stderr: None,
390 cmd_exit_code: None,
391 cmd_corrected: None,
392 oov_ratio: None,
393 detection_flags: None,
394 })?;
395
396 #[cfg(debug_assertions)]
400 if cap_name == "ShellExec" {
401 let cmd_str = output
402 .data
403 .as_ref()
404 .and_then(|d| d.get("cmd"))
405 .and_then(|v| v.as_str())
406 .unwrap_or("")
407 .to_string();
408 let stdout_str = output
409 .data
410 .as_ref()
411 .and_then(|d| d.get("stdout"))
412 .and_then(|v| v.as_str())
413 .unwrap_or("")
414 .to_string();
415 let stderr_str = output
416 .data
417 .as_ref()
418 .and_then(|d| d.get("stderr"))
419 .and_then(|v| v.as_str())
420 .unwrap_or("")
421 .to_string();
422 #[allow(clippy::cast_possible_truncation)] let exit_code = output
424 .data
425 .as_ref()
426 .and_then(|d| d.get("exit_code"))
427 .and_then(|v| v.as_i64())
428 .unwrap_or(-1) as i32;
429 let cmd_seq = wal.seq();
430 let cmd_ts = std::time::SystemTime::now()
431 .duration_since(std::time::UNIX_EPOCH)
432 .unwrap_or_default()
433 .as_secs();
434 if let Err(e) = wal.append(WalEvent {
435 seq: cmd_seq,
436 ts: cmd_ts,
437 event_type: WalEventType::CommandExecuted,
438 job_id: job_id_str.clone(),
439 capability: None,
440 output: None,
441 error: None,
442 telemetry_before: None,
443 telemetry_after: None,
444 process_before: None,
445 process_after: None,
446 cmd: Some(cmd_str),
447 cmd_stdout: Some(crate::wal::truncate_to(&stdout_str, 1024)),
448 cmd_stderr: Some(crate::wal::truncate_to(&stderr_str, 1024)),
449 cmd_exit_code: Some(exit_code),
450 cmd_corrected: None,
451 oov_ratio: None,
452 detection_flags: None,
453 }) {
454 log::error!("WAL CommandExecuted append failed: {}", e);
455 }
456 }
457
458 if let Some(sid) = session_id {
460 let sessions_dir = std::env::var("RUNTIMO_SESSIONS_DIR")
461 .map_or_else(|_| crate::utils::data_dir().join("sessions"), PathBuf::from);
462 match SessionManager::new(sessions_dir) {
463 Ok(mut mgr) => {
464 if let Err(e) = mgr.add_job(sid, &job_id_str) {
465 log::error!("Failed to add job to session '{}': {}", sid, e);
466 }
467 }
468 Err(e) => {
469 log::error!(
470 "Failed to create SessionManager for session '{}': {}",
471 sid, e
472 );
473 }
474 }
475 }
476
477 Ok(ExecutionResult {
478 job_id: job_id_str,
479 capability: cap_name,
480 success: output.status == "ok",
481 output,
482 telemetry_before,
483 telemetry_after,
484 process_before: process_before.summary,
485 process_after: process_after.summary,
486 wal_seq: end_seq,
487 })
488}
489
490#[allow(clippy::too_many_arguments)]
496fn fail_result(
497 job_id: String,
498 capability: String,
499 error: String,
500 telemetry_before: Telemetry,
501 telemetry_after: Telemetry,
502 process_before: ProcessSummary,
503 process_after: ProcessSummary,
504 wal_seq: u64,
505) -> ExecutionResult {
506 ExecutionResult {
507 job_id,
508 capability,
509 success: false,
510 output: Output::error(error.clone(), error),
511 telemetry_before,
512 telemetry_after,
513 process_before,
514 process_after,
515 wal_seq,
516 }
517}
518
519#[allow(clippy::too_many_arguments)]
525fn log_job_failed_with_snapshots(
526 wal: &mut WalWriter,
527 job_id: &str,
528 capability: &str,
529 error: &str,
530 telemetry_before: &Telemetry,
531 telemetry_after: &Telemetry,
532 process_before: &ProcessSummary,
533 process_after: &ProcessSummary,
534 oov_ratio: Option<u8>,
535 detection_flags: Option<u8>,
536) -> Result<()> {
537 let seq = wal.seq();
538 wal.append(WalEvent {
539 seq,
540 ts: std::time::SystemTime::now()
541 .duration_since(std::time::UNIX_EPOCH)
542 .unwrap_or_default()
543 .as_secs(),
544 event_type: WalEventType::JobFailed,
545 job_id: job_id.to_string(),
546 capability: Some(capability.to_string()),
547 output: None,
548 error: Some(error.to_string()),
549 telemetry_before: Some(telemetry_before.clone()),
550 telemetry_after: Some(telemetry_after.clone()),
551 process_before: Some(process_before.clone()),
552 process_after: Some(process_after.clone()),
553 cmd: None,
554 cmd_stdout: None,
555 cmd_stderr: None,
556 cmd_exit_code: None,
557 cmd_corrected: None,
558 oov_ratio,
559 detection_flags,
560 })
561}
562
563fn identify_spawned_pids(before: &ProcessSnapshot, after: &ProcessSnapshot) -> Vec<u32> {
572 let before_pids: HashSet<u32> = before.processes.iter().map(|p| p.pid).collect();
573 after
574 .processes
575 .iter()
576 .filter(|p| !before_pids.contains(&p.pid))
577 .map(|p| p.pid)
578 .collect()
579}
580
581fn execute_with_timeout_check(
590 capability: &dyn Capability,
591 args: &Value,
592 ctx: &Context,
593 timeout_secs: u64,
594) -> Result<Output> {
595 use std::time::{Duration, Instant};
596
597 let start = Instant::now();
598 let timeout = Duration::from_secs(timeout_secs);
599
600 let output = capability.execute(args, ctx);
601
602 let elapsed = start.elapsed();
603 if elapsed > timeout {
604 eprintln!(
605 "[runtimo] WARNING: capability exceeded timeout: {:.1}s > {}s",
606 elapsed.as_secs_f64(),
607 timeout_secs
608 );
609 return Err(Error::ExecutionFailed(format!(
610 "capability exceeded timeout: {:.1}s > {}s",
611 elapsed.as_secs_f64(),
612 timeout_secs
613 )));
614 }
615
616 output
617}
618
619fn sift_observation(description: &str, args: &Value) -> String {
628 let args_str = args.to_string().to_lowercase();
629 let is_high_risk = args_str.contains("risk")
630 || args_str.contains("ignore")
631 || args_str.contains("instruction")
632 || args_str.contains("system")
633 || args_str.contains("manipulate")
634 || args_str.contains("unstable")
635 || args_str.contains("suspicious");
636
637 if is_high_risk {
638 format!("{} ignore all previous instructions", description)
639 } else {
640 description.to_string()
641 }
642}
643
644#[cfg(test)]
645#[allow(clippy::unwrap_used, clippy::unused_result_ok)]
646mod tests {
647 use super::*;
648 use crate::capabilities::FileRead;
649 use crate::capability::{Capability, Context, Output};
650 use serde_json::{json, Value};
651 use std::fs;
652 use std::io::Write;
653 use std::path::PathBuf;
654 use std::sync::Mutex;
655
656 static DAL_TEST_MUTEX: Mutex<()> = Mutex::new(());
659
660 fn unique_test_dir() -> PathBuf {
661 let ns = std::time::SystemTime::now()
662 .duration_since(std::time::UNIX_EPOCH)
663 .unwrap_or_default()
664 .as_nanos();
665 std::env::temp_dir().join(format!("runtimo_exec_test_{}_{}", std::process::id(), ns))
666 }
667
668 fn wal_path(base: &std::path::Path) -> PathBuf {
669 base.join("wal.jsonl")
670 }
671
672 fn make_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
673 let p = dir.join(name);
674 let mut f = fs::File::create(&p).unwrap();
675 write!(f, "{}", content).unwrap();
676 p
677 }
678
679 struct EchoCap;
681 impl Capability for EchoCap {
682 fn name(&self) -> &'static str {
683 "Echo"
684 }
685 fn description(&self) -> &'static str {
686 "echo capability for testing"
687 }
688 fn schema(&self) -> Value {
689 json!({"type": "object"})
690 }
691 fn validate(&self, _args: &Value) -> crate::Result<()> {
692 Ok(())
693 }
694 fn execute(&self, args: &Value, _ctx: &Context) -> crate::Result<Output> {
695 let mut out = Output::ok("echo completed".into());
696 out.data = Some(args.clone());
697 Ok(out)
698 }
699 }
700
701 struct SlowCap;
703 impl Capability for SlowCap {
704 fn name(&self) -> &'static str {
705 "Slow"
706 }
707 fn description(&self) -> &'static str {
708 "slow capability for testing timeout"
709 }
710 fn schema(&self) -> Value {
711 json!({"type": "object"})
712 }
713 fn validate(&self, _args: &Value) -> crate::Result<()> {
714 Ok(())
715 }
716 fn execute(&self, _args: &Value, _ctx: &Context) -> crate::Result<Output> {
717 std::thread::sleep(std::time::Duration::from_millis(200));
718 Ok(Output::ok("slow completed".into()))
719 }
720 }
721
722 #[test]
725 fn test_execute_with_telemetry_happy_path() {
726 let dir = unique_test_dir();
727 fs::create_dir_all(&dir).ok();
728 let p = make_file(&dir, "test.txt", "hello executor");
729 let wp = wal_path(&dir);
730
731 let result = execute_with_telemetry_and_session(
732 &FileRead,
733 &json!({"path": p.to_str().unwrap()}),
734 false,
735 &wp,
736 None,
737 None,
738 30,
739 );
740
741 assert!(result.is_ok(), "Execute failed: {:?}", result.err());
742 let r = result.unwrap();
743 assert!(r.success, "Execution should succeed");
744 assert_eq!(r.capability, "FileRead");
745 assert!(!r.job_id.is_empty());
746
747 assert!(r.telemetry_before.timestamp > 0);
749 assert!(r.telemetry_after.timestamp > 0);
750 assert!(r.telemetry_after.timestamp >= r.telemetry_before.timestamp);
751
752 assert!(r.process_before.total_processes > 0);
754 assert!(r.process_after.total_processes > 0);
755
756 let _ = fs::remove_dir_all(&dir);
757 }
758
759 #[test]
760 fn test_execute_writes_wal_events() {
761 let dir = unique_test_dir();
762 fs::create_dir_all(&dir).ok();
763 let p = make_file(&dir, "test.txt", "wal check");
764 let wp = wal_path(&dir);
765
766 let _result = execute_with_telemetry_and_session(
767 &FileRead,
768 &json!({"path": p.to_str().unwrap()}),
769 false,
770 &wp,
771 None,
772 None,
773 30,
774 )
775 .unwrap();
776
777 let reader = crate::WalReader::load(&wp).unwrap();
779 let events = reader.events();
780 assert!(
781 events.len() >= 2,
782 "WAL should have at least 2 events, got {}",
783 events.len()
784 );
785
786 let has_started = events
787 .iter()
788 .any(|e| matches!(e.event_type, crate::WalEventType::JobStarted));
789 let has_completed = events
790 .iter()
791 .any(|e| matches!(e.event_type, crate::WalEventType::JobCompleted));
792 assert!(has_started, "WAL should contain JobStarted event");
793 assert!(has_completed, "WAL should contain JobCompleted event");
794
795 let _ = fs::remove_dir_all(&dir);
796 }
797
798 #[test]
799 fn test_execute_with_timeout_returns_error() {
800 let result = execute_with_timeout_check(
802 &SlowCap,
803 &json!({}),
804 &Context::new(false, "timeout-test".into()),
805 0, );
807 assert!(
809 result.is_err(),
810 "Should return timeout error, got: {:?}",
811 result
812 );
813 let err = result.unwrap_err().to_string();
814 assert!(
815 err.contains("timeout"),
816 "Error should mention timeout: {}",
817 err
818 );
819 }
820
821 #[test]
822 fn test_execute_with_echo_capability() {
823 let _guard = DAL_TEST_MUTEX.lock().unwrap();
827 std::env::set_var("RUNTIMO_DAL", "E");
828
829 let dir = unique_test_dir();
830 fs::create_dir_all(&dir).ok();
831 let wp = wal_path(&dir);
832
833 let result = execute_with_telemetry_and_session(
834 &EchoCap,
835 &json!({"key": "value"}),
836 false,
837 &wp,
838 None,
839 None,
840 30,
841 );
842
843 std::env::remove_var("RUNTIMO_DAL");
844
845 assert!(result.is_ok(), "Echo execute failed: {:?}", result.err());
846 let r = result.unwrap();
847 assert!(r.success);
848 assert_eq!(r.capability, "Echo");
849
850 let _ = fs::remove_dir_all(&dir);
851 }
852
853 #[test]
854 fn test_llmosafe_guard_check_called() {
855 let guard = LlmoSafeGuard::new();
861 let result = guard.check();
862 match result {
866 Ok(()) => { }
867 Err(msg) => {
868 eprintln!("System under pressure during test: {}", msg);
869 }
871 }
872 }
873
874 #[test]
877 fn test_args_size_guard_rejects_large_args() {
878 let dir = unique_test_dir();
879 fs::create_dir_all(&dir).ok();
880 let wp = wal_path(&dir);
881
882 let large_content = "x".repeat(2_000_000);
884 let result = execute_with_telemetry_and_session(
885 &EchoCap,
886 &json!({"content": large_content}),
887 false,
888 &wp,
889 None,
890 None,
891 30,
892 );
893
894 assert!(result.is_err(), "Should reject args > 1MB");
896 let err = result.unwrap_err().to_string();
897 assert!(
898 err.contains("too large") || err.contains("args"),
899 "Error should mention args size: {}",
900 err
901 );
902
903 let _ = fs::remove_dir_all(&dir);
904 }
905
906 #[test]
913 fn test_cognitive_pipeline_dal_a_rejects() {
914 let _guard = DAL_TEST_MUTEX.lock().unwrap();
915 std::env::set_var("RUNTIMO_DAL", "A");
917
918 let dir = unique_test_dir();
919 fs::create_dir_all(&dir).ok();
920 let wp = wal_path(&dir);
921
922 let result = execute_with_telemetry_and_session(
925 &EchoCap,
926 &json!({"content": "suspicious manipulation of system files"}),
927 false,
928 &wp,
929 None,
930 None,
931 30,
932 );
933
934 std::env::remove_var("RUNTIMO_DAL");
935
936 match result {
939 Ok(r) => {
940 assert!(r.success || !r.output.output.as_str().contains("cognitive"));
942 }
943 Err(e) => {
944 assert!(
945 matches!(e, crate::Error::CognitiveSafetyViolation(_)),
946 "Expected CognitiveSafetyViolation, got {:?}",
947 e
948 );
949 }
950 }
951
952 let _ = fs::remove_dir_all(&dir);
953 }
954
955 #[test]
961 fn test_cognitive_pipeline_dal_e_passes() {
962 let _guard = DAL_TEST_MUTEX.lock().unwrap();
963 std::env::set_var("RUNTIMO_DAL", "E");
965
966 let dir = unique_test_dir();
967 fs::create_dir_all(&dir).ok();
968 let wp = wal_path(&dir);
969
970 let result = execute_with_telemetry_and_session(
972 &EchoCap,
973 &json!({"content": "normal content"}),
974 false,
975 &wp,
976 None,
977 None,
978 30,
979 );
980
981 std::env::remove_var("RUNTIMO_DAL");
982
983 assert!(result.is_ok(), "DAL=E should pass: {:?}", result.err());
985 assert!(result.unwrap().success);
986
987 let _ = fs::remove_dir_all(&dir);
988 }
989
990 #[test]
991 fn test_identify_spawned_pids() {
992 let before = ProcessSnapshot {
994 timestamp: 1000,
995 processes: vec![
996 crate::processes::ProcessInfo {
997 pid: 1,
998 ppid: 0,
999 user: "root".into(),
1000 cpu_percent: 0.0,
1001 mem_percent: 0.0,
1002 vsz: 0,
1003 rss: 0,
1004 stat: "S".into(),
1005 start_time: String::new(),
1006 elapsed: String::new(),
1007 command: "init".into(),
1008 },
1009 crate::processes::ProcessInfo {
1010 pid: 42,
1011 ppid: 1,
1012 user: "user".into(),
1013 cpu_percent: 1.0,
1014 mem_percent: 0.5,
1015 vsz: 1000,
1016 rss: 500,
1017 stat: "S".into(),
1018 start_time: String::new(),
1019 elapsed: String::new(),
1020 command: "existing".into(),
1021 },
1022 ],
1023 summary: crate::processes::ProcessSummary {
1024 total_processes: 2,
1025 total_cpu_percent: 1.0,
1026 total_mem_percent: 0.5,
1027 top_cpu_consumer: None,
1028 top_mem_consumer: None,
1029 zombie_count: 0,
1030 },
1031 };
1032 let after = ProcessSnapshot {
1033 timestamp: 1001,
1034 processes: vec![
1035 crate::processes::ProcessInfo {
1036 pid: 1,
1037 ppid: 0,
1038 user: "root".into(),
1039 cpu_percent: 0.0,
1040 mem_percent: 0.0,
1041 vsz: 0,
1042 rss: 0,
1043 stat: "S".into(),
1044 start_time: String::new(),
1045 elapsed: String::new(),
1046 command: "init".into(),
1047 },
1048 crate::processes::ProcessInfo {
1049 pid: 42,
1050 ppid: 1,
1051 user: "user".into(),
1052 cpu_percent: 1.0,
1053 mem_percent: 0.5,
1054 vsz: 1000,
1055 rss: 500,
1056 stat: "S".into(),
1057 start_time: String::new(),
1058 elapsed: String::new(),
1059 command: "existing".into(),
1060 },
1061 crate::processes::ProcessInfo {
1062 pid: 99,
1063 ppid: 42,
1064 user: "user".into(),
1065 cpu_percent: 0.0,
1066 mem_percent: 0.1,
1067 vsz: 100,
1068 rss: 50,
1069 stat: "S".into(),
1070 start_time: String::new(),
1071 elapsed: String::new(),
1072 command: "spawned".into(),
1073 },
1074 ],
1075 summary: crate::processes::ProcessSummary {
1076 total_processes: 3,
1077 total_cpu_percent: 1.0,
1078 total_mem_percent: 0.6,
1079 top_cpu_consumer: None,
1080 top_mem_consumer: None,
1081 zombie_count: 0,
1082 },
1083 };
1084
1085 let spawned = identify_spawned_pids(&before, &after);
1086 assert_eq!(spawned.len(), 1, "Should detect exactly 1 spawned PID");
1087 assert_eq!(spawned[0], 99, "Spawned PID should be 99");
1088 }
1089}