1use serde::Serialize;
15
16use crate::backend::storage::cron::CronJob;
17use crate::backend::storage::work_queue::{work_state, WorkItem};
18use crate::server::AppState;
19
20#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
23pub struct ProcessEntry {
24 pub pid: u32,
25 pub command: String,
26 pub started_at_ms: u64,
29}
30
31#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
33pub struct BackgroundTaskEntry {
34 pub id: String,
35 pub label: String,
36 pub pid: Option<i64>,
37 pub started_at_ms: i64,
39 pub last_seen_ms: i64,
41}
42
43#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
45pub struct ShellEntry {
46 pub shell_id: String,
47 pub cmd: String,
48}
49
50#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
52pub struct AgentResources {
53 pub block_id: String,
54 pub processes: Vec<ProcessEntry>,
56 pub background_tasks: Vec<BackgroundTaskEntry>,
58 pub shell_sessions: Vec<ShellEntry>,
60 pub sub_blocks: Vec<String>,
62 pub container: Option<String>,
64 pub cwd: String,
67}
68
69#[derive(Clone, Debug, Default)]
72pub struct AgentIdentity {
73 pub agent_id: String,
74 pub uid: Option<String>,
75}
76
77impl AgentIdentity {
78 pub fn of(state: &AppState, block_id: &str) -> Self {
80 let reg = state.reactive_handler.get_agent_by_block(block_id);
81 Self {
82 agent_id: reg.as_ref().map(|a| a.agent_id.clone()).unwrap_or_default(),
83 uid: reg.and_then(|a| a.uid),
84 }
85 }
86
87 pub fn label(&self, block_id: &str) -> String {
89 if self.agent_id.is_empty() { block_id.to_string() } else { self.agent_id.clone() }
90 }
91}
92
93pub fn snapshot(state: &AppState, block_id: &str) -> AgentResources {
97 let mut processes: Vec<ProcessEntry> = state
98 .process_tracker
99 .list_block(block_id)
100 .into_iter()
101 .map(|p| ProcessEntry { pid: p.pid, command: p.command, started_at_ms: p.started_at_ms })
102 .collect();
103 let missing: Vec<u32> = processes.iter().filter(|p| p.started_at_ms == 0).map(|p| p.pid).collect();
106 if !missing.is_empty() {
107 let times = start_times(&missing);
108 processes.retain_mut(|p| {
111 if p.started_at_ms == 0 {
112 match times.get(&p.pid) {
113 Some(t) => p.started_at_ms = *t,
114 None => return false,
115 }
116 }
117 true
118 });
119 }
120 let background_tasks = state
121 .mstore
122 .background_task_list_for_block(block_id)
123 .unwrap_or_default()
124 .into_iter()
125 .filter(|t| t.ended_at_ms.is_none())
126 .map(|t| BackgroundTaskEntry { id: t.id, label: t.label, pid: t.pid, started_at_ms: t.started_at_ms, last_seen_ms: t.last_seen_ms })
127 .collect();
128 let shell_sessions = state
129 .shell_sessions
130 .list_active()
131 .into_iter()
132 .filter(|s| s.block_id == block_id)
133 .map(|s| ShellEntry { shell_id: s.shell_id, cmd: s.cmd })
134 .collect();
135 let block = state.mstore.get::<crate::backend::obj::Block>(block_id).ok().flatten();
136 let container = block.as_ref().and_then(container_of);
137 let cwd = block
138 .as_ref()
139 .map(|b| crate::backend::obj::meta_get_string(&b.meta, "cmd:cwd", ""))
140 .unwrap_or_default();
141 let sub_blocks = block.and_then(|b| b.subblockids).unwrap_or_default();
142 AgentResources { block_id: block_id.to_string(), processes, background_tasks, shell_sessions, sub_blocks, container, cwd }
143}
144
145pub fn container_of(block: &crate::backend::obj::Block) -> Option<String> {
148 use crate::backend::obj::meta_get_string;
149 if meta_get_string(&block.meta, "agentMode", "") != "container" {
150 return None;
151 }
152 let agent_id = meta_get_string(&block.meta, "agentId", "");
153 (!agent_id.is_empty()).then(|| crate::backend::container::container_name_for_slug(&agent_id))
154}
155
156pub fn container_shared(state: &AppState, block_id: &str, container: &str) -> bool {
159 crate::backend::blockcontroller::get_all_controllers()
160 .into_keys()
161 .filter(|id| id != block_id)
162 .filter_map(|id| state.mstore.get::<crate::backend::obj::Block>(&id).ok().flatten())
163 .any(|b| container_of(&b).as_deref() == Some(container))
164}
165
166pub fn start_times(pids: &[u32]) -> std::collections::HashMap<u32, u64> {
170 if pids.is_empty() {
171 return Default::default();
172 }
173 let list: Vec<sysinfo::Pid> = pids.iter().map(|p| sysinfo::Pid::from_u32(*p)).collect();
174 let mut sys = sysinfo::System::new();
175 sys.refresh_processes_specifics(
176 sysinfo::ProcessesToUpdate::Some(&list),
177 true,
178 sysinfo::ProcessRefreshKind::nothing(),
179 );
180 sys.processes().iter().map(|(pid, p)| (pid.as_u32(), p.start_time() * 1000)).collect()
181}
182
183pub fn survivors(before: &[ProcessEntry]) -> Vec<ProcessEntry> {
187 let pids: Vec<u32> = before.iter().map(|p| p.pid).collect();
188 let live = start_times(&pids);
189 before
190 .iter()
191 .filter(|p| live.get(&p.pid).is_some_and(|now| same_start(p.started_at_ms, *now)))
192 .cloned()
193 .collect()
194}
195
196pub fn live_background_pids(tasks: &[BackgroundTaskEntry]) -> Vec<u32> {
205 let pids: Vec<u32> = tasks.iter().filter_map(|t| t.pid.and_then(|p| u32::try_from(p).ok())).filter(|p| *p > 1).collect();
206 let live = start_times(&pids);
207 tasks
208 .iter()
209 .filter_map(|t| {
210 let pid = u32::try_from(t.pid?).ok()?;
211 let started = *live.get(&pid)?;
212 (started <= (t.last_seen_ms.max(t.started_at_ms).max(0) as u64) + 1_000).then_some(pid)
214 })
215 .collect()
216}
217
218pub fn force_kill_tree(pid: u32, started_at_ms: u64) -> usize {
223 let mut sys = sysinfo::System::new();
224 sys.refresh_processes_specifics(sysinfo::ProcessesToUpdate::All, true, sysinfo::ProcessRefreshKind::nothing());
225 let root = sysinfo::Pid::from_u32(pid);
226 match sys.process(root) {
227 Some(p) if same_start(started_at_ms, p.start_time() * 1000) => {}
228 _ => return 0, }
230 let mut tree = vec![root];
232 let mut i = 0;
233 while i < tree.len() {
234 let parent = tree[i];
235 tree.extend(sys.processes().iter().filter(|(_, p)| p.parent() == Some(parent)).map(|(id, _)| *id));
236 i += 1;
237 }
238 tree.iter()
239 .rev()
240 .filter(|id| sys.process(**id).is_some_and(|p| p.kill_with(sysinfo::Signal::Kill).unwrap_or_else(|| p.kill())))
241 .count()
242}
243
244fn same_start(recorded_ms: u64, now_ms: u64) -> bool {
246 recorded_ms == 0 || recorded_ms.abs_diff(now_ms) <= 1000
247}
248
249pub fn holds_claim(claimed_by: &str, claimed_by_uid: &str, agent_id: &str, uid: Option<&str>) -> bool {
253 match uid.filter(|u| !u.is_empty()) {
254 Some(u) if !claimed_by_uid.is_empty() => claimed_by_uid == u,
255 _ => !agent_id.is_empty() && claimed_by == agent_id,
256 }
257}
258
259pub fn cron_targets(target: &str, target_uid: &str, agent_id: &str, uid: Option<&str>) -> bool {
263 match uid.filter(|u| !u.is_empty()) {
264 Some(u) if !target_uid.is_empty() => target_uid == u,
265 _ => !agent_id.is_empty() && target.eq_ignore_ascii_case(agent_id),
266 }
267}
268
269pub(crate) fn claims_held_by<'a>(items: &'a [WorkItem], agent_id: &str, uid: Option<&str>) -> Vec<&'a WorkItem> {
271 items
272 .iter()
273 .filter(|w| w.state == work_state::CLAIMED && holds_claim(&w.claimed_by, &w.claimed_by_uid, agent_id, uid))
274 .collect()
275}
276
277pub(crate) fn crons_targeting(jobs: &[CronJob], agent_id: &str, uid: Option<&str>) -> Vec<String> {
279 jobs.iter()
280 .filter(|j| cron_targets(&j.target, &j.target_uid, agent_id, uid))
281 .map(|j| j.name.clone())
282 .collect()
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn a_live_process_survives_and_a_reused_pid_does_not() {
291 let me = std::process::id();
292 let started = *start_times(&[me]).get(&me).expect("this process is running");
293 assert!(started > 0);
294 let entry = |started_at_ms| ProcessEntry { pid: me, command: "test".into(), started_at_ms };
295 assert_eq!(survivors(&[entry(started)]).len(), 1, "same pid, same start: still running");
296 assert_eq!(survivors(&[entry(0)]).len(), 1, "unknown start time: judged by pid");
297 assert!(survivors(&[entry(started - 60_000)]).is_empty(), "same pid, other start: a reused pid");
298 }
299
300 #[test]
301 fn a_background_task_pid_counts_only_while_it_is_that_task() {
302 let me = std::process::id();
303 let started = *start_times(&[me]).get(&me).unwrap() as i64;
304 let task = |started_at_ms, last_seen_ms| BackgroundTaskEntry {
305 id: "t".into(),
306 label: "task dev".into(),
307 pid: Some(me as i64),
308 started_at_ms,
309 last_seen_ms,
310 };
311 assert_eq!(live_background_pids(&[task(started + 500, started + 500)]), vec![me], "seen just after it started");
312 assert_eq!(
313 live_background_pids(&[task(started - 60_000, started + 10_000)]),
314 vec![me],
315 "declared a minute before it started (a permission prompt), seen running since: still the task"
316 );
317 assert!(
318 live_background_pids(&[task(started - 60_000, started - 30_000)]).is_empty(),
319 "last seen before this process started: the PID was reused"
320 );
321 let no_pid = BackgroundTaskEntry { pid: None, ..task(started, started) };
322 assert!(live_background_pids(&[no_pid]).is_empty());
323 }
324
325 #[test]
328 fn force_kill_tree_takes_the_child_too() {
329 #[cfg(windows)]
330 let mut parent = std::process::Command::new("cmd").args(["/C", "ping -n 30 127.0.0.1 > nul"]).spawn().unwrap();
331 #[cfg(unix)]
332 let mut parent = std::process::Command::new("sh").args(["-c", "trap '' TERM; sleep 30 & wait"]).spawn().unwrap();
333 let pid = parent.id();
334 let child_of = |pid: u32| {
336 let mut sys = sysinfo::System::new();
337 sys.refresh_processes_specifics(sysinfo::ProcessesToUpdate::All, true, sysinfo::ProcessRefreshKind::nothing());
338 sys.processes()
339 .iter()
340 .find(|(_, p)| p.parent() == Some(sysinfo::Pid::from_u32(pid)))
341 .map(|(id, _)| id.as_u32())
342 };
343 let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
344 let mut child = None;
345 while child.is_none() && std::time::Instant::now() < until {
346 std::thread::sleep(std::time::Duration::from_millis(100));
347 child = child_of(pid);
348 }
349 let child = child.expect("the shell started a child");
350
351 assert_eq!(force_kill_tree(pid, 60_000), 0, "a start time that doesn't match: not this process, nothing killed");
352 assert!(force_kill_tree(pid, 0) >= 1);
355 let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
356 while (start_times(&[pid, child]).len() > 0) && std::time::Instant::now() < until {
357 std::thread::sleep(std::time::Duration::from_millis(50));
358 }
359 let _ = parent.wait();
360 assert!(start_times(&[pid, child]).is_empty(), "the parent and its child are gone");
361 }
362
363 #[test]
364 fn only_a_container_agent_has_a_container() {
365 let block = |meta: &[(&str, &str)]| crate::backend::obj::Block {
366 meta: meta.iter().map(|(k, v)| (k.to_string(), serde_json::json!(v))).collect(),
367 ..Default::default()
368 };
369 assert_eq!(
370 container_of(&block(&[("agentMode", "container"), ("agentId", "lark")])).as_deref(),
371 Some(crate::backend::container::container_name_for_slug("lark").as_str())
372 );
373 assert_eq!(container_of(&block(&[("agentMode", "host"), ("agentId", "lark")])), None);
374 assert_eq!(container_of(&block(&[("agentMode", "container")])), None, "no agentId, no name");
375 }
376
377 #[test]
378 fn an_exited_process_is_not_a_survivor() {
379 #[cfg(windows)]
380 let mut child = std::process::Command::new("cmd").args(["/C", "exit 0"]).spawn().unwrap();
381 #[cfg(unix)]
382 let mut child = std::process::Command::new("sh").args(["-c", "exit 0"]).spawn().unwrap();
383 let pid = child.id();
384 child.wait().unwrap();
385 drop(child);
386 assert!(survivors(&[ProcessEntry { pid, command: "gone".into(), started_at_ms: 0 }]).is_empty());
387 }
388}