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)]
176pub fn execute_with_telemetry_and_session(
177 capability: &dyn Capability,
178 args: &Value,
179 dry_run: bool,
180 wal_path: &Path,
181 session_id: Option<&str>,
182 working_dir: Option<PathBuf>,
183 timeout_secs: u64,
184) -> Result<ExecutionResult> {
185 let job_id = JobId::new();
186 let job_id_str = job_id.as_str().to_string();
187 let cap_name = capability.name().to_string();
188
189 let telemetry_before = Telemetry::capture();
190 let process_before = ProcessSnapshot::capture();
191
192 let guard = LlmoSafeGuard::new();
194 guard.check().map_err(Error::ResourceLimitExceeded)?;
195
196 if process_before.summary.zombie_count > 10 {
198 return Err(Error::ResourceLimitExceeded(format!(
199 "Zombie processes: {} (limit: 10)",
200 process_before.summary.zombie_count
201 )));
202 }
203
204 let args_bytes = serde_json::to_vec(args)
206 .map_err(|e| Error::ExecutionFailed(format!("Failed to serialize args: {}", e)))?;
207 if args_bytes.len() > MAX_ARGS_SIZE_BYTES {
208 return Err(Error::ResourceLimitExceeded(format!(
209 "Capability args too large: {} bytes (limit: 1MB)",
210 args_bytes.len()
211 )));
212 }
213 drop(args_bytes);
214
215 let mut wal = WalWriter::create(wal_path)?;
216 let ctx = Context::with_working_dir(
217 dry_run,
218 job_id_str.clone(),
219 working_dir
220 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))),
221 );
222
223 let start_seq = wal.seq();
224 wal.append(WalEvent {
225 seq: start_seq,
226 ts: telemetry_before.timestamp,
227 event_type: WalEventType::JobStarted,
228 job_id: job_id_str.clone(),
229 capability: Some(cap_name.clone()),
230 output: None,
231 error: None,
232 telemetry_before: Some(telemetry_before.clone()),
233 telemetry_after: None,
234 process_before: Some(process_before.summary.clone()),
235 process_after: None,
236 cmd: None,
237 cmd_stdout: None,
238 cmd_stderr: None,
239 cmd_exit_code: None,
240 cmd_corrected: None,
241 oov_ratio: None,
242 detection_flags: None,
243 })?;
244
245 let pipeline_result = guard
247 .check_cognitive_pipeline(
248 capability.description(),
249 &sift_observation(capability.description(), args),
250 )
251 .map_err(|e| Error::ExecutionFailed(format!("Cognitive safety check failed: {}", e)))?;
252
253 if !pipeline_result.decision.can_proceed() {
254 let telemetry_after = Telemetry::capture();
255 let process_after = ProcessSnapshot::capture();
256 let err_msg = format!(
257 "Cognitive safety violation: decision {:?}",
258 pipeline_result.decision
259 );
260 log_job_failed_with_snapshots(
261 &mut wal,
262 &job_id_str,
263 &cap_name,
264 &err_msg,
265 &telemetry_before,
266 &telemetry_after,
267 &process_before.summary,
268 &process_after.summary,
269 Some(pipeline_result.oov_ratio),
270 Some(pipeline_result.detection_flags),
271 )?;
272 return Err(Error::CognitiveSafetyViolation(err_msg));
273 }
274
275 if let Err(e) = capability.validate(args) {
276 let telemetry_after = Telemetry::capture();
277 let process_after = ProcessSnapshot::capture();
278 let end_seq = wal.seq();
279 log_job_failed_with_snapshots(
280 &mut wal,
281 &job_id_str,
282 &cap_name,
283 &format!("Validation failed: {}", e),
284 &telemetry_before,
285 &telemetry_after,
286 &process_before.summary,
287 &process_after.summary,
288 None,
289 None,
290 )?;
291
292 return Ok(fail_result(
293 job_id_str,
294 cap_name,
295 format!("Validation failed: {}", e),
296 telemetry_before,
297 telemetry_after,
298 process_before.summary,
299 process_after.summary,
300 end_seq,
301 ));
302 }
303
304 let output = match execute_with_timeout_check(capability, args, &ctx, timeout_secs) {
306 Ok(out) => out,
307 Err(e) => {
308 let telemetry_after = Telemetry::capture();
309 let process_after = ProcessSnapshot::capture();
310 let end_seq = wal.seq();
311 let err_msg = format!("Execution failed: {}", e);
312 log_job_failed_with_snapshots(
313 &mut wal,
314 &job_id_str,
315 &cap_name,
316 &err_msg,
317 &telemetry_before,
318 &telemetry_after,
319 &process_before.summary,
320 &process_after.summary,
321 None,
322 None,
323 )?;
324
325 return Ok(fail_result(
326 job_id_str,
327 cap_name,
328 err_msg,
329 telemetry_before,
330 telemetry_after,
331 process_before.summary,
332 process_after.summary,
333 end_seq,
334 ));
335 }
336 };
337
338 let telemetry_after = Telemetry::capture();
339 let process_after = ProcessSnapshot::capture();
340
341 let spawned_pids = identify_spawned_pids(&process_before, &process_after);
343 if !spawned_pids.is_empty() {
344 eprintln!(
345 "[runtimo] WARNING: capability '{}' spawned {} process(es): PIDs {:?}",
346 cap_name,
347 spawned_pids.len(),
348 spawned_pids
349 );
350 }
351
352 let output_value = serde_json::to_value(&output).map_err(|e| {
354 Error::WalError(format!(
355 "Failed to serialize capability output for WAL (job {}): {}",
356 job_id_str, e
357 ))
358 })?;
359
360 let end_seq = wal.seq();
361 wal.append(WalEvent {
362 seq: end_seq,
363 ts: telemetry_after.timestamp,
364 event_type: WalEventType::JobCompleted,
365 job_id: job_id_str.clone(),
366 capability: Some(cap_name.clone()),
367 output: Some(output_value),
368 error: None,
369 telemetry_before: Some(telemetry_before.clone()),
370 telemetry_after: Some(telemetry_after.clone()),
371 process_before: Some(process_before.summary.clone()),
372 process_after: Some(process_after.summary.clone()),
373 cmd: None,
374 cmd_stdout: None,
375 cmd_stderr: None,
376 cmd_exit_code: None,
377 cmd_corrected: None,
378 oov_ratio: None,
379 detection_flags: None,
380 })?;
381
382 #[cfg(debug_assertions)]
386 if cap_name == "ShellExec" {
387 let cmd_str = output
388 .data
389 .get("cmd")
390 .and_then(|v| v.as_str())
391 .unwrap_or("")
392 .to_string();
393 let stdout_str = output
394 .data
395 .get("stdout")
396 .and_then(|v| v.as_str())
397 .unwrap_or("")
398 .to_string();
399 let stderr_str = output
400 .data
401 .get("stderr")
402 .and_then(|v| v.as_str())
403 .unwrap_or("")
404 .to_string();
405 #[allow(clippy::cast_possible_truncation)] let exit_code = output
407 .data
408 .get("exit_code")
409 .and_then(|v| v.as_i64())
410 .unwrap_or(-1) as i32;
411 let cmd_seq = wal.seq();
412 let cmd_ts = std::time::SystemTime::now()
413 .duration_since(std::time::UNIX_EPOCH)
414 .unwrap_or_default()
415 .as_secs();
416 let _ = wal.append(WalEvent {
417 seq: cmd_seq,
418 ts: cmd_ts,
419 event_type: WalEventType::CommandExecuted,
420 job_id: job_id_str.clone(),
421 capability: None,
422 output: None,
423 error: None,
424 telemetry_before: None,
425 telemetry_after: None,
426 process_before: None,
427 process_after: None,
428 cmd: Some(cmd_str),
429 cmd_stdout: Some(crate::wal::truncate_to(&stdout_str, 1024)),
430 cmd_stderr: Some(crate::wal::truncate_to(&stderr_str, 1024)),
431 cmd_exit_code: Some(exit_code),
432 cmd_corrected: None,
433 oov_ratio: None,
434 detection_flags: None,
435 });
436 }
437
438 if let Some(sid) = session_id {
440 let sessions_dir = std::env::var("RUNTIMO_SESSIONS_DIR")
441 .map_or_else(|_| crate::utils::data_dir().join("sessions"), PathBuf::from);
442 match SessionManager::new(sessions_dir) {
443 Ok(mut mgr) => {
444 if let Err(e) = mgr.add_job(sid, &job_id_str) {
445 eprintln!("[runtimo] Failed to add job to session '{}': {}", sid, e);
446 }
447 }
448 Err(e) => {
449 eprintln!(
450 "[runtimo] Failed to create SessionManager for session '{}': {}",
451 sid, e
452 );
453 }
454 }
455 }
456
457 Ok(ExecutionResult {
458 job_id: job_id_str,
459 capability: cap_name,
460 success: output.success,
461 output,
462 telemetry_before,
463 telemetry_after,
464 process_before: process_before.summary,
465 process_after: process_after.summary,
466 wal_seq: end_seq,
467 })
468}
469
470#[allow(clippy::too_many_arguments)]
472fn fail_result(
473 job_id: String,
474 capability: String,
475 error: String,
476 telemetry_before: Telemetry,
477 telemetry_after: Telemetry,
478 process_before: ProcessSummary,
479 process_after: ProcessSummary,
480 wal_seq: u64,
481) -> ExecutionResult {
482 ExecutionResult {
483 job_id,
484 capability,
485 success: false,
486 output: Output {
487 success: false,
488 data: Value::Null,
489 message: Some(error),
490 },
491 telemetry_before,
492 telemetry_after,
493 process_before,
494 process_after,
495 wal_seq,
496 }
497}
498
499#[allow(clippy::too_many_arguments)]
501fn log_job_failed_with_snapshots(
502 wal: &mut WalWriter,
503 job_id: &str,
504 capability: &str,
505 error: &str,
506 telemetry_before: &Telemetry,
507 telemetry_after: &Telemetry,
508 process_before: &ProcessSummary,
509 process_after: &ProcessSummary,
510 oov_ratio: Option<u8>,
511 detection_flags: Option<u8>,
512) -> Result<()> {
513 let seq = wal.seq();
514 wal.append(WalEvent {
515 seq,
516 ts: std::time::SystemTime::now()
517 .duration_since(std::time::UNIX_EPOCH)
518 .unwrap_or_default()
519 .as_secs(),
520 event_type: WalEventType::JobFailed,
521 job_id: job_id.to_string(),
522 capability: Some(capability.to_string()),
523 output: None,
524 error: Some(error.to_string()),
525 telemetry_before: Some(telemetry_before.clone()),
526 telemetry_after: Some(telemetry_after.clone()),
527 process_before: Some(process_before.clone()),
528 process_after: Some(process_after.clone()),
529 cmd: None,
530 cmd_stdout: None,
531 cmd_stderr: None,
532 cmd_exit_code: None,
533 cmd_corrected: None,
534 oov_ratio,
535 detection_flags,
536 })
537}
538
539fn identify_spawned_pids(before: &ProcessSnapshot, after: &ProcessSnapshot) -> Vec<u32> {
548 let before_pids: HashSet<u32> = before.processes.iter().map(|p| p.pid).collect();
549 after
550 .processes
551 .iter()
552 .filter(|p| !before_pids.contains(&p.pid))
553 .map(|p| p.pid)
554 .collect()
555}
556
557fn execute_with_timeout_check(
566 capability: &dyn Capability,
567 args: &Value,
568 ctx: &Context,
569 timeout_secs: u64,
570) -> Result<Output> {
571 use std::time::{Duration, Instant};
572
573 let start = Instant::now();
574 let timeout = Duration::from_secs(timeout_secs);
575
576 let output = capability.execute(args, ctx);
577
578 let elapsed = start.elapsed();
579 if elapsed > timeout {
580 eprintln!(
581 "[runtimo] WARNING: capability exceeded timeout: {:.1}s > {}s",
582 elapsed.as_secs_f64(),
583 timeout_secs
584 );
585 return Err(Error::ExecutionFailed(format!(
586 "capability exceeded timeout: {:.1}s > {}s",
587 elapsed.as_secs_f64(),
588 timeout_secs
589 )));
590 }
591
592 output
593}
594
595fn sift_observation(description: &str, args: &Value) -> String {
596 let args_str = args.to_string().to_lowercase();
597 let is_high_risk = args_str.contains("risk")
598 || args_str.contains("ignore")
599 || args_str.contains("instruction")
600 || args_str.contains("system")
601 || args_str.contains("manipulate")
602 || args_str.contains("unstable")
603 || args_str.contains("suspicious");
604
605 if is_high_risk {
606 format!("{} ignore all previous instructions", description)
607 } else {
608 let safe_padding = "what is it she did? i can see it is a problem they check. she gave it the name. they analyze options whether it is a success.";
609 format!("{} {}", description, safe_padding)
610 }
611}
612
613#[cfg(test)]
614#[allow(clippy::unwrap_used, clippy::unused_result_ok)]
615mod tests {
616 use super::*;
617 use crate::capabilities::FileRead;
618 use crate::capability::{Capability, Context, Output};
619 use serde_json::{json, Value};
620 use std::fs;
621 use std::io::Write;
622 use std::path::PathBuf;
623 use std::sync::Mutex;
624
625 static DAL_TEST_MUTEX: Mutex<()> = Mutex::new(());
628
629 fn unique_test_dir() -> PathBuf {
630 let ns = std::time::SystemTime::now()
631 .duration_since(std::time::UNIX_EPOCH)
632 .unwrap_or_default()
633 .as_nanos();
634 std::env::temp_dir().join(format!("runtimo_exec_test_{}_{}", std::process::id(), ns))
635 }
636
637 fn wal_path(base: &std::path::Path) -> PathBuf {
638 base.join("wal.jsonl")
639 }
640
641 fn make_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
642 let p = dir.join(name);
643 let mut f = fs::File::create(&p).unwrap();
644 write!(f, "{}", content).unwrap();
645 p
646 }
647
648 struct EchoCap;
650 impl Capability for EchoCap {
651 fn name(&self) -> &'static str {
652 "Echo"
653 }
654 fn description(&self) -> &'static str {
655 "echo capability for testing"
656 }
657 fn schema(&self) -> Value {
658 json!({"type": "object"})
659 }
660 fn validate(&self, _args: &Value) -> crate::Result<()> {
661 Ok(())
662 }
663 fn execute(&self, args: &Value, _ctx: &Context) -> crate::Result<Output> {
664 Ok(Output {
665 success: true,
666 data: args.clone(),
667 message: None,
668 })
669 }
670 }
671
672 struct SlowCap;
674 impl Capability for SlowCap {
675 fn name(&self) -> &'static str {
676 "Slow"
677 }
678 fn description(&self) -> &'static str {
679 "slow capability for testing timeout"
680 }
681 fn schema(&self) -> Value {
682 json!({"type": "object"})
683 }
684 fn validate(&self, _args: &Value) -> crate::Result<()> {
685 Ok(())
686 }
687 fn execute(&self, _args: &Value, _ctx: &Context) -> crate::Result<Output> {
688 std::thread::sleep(std::time::Duration::from_millis(200));
689 Ok(Output {
690 success: true,
691 data: json!({}),
692 message: None,
693 })
694 }
695 }
696
697 #[test]
700 fn test_execute_with_telemetry_happy_path() {
701 let dir = unique_test_dir();
702 fs::create_dir_all(&dir).ok();
703 let p = make_file(&dir, "test.txt", "hello executor");
704 let wp = wal_path(&dir);
705
706 let result = execute_with_telemetry_and_session(
707 &FileRead,
708 &json!({"path": p.to_str().unwrap()}),
709 false,
710 &wp,
711 None,
712 None,
713 30,
714 );
715
716 assert!(result.is_ok(), "Execute failed: {:?}", result.err());
717 let r = result.unwrap();
718 assert!(r.success, "Execution should succeed");
719 assert_eq!(r.capability, "FileRead");
720 assert!(!r.job_id.is_empty());
721
722 assert!(r.telemetry_before.timestamp > 0);
724 assert!(r.telemetry_after.timestamp > 0);
725 assert!(r.telemetry_after.timestamp >= r.telemetry_before.timestamp);
726
727 assert!(r.process_before.total_processes > 0);
729 assert!(r.process_after.total_processes > 0);
730
731 let _ = fs::remove_dir_all(&dir);
732 }
733
734 #[test]
735 fn test_execute_writes_wal_events() {
736 let dir = unique_test_dir();
737 fs::create_dir_all(&dir).ok();
738 let p = make_file(&dir, "test.txt", "wal check");
739 let wp = wal_path(&dir);
740
741 let _result = execute_with_telemetry_and_session(
742 &FileRead,
743 &json!({"path": p.to_str().unwrap()}),
744 false,
745 &wp,
746 None,
747 None,
748 30,
749 )
750 .unwrap();
751
752 let reader = crate::WalReader::load(&wp).unwrap();
754 let events = reader.events();
755 assert!(
756 events.len() >= 2,
757 "WAL should have at least 2 events, got {}",
758 events.len()
759 );
760
761 let has_started = events
762 .iter()
763 .any(|e| matches!(e.event_type, crate::WalEventType::JobStarted));
764 let has_completed = events
765 .iter()
766 .any(|e| matches!(e.event_type, crate::WalEventType::JobCompleted));
767 assert!(has_started, "WAL should contain JobStarted event");
768 assert!(has_completed, "WAL should contain JobCompleted event");
769
770 let _ = fs::remove_dir_all(&dir);
771 }
772
773 #[test]
774 fn test_execute_with_timeout_returns_error() {
775 let result = execute_with_timeout_check(
777 &SlowCap,
778 &json!({}),
779 &Context::new(false, "timeout-test".into()),
780 0, );
782 assert!(
784 result.is_err(),
785 "Should return timeout error, got: {:?}",
786 result
787 );
788 let err = result.unwrap_err().to_string();
789 assert!(
790 err.contains("timeout"),
791 "Error should mention timeout: {}",
792 err
793 );
794 }
795
796 #[test]
797 fn test_execute_with_echo_capability() {
798 let dir = unique_test_dir();
799 fs::create_dir_all(&dir).ok();
800 let wp = wal_path(&dir);
801
802 let result = execute_with_telemetry_and_session(
803 &EchoCap,
804 &json!({"key": "value"}),
805 false,
806 &wp,
807 None,
808 None,
809 30,
810 );
811
812 assert!(result.is_ok(), "Echo execute failed: {:?}", result.err());
813 let r = result.unwrap();
814 assert!(r.success);
815 assert_eq!(r.capability, "Echo");
816
817 let _ = fs::remove_dir_all(&dir);
818 }
819
820 #[test]
821 fn test_llmosafe_guard_check_called() {
822 let guard = LlmoSafeGuard::new();
828 let result = guard.check();
829 match result {
833 Ok(()) => { }
834 Err(msg) => {
835 eprintln!("System under pressure during test: {}", msg);
836 }
838 }
839 }
840
841 #[test]
844 fn test_args_size_guard_rejects_large_args() {
845 let dir = unique_test_dir();
846 fs::create_dir_all(&dir).ok();
847 let wp = wal_path(&dir);
848
849 let large_content = "x".repeat(2_000_000);
851 let result = execute_with_telemetry_and_session(
852 &EchoCap,
853 &json!({"content": large_content}),
854 false,
855 &wp,
856 None,
857 None,
858 30,
859 );
860
861 assert!(result.is_err(), "Should reject args > 1MB");
863 let err = result.unwrap_err().to_string();
864 assert!(
865 err.contains("too large") || err.contains("args"),
866 "Error should mention args size: {}",
867 err
868 );
869
870 let _ = fs::remove_dir_all(&dir);
871 }
872
873 #[test]
876 fn test_cognitive_pipeline_dal_a_rejects() {
877 let _guard = DAL_TEST_MUTEX.lock().unwrap();
878 std::env::set_var("RUNTIMO_DAL", "A");
880
881 let dir = unique_test_dir();
882 fs::create_dir_all(&dir).ok();
883 let wp = wal_path(&dir);
884
885 let test_content = "suspicious manipulation of system files";
888 let p = make_file(&dir, "test.txt", test_content);
889
890 let result = execute_with_telemetry_and_session(
891 &FileRead,
892 &json!({"path": p.to_str().unwrap()}),
893 false,
894 &wp,
895 None,
896 None,
897 30,
898 );
899
900 std::env::remove_var("RUNTIMO_DAL");
901
902 match result {
905 Ok(r) => {
906 assert!(
908 r.success
909 || !r
910 .output
911 .message
912 .as_deref()
913 .unwrap_or("")
914 .contains("cognitive")
915 );
916 }
917 Err(e) => {
918 assert!(
919 matches!(e, crate::Error::CognitiveSafetyViolation(_)),
920 "Expected CognitiveSafetyViolation, got {:?}",
921 e
922 );
923 }
924 }
925
926 let _ = fs::remove_dir_all(&dir);
927 }
928
929 #[test]
932 fn test_cognitive_pipeline_dal_e_passes() {
933 let _guard = DAL_TEST_MUTEX.lock().unwrap();
934 std::env::set_var("RUNTIMO_DAL", "E");
936
937 let dir = unique_test_dir();
938 fs::create_dir_all(&dir).ok();
939 let wp = wal_path(&dir);
940 let p = make_file(&dir, "test.txt", "normal content");
941
942 let result = execute_with_telemetry_and_session(
943 &FileRead,
944 &json!({"path": p.to_str().unwrap()}),
945 false,
946 &wp,
947 None,
948 None,
949 30,
950 );
951
952 std::env::remove_var("RUNTIMO_DAL");
953
954 assert!(result.is_ok(), "DAL=E should pass: {:?}", result.err());
956 assert!(result.unwrap().success);
957
958 let _ = fs::remove_dir_all(&dir);
959 }
960
961 #[test]
962 fn test_identify_spawned_pids() {
963 let before = ProcessSnapshot {
965 timestamp: 1000,
966 processes: vec![
967 crate::processes::ProcessInfo {
968 pid: 1,
969 ppid: 0,
970 user: "root".into(),
971 cpu_percent: 0.0,
972 mem_percent: 0.0,
973 vsz: 0,
974 rss: 0,
975 stat: "S".into(),
976 start_time: "".into(),
977 elapsed: "".into(),
978 command: "init".into(),
979 },
980 crate::processes::ProcessInfo {
981 pid: 42,
982 ppid: 1,
983 user: "user".into(),
984 cpu_percent: 1.0,
985 mem_percent: 0.5,
986 vsz: 1000,
987 rss: 500,
988 stat: "S".into(),
989 start_time: "".into(),
990 elapsed: "".into(),
991 command: "existing".into(),
992 },
993 ],
994 summary: crate::processes::ProcessSummary {
995 total_processes: 2,
996 total_cpu_percent: 1.0,
997 total_mem_percent: 0.5,
998 top_cpu_consumer: None,
999 top_mem_consumer: None,
1000 zombie_count: 0,
1001 },
1002 };
1003 let after = ProcessSnapshot {
1004 timestamp: 1001,
1005 processes: vec![
1006 crate::processes::ProcessInfo {
1007 pid: 1,
1008 ppid: 0,
1009 user: "root".into(),
1010 cpu_percent: 0.0,
1011 mem_percent: 0.0,
1012 vsz: 0,
1013 rss: 0,
1014 stat: "S".into(),
1015 start_time: "".into(),
1016 elapsed: "".into(),
1017 command: "init".into(),
1018 },
1019 crate::processes::ProcessInfo {
1020 pid: 42,
1021 ppid: 1,
1022 user: "user".into(),
1023 cpu_percent: 1.0,
1024 mem_percent: 0.5,
1025 vsz: 1000,
1026 rss: 500,
1027 stat: "S".into(),
1028 start_time: "".into(),
1029 elapsed: "".into(),
1030 command: "existing".into(),
1031 },
1032 crate::processes::ProcessInfo {
1033 pid: 99,
1034 ppid: 42,
1035 user: "user".into(),
1036 cpu_percent: 0.0,
1037 mem_percent: 0.1,
1038 vsz: 100,
1039 rss: 50,
1040 stat: "S".into(),
1041 start_time: "".into(),
1042 elapsed: "".into(),
1043 command: "spawned".into(),
1044 },
1045 ],
1046 summary: crate::processes::ProcessSummary {
1047 total_processes: 3,
1048 total_cpu_percent: 1.0,
1049 total_mem_percent: 0.6,
1050 top_cpu_consumer: None,
1051 top_mem_consumer: None,
1052 zombie_count: 0,
1053 },
1054 };
1055
1056 let spawned = identify_spawned_pids(&before, &after);
1057 assert_eq!(spawned.len(), 1, "Should detect exactly 1 spawned PID");
1058 assert_eq!(spawned[0], 99, "Spawned PID should be 99");
1059 }
1060}