Skip to main content

agent_berth/
status.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4#[serde(rename_all = "kebab-case")]
5pub enum AgentEventKind {
6    SessionStart,
7    PromptSubmit,
8    ToolStart,
9    ToolComplete,
10    PermissionRequest,
11    QuestionAsked,
12    Notification,
13    Stop,
14    SessionEnd,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum AgentStatus {
20    #[default]
21    Idle,
22    Working,
23    Waiting,
24    Done,
25}
26
27impl AgentStatus {
28    pub fn as_str(self) -> &'static str {
29        match self {
30            Self::Idle => "idle",
31            Self::Working => "working",
32            Self::Waiting => "waiting",
33            Self::Done => "done",
34        }
35    }
36
37    pub fn is_busy(self) -> bool {
38        matches!(self, Self::Working | Self::Waiting)
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
43#[serde(rename_all = "lowercase")]
44pub enum Source {
45    #[default]
46    Cli,
47    Desktop,
48}
49
50impl Source {
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::Cli => "cli",
54            Self::Desktop => "desktop",
55        }
56    }
57
58    pub fn from_label(value: &str) -> Self {
59        if value.to_ascii_lowercase().contains("desktop") {
60            Self::Desktop
61        } else {
62            Self::Cli
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct AgentEvent {
69    pub session_id: String,
70    pub kind: AgentEventKind,
71    pub source: Source,
72    pub pid: Option<u32>,
73    pub parent_id: Option<String>,
74    pub background_running: bool,
75}
76
77impl AgentEvent {
78    pub fn new(session_id: impl Into<String>, kind: AgentEventKind) -> Self {
79        Self {
80            session_id: session_id.into(),
81            kind,
82            source: Source::Cli,
83            pid: None,
84            parent_id: None,
85            background_running: false,
86        }
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct AgentSession {
92    #[serde(default)]
93    pub status: AgentStatus,
94    #[serde(default)]
95    pub source: Source,
96    #[serde(default)]
97    pub pid: Option<u32>,
98    #[serde(default)]
99    pub parent_id: Option<String>,
100    #[serde(default)]
101    pub background_only: bool,
102    #[serde(default)]
103    pub cwd: Option<String>,
104    #[serde(default)]
105    pub cmdline: Vec<String>,
106    #[serde(default)]
107    pub created_ms: u64,
108    #[serde(default)]
109    pub last_report_ms: u64,
110    #[serde(default)]
111    pub exited: bool,
112    #[serde(default)]
113    pub discovered: bool,
114    #[serde(default)]
115    pub title: Option<String>,
116}
117
118impl Default for AgentSession {
119    fn default() -> Self {
120        Self {
121            status: AgentStatus::Idle,
122            source: Source::Cli,
123            pid: None,
124            parent_id: None,
125            background_only: false,
126            cwd: None,
127            cmdline: Vec::new(),
128            created_ms: 0,
129            last_report_ms: 0,
130            exited: false,
131            discovered: false,
132            title: None,
133        }
134    }
135}
136
137impl AgentSession {
138    pub fn apply(&mut self, event: &AgentEvent) {
139        self.source = event.source;
140        if event.pid.is_some() {
141            self.pid = event.pid;
142        }
143        if event.parent_id.is_some() {
144            self.parent_id = event.parent_id.clone();
145        }
146        match event.kind {
147            AgentEventKind::PromptSubmit => {
148                self.status = AgentStatus::Working;
149                self.background_only = false;
150            }
151            AgentEventKind::ToolStart => {
152                if self.status == AgentStatus::Idle {
153                    self.status = AgentStatus::Working;
154                }
155                if self.status == AgentStatus::Working {
156                    self.background_only = false;
157                }
158            }
159            AgentEventKind::ToolComplete => {
160                if self.status == AgentStatus::Waiting {
161                    self.status = AgentStatus::Working;
162                }
163                if self.status == AgentStatus::Working {
164                    self.background_only = false;
165                }
166            }
167            AgentEventKind::PermissionRequest | AgentEventKind::QuestionAsked => {
168                self.status = AgentStatus::Waiting;
169            }
170            AgentEventKind::Notification => {
171                if self.status == AgentStatus::Working {
172                    self.status = AgentStatus::Waiting;
173                }
174            }
175            AgentEventKind::Stop => {
176                self.status = if event.background_running {
177                    AgentStatus::Working
178                } else {
179                    AgentStatus::Done
180                };
181                self.background_only = event.background_running;
182            }
183            AgentEventKind::SessionStart | AgentEventKind::SessionEnd => {}
184        }
185    }
186}
187
188pub fn prune(
189    sessions: &mut std::collections::BTreeMap<String, AgentSession>,
190    keep: Option<&str>,
191    source: Option<Source>,
192    pid: Option<u32>,
193) {
194    sessions.retain(|sid, item| {
195        if Some(sid.as_str()) == keep || item.status.is_busy() {
196            return true;
197        }
198        if source.is_some() && Some(item.source) != source {
199            return true;
200        }
201        item.pid != pid
202    });
203}
204
205pub fn apply_event(
206    sessions: &mut std::collections::BTreeMap<String, AgentSession>,
207    event: AgentEvent,
208) {
209    let sid = event.session_id.clone();
210    let kind = event.kind;
211    let existing_pid = sessions.get(&sid).and_then(|item| item.pid);
212    let pid = event.pid.or(existing_pid);
213    if kind == AgentEventKind::SessionEnd {
214        sessions.remove(&sid);
215        sessions.retain(|_, child| child.parent_id.as_deref() != Some(sid.as_str()));
216        return;
217    }
218    if kind == AgentEventKind::SessionStart {
219        prune(sessions, Some(&sid), Some(event.source), pid);
220        return;
221    }
222    if !sessions.contains_key(&sid) {
223        if matches!(
224            kind,
225            AgentEventKind::ToolComplete | AgentEventKind::Notification
226        ) {
227            return;
228        }
229        if kind == AgentEventKind::Stop && !event.background_running {
230            return;
231        }
232        sessions.insert(
233            sid.clone(),
234            AgentSession {
235                created_ms: crate::store::now_ms(),
236                ..AgentSession::default()
237            },
238        );
239    }
240    let parent_id;
241    let source;
242    let session_pid;
243    {
244        let item = sessions.get_mut(&sid).expect("session inserted");
245        item.apply(&event);
246        parent_id = item.parent_id.clone();
247        source = item.source;
248        session_pid = item.pid;
249    }
250    if parent_id.is_some() {
251        if sessions
252            .get(&sid)
253            .is_some_and(|item| item.status == AgentStatus::Done)
254        {
255            sessions.remove(&sid);
256        }
257    } else if matches!(kind, AgentEventKind::PromptSubmit | AgentEventKind::Stop) {
258        prune(sessions, Some(&sid), Some(source), session_pid);
259    }
260}
261
262pub fn session_key(payload: &serde_json::Value) -> Option<String> {
263    let sid = string_field(payload, &["session_id", "sessionId"]);
264    let agent = string_field(payload, &["agent_id", "agentId"]);
265    match (sid, agent) {
266        (Some(sid), Some(agent)) => Some(format!("{sid}:{agent}")),
267        (None, Some(agent)) => Some(agent.to_string()),
268        (Some(sid), None) => Some(sid.to_string()),
269        (None, None) => None,
270    }
271}
272
273pub fn string_field<'a>(payload: &'a serde_json::Value, names: &[&str]) -> Option<&'a str> {
274    let obj = payload.as_object()?;
275    for name in names {
276        match obj.get(*name) {
277            Some(serde_json::Value::String(value)) if !value.is_empty() => return Some(value),
278            _ => {}
279        }
280    }
281    None
282}
283
284pub fn u32_field(payload: &serde_json::Value, names: &[&str]) -> Option<u32> {
285    let obj = payload.as_object()?;
286    for name in names {
287        match obj.get(*name) {
288            Some(serde_json::Value::Number(n)) => {
289                if let Some(v) = n.as_u64() {
290                    return u32::try_from(v).ok();
291                }
292            }
293            Some(serde_json::Value::String(s)) => {
294                if let Ok(v) = s.parse() {
295                    return Some(v);
296                }
297            }
298            _ => {}
299        }
300    }
301    None
302}
303
304pub fn title_field(payload: &serde_json::Value) -> Option<String> {
305    string_field(
306        payload,
307        &[
308            "title",
309            "name",
310            "thread_name",
311            "session_name",
312            "conversation_title",
313            "display_name",
314        ],
315    )
316    .map(str::to_string)
317}
318
319pub fn cwd_field(payload: &serde_json::Value) -> Option<String> {
320    string_field(
321        payload,
322        &[
323            "cwd",
324            "workdir",
325            "workDir",
326            "working_directory",
327            "work_dir",
328            "directory",
329        ],
330    )
331    .map(str::to_string)
332}
333
334#[cfg(test)]
335#[path = "status_tests.rs"]
336mod tests;