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