Skip to main content

agent_berth/
store.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::time::{Duration, SystemTime, UNIX_EPOCH};
3
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::process::pid_alive;
9use crate::providers;
10use crate::status::{
11    AgentSession, AgentStatus, Source, cwd_field, string_field, title_field, u32_field,
12};
13
14pub const PLUGIN_STALE: Duration = Duration::from_secs(5);
15pub const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
16
17#[derive(Debug, Clone)]
18pub enum Change {
19    Hooks { provider: String },
20    Snapshot { provider: String, instance: String },
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum SessionKind {
26    Hook,
27    Plugin,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PluginSnapshot {
32    #[serde(default)]
33    pub status: BTreeMap<String, String>,
34    #[serde(default)]
35    pub blocking: Vec<String>,
36    #[serde(default)]
37    pub titles: BTreeMap<String, String>,
38    #[serde(default)]
39    pub cwd: Option<String>,
40    #[serde(default)]
41    pub cmdline: Vec<String>,
42    #[serde(default)]
43    pub pid: Option<u32>,
44    #[serde(default)]
45    pub created_ms: u64,
46    #[serde(default)]
47    pub last_report_ms: u64,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Store {
52    #[serde(default)]
53    pub last_heartbeat_ms: u64,
54    #[serde(default)]
55    pub previous_heartbeat_ms: Option<u64>,
56    #[serde(default)]
57    pub hooks: BTreeMap<String, BTreeMap<String, AgentSession>>,
58    #[serde(default)]
59    pub snapshots: BTreeMap<String, BTreeMap<String, PluginSnapshot>>,
60    #[serde(default)]
61    pub removed: BTreeMap<String, BTreeMap<String, u64>>,
62}
63
64impl Default for Store {
65    fn default() -> Self {
66        Self {
67            last_heartbeat_ms: now_ms(),
68            previous_heartbeat_ms: None,
69            hooks: BTreeMap::new(),
70            snapshots: BTreeMap::new(),
71            removed: BTreeMap::new(),
72        }
73    }
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
77pub struct ProviderStats {
78    pub provider: String,
79    pub running: u64,
80    pub waiting: u64,
81    pub idle: u64,
82    pub done: u64,
83    pub total: u64,
84}
85
86impl ProviderStats {
87    fn add(&mut self, status: AgentStatus) {
88        match status {
89            AgentStatus::Running => self.running += 1,
90            AgentStatus::Waiting => self.waiting += 1,
91            AgentStatus::Idle => self.idle += 1,
92            AgentStatus::Done => self.done += 1,
93        }
94        self.total += 1;
95    }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ListedSession {
100    pub provider: String,
101    pub session_id: String,
102    pub status: AgentStatus,
103    pub source: Source,
104    pub cwd: Option<String>,
105    pub cmdline: Vec<String>,
106    pub pid: Option<u32>,
107    pub created_ms: u64,
108    pub last_report_ms: u64,
109    pub kind: SessionKind,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub parent_id: Option<String>,
112    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
113    pub exited: bool,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub title: Option<String>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub transcript_path: Option<String>,
118}
119
120impl Store {
121    pub fn on_server_start(&mut self) {
122        let now = now_ms();
123        if self.last_heartbeat_ms > 0 {
124            self.previous_heartbeat_ms = Some(self.last_heartbeat_ms);
125        }
126        self.last_heartbeat_ms = now;
127    }
128
129    pub fn heartbeat(&mut self) {
130        self.last_heartbeat_ms = now_ms();
131    }
132
133    pub fn update(&mut self, provider: &str, payload: Value) -> Result<Change> {
134        if !payload.is_object() {
135            anyhow::bail!("provide a JSON object");
136        }
137        if payload.get("hook_event_name").is_some() || payload.get("hookEventName").is_some() {
138            let sid = crate::status::session_key(&payload);
139            if let Some(sid) = &sid {
140                self.clear_removed(provider, sid);
141            }
142            let bucket = self.hooks.entry(provider.to_string()).or_default();
143            providers::apply_hook(provider, bucket, &payload);
144            if let Some(sid) = sid {
145                if let Some(session) = bucket.get_mut(&sid) {
146                    touch_session(session, &payload, provider);
147                    if session.exited {
148                        bucket.remove(&sid);
149                    }
150                } else if crate::status::string_field(
151                    &payload,
152                    &["hook_event_name", "hookEventName"],
153                )
154                .is_some_and(|name| name.eq_ignore_ascii_case("SessionEnd"))
155                {
156                    // already removed
157                }
158            }
159            return Ok(Change::Hooks {
160                provider: provider.to_string(),
161            });
162        }
163        let instance = payload
164            .get("id")
165            .and_then(Value::as_str)
166            .filter(|s| !s.is_empty())
167            .ok_or_else(|| anyhow::anyhow!("provide a string id"))?;
168        let raw_status = payload
169            .get("status")
170            .ok_or_else(|| anyhow::anyhow!("status must be an object"))?;
171        let obj = raw_status
172            .as_object()
173            .ok_or_else(|| anyhow::anyhow!("status must be an object"))?;
174        let mut status = BTreeMap::new();
175        for (sid, kind) in obj {
176            let kind = kind
177                .as_str()
178                .ok_or_else(|| anyhow::anyhow!("status values must be idle, busy, or retry"))?;
179            if !matches!(kind, "idle" | "busy" | "retry") {
180                anyhow::bail!("status values must be idle, busy, or retry");
181            }
182            status.insert(sid.clone(), kind.to_string());
183        }
184        for sid in status.keys() {
185            self.clear_removed(provider, sid);
186        }
187        let blocking = match payload.get("blocking") {
188            None => Vec::new(),
189            Some(Value::Array(items)) => {
190                let mut out = Vec::new();
191                for item in items {
192                    let sid = item
193                        .as_str()
194                        .ok_or_else(|| anyhow::anyhow!("blocking must be an array of strings"))?;
195                    out.push(sid.to_string());
196                }
197                out
198            }
199            Some(_) => anyhow::bail!("blocking must be an array of strings"),
200        };
201        let titles = match payload.get("titles") {
202            None => BTreeMap::new(),
203            Some(Value::Object(items)) => {
204                let mut out = BTreeMap::new();
205                for (sid, title) in items {
206                    let title = title
207                        .as_str()
208                        .ok_or_else(|| anyhow::anyhow!("titles must be an object of strings"))?;
209                    out.insert(sid.clone(), providers::normalize_title(provider, title));
210                }
211                out
212            }
213            Some(_) => anyhow::bail!("titles must be an object of strings"),
214        };
215        let pid = u32_field(&payload, &["pid"]).or_else(|| instance.parse().ok());
216        let created_ms = self
217            .snapshots
218            .get(provider)
219            .and_then(|instances| instances.get(instance))
220            .map(|existing| existing.created_ms)
221            .filter(|created| *created > 0)
222            .unwrap_or_else(now_ms);
223        let snapshot = PluginSnapshot {
224            status,
225            blocking,
226            titles,
227            cwd: cwd_field(&payload),
228            cmdline: string_list(&payload, "cmdline"),
229            pid,
230            created_ms,
231            last_report_ms: now_ms(),
232        };
233        self.snapshots
234            .entry(provider.to_string())
235            .or_default()
236            .insert(instance.to_string(), snapshot);
237        Ok(Change::Snapshot {
238            provider: provider.to_string(),
239            instance: instance.to_string(),
240        })
241    }
242
243    pub fn mark_removed(&mut self, provider: &str, session_id: &str) -> u64 {
244        let at = now_ms();
245        self.removed
246            .entry(provider.to_string())
247            .or_default()
248            .insert(session_id.to_string(), at);
249        at
250    }
251
252    pub fn clear_removed(&mut self, provider: &str, session_id: &str) {
253        if let Some(bucket) = self.removed.get_mut(provider) {
254            bucket.remove(session_id);
255        }
256    }
257
258    pub fn is_removed(&self, provider: &str, session_id: &str) -> bool {
259        self.removed
260            .get(provider)
261            .is_some_and(|bucket| bucket.contains_key(session_id))
262    }
263
264    pub fn discover(&mut self, ctx: &crate::paths::Context) -> bool {
265        let claude =
266            crate::providers::discover_claude(ctx, self.hooks.entry("claude".into()).or_default());
267        let codex =
268            crate::providers::discover_codex(ctx, self.hooks.entry("codex".into()).or_default());
269        claude || codex
270    }
271
272    pub fn listed(&self) -> Vec<ListedSession> {
273        let mut out = Vec::new();
274        for (provider, sessions) in &self.hooks {
275            for (sid, session) in sessions {
276                let mut cmdline = session.cmdline.clone();
277                if cmdline.is_empty() {
278                    cmdline = providers::resume_cmd(provider, sid);
279                }
280                out.push(ListedSession {
281                    provider: provider.clone(),
282                    session_id: sid.clone(),
283                    status: session.status,
284                    source: session.source,
285                    cwd: session.cwd.clone(),
286                    cmdline,
287                    pid: session.pid,
288                    created_ms: created_or(session.created_ms, session.last_report_ms),
289                    last_report_ms: session.last_report_ms,
290                    kind: SessionKind::Hook,
291                    parent_id: session.parent_id.clone(),
292                    exited: session.exited,
293                    title: session.title.clone(),
294                    transcript_path: session.transcript_path.clone(),
295                });
296            }
297        }
298        for (provider, instances) in &self.snapshots {
299            for snapshot in instances.values() {
300                let blocking: std::collections::BTreeSet<_> =
301                    snapshot.blocking.iter().cloned().collect();
302                for (sid, kind) in &snapshot.status {
303                    let status = if blocking.contains(sid) {
304                        AgentStatus::Waiting
305                    } else if matches!(kind.as_str(), "busy" | "retry") {
306                        AgentStatus::Running
307                    } else {
308                        AgentStatus::Idle
309                    };
310                    let mut cmdline = snapshot.cmdline.clone();
311                    if cmdline.is_empty() {
312                        cmdline = providers::resume_cmd(provider, sid);
313                    }
314                    out.push(ListedSession {
315                        provider: provider.clone(),
316                        session_id: sid.clone(),
317                        status,
318                        source: Source::Cli,
319                        cwd: snapshot.cwd.clone(),
320                        cmdline,
321                        pid: snapshot.pid,
322                        created_ms: created_or(snapshot.created_ms, snapshot.last_report_ms),
323                        last_report_ms: snapshot.last_report_ms,
324                        kind: SessionKind::Plugin,
325                        parent_id: None,
326                        exited: false,
327                        title: snapshot.titles.get(sid).cloned(),
328                        transcript_path: None,
329                    });
330                }
331            }
332        }
333        out.retain(|session| !self.is_removed(&session.provider, &session.session_id));
334        let mut best: BTreeMap<(String, String, SessionKind), ListedSession> = BTreeMap::new();
335        for session in out {
336            let key = (
337                session.provider.clone(),
338                session.session_id.clone(),
339                session.kind,
340            );
341            match best.get(&key) {
342                Some(existing) if existing.last_report_ms >= session.last_report_ms => {}
343                _ => {
344                    best.insert(key, session);
345                }
346            }
347        }
348        let mut out: Vec<ListedSession> = best.into_values().collect();
349        out.sort_by(|a, b| (&a.provider, &a.session_id).cmp(&(&b.provider, &b.session_id)));
350        out
351    }
352
353    pub fn active(&self) -> Vec<ListedSession> {
354        let now = now_ms();
355        self.listed()
356            .into_iter()
357            .filter(|session| session.is_active(now))
358            .collect()
359    }
360
361    pub fn stats(&self) -> Vec<ProviderStats> {
362        let mut by_provider: BTreeMap<String, ProviderStats> = BTreeMap::new();
363        for session in self.active() {
364            by_provider
365                .entry(session.provider.clone())
366                .or_insert_with(|| ProviderStats {
367                    provider: session.provider.clone(),
368                    ..ProviderStats::default()
369                })
370                .add(session.status);
371        }
372        by_provider.into_values().collect()
373    }
374
375    pub fn resumable(&self, idle: Option<Duration>) -> Vec<ListedSession> {
376        let now = now_ms();
377        self.listed()
378            .into_iter()
379            .filter(|session| session.is_resumable(self, now, idle))
380            .collect()
381    }
382
383    pub fn prune(&mut self, now_ms: u64, retention: Duration) -> bool {
384        let cutoff = now_ms.saturating_sub(retention.as_millis() as u64);
385        let mut changed = false;
386
387        for bucket in self.hooks.values_mut() {
388            let before = bucket.len();
389            bucket.retain(|_, session| {
390                session.pid.is_some_and(pid_alive) || session.last_report_ms >= cutoff
391            });
392            changed |= bucket.len() != before;
393        }
394        self.hooks.retain(|_, bucket| !bucket.is_empty());
395
396        for bucket in self.snapshots.values_mut() {
397            let before = bucket.len();
398            bucket.retain(|_, snapshot| {
399                snapshot.pid.is_some_and(pid_alive) || snapshot.last_report_ms >= cutoff
400            });
401            changed |= bucket.len() != before;
402        }
403        self.snapshots.retain(|_, bucket| !bucket.is_empty());
404
405        let mut live: BTreeSet<(String, String)> = BTreeSet::new();
406        for (provider, bucket) in &self.hooks {
407            for sid in bucket.keys() {
408                live.insert((provider.clone(), sid.clone()));
409            }
410        }
411        for (provider, bucket) in &self.snapshots {
412            for snapshot in bucket.values() {
413                for sid in snapshot.status.keys() {
414                    live.insert((provider.clone(), sid.clone()));
415                }
416            }
417        }
418        for (provider, bucket) in self.removed.iter_mut() {
419            let before = bucket.len();
420            bucket.retain(|sid, _| live.contains(&(provider.clone(), sid.clone())));
421            changed |= bucket.len() != before;
422        }
423        self.removed.retain(|_, bucket| !bucket.is_empty());
424        changed
425    }
426}
427
428impl ListedSession {
429    pub fn is_active(&self, now_ms: u64) -> bool {
430        if self.exited || self.parent_id.is_some() {
431            return false;
432        }
433        match self.kind {
434            SessionKind::Plugin => {
435                now_ms.saturating_sub(self.last_report_ms) <= PLUGIN_STALE.as_millis() as u64
436            }
437            SessionKind::Hook => {
438                self.status.is_busy()
439                    || self.pid.is_some_and(pid_alive)
440                    || (self.source == Source::Desktop && !self.exited)
441            }
442        }
443    }
444
445    pub fn process_attached(&self) -> bool {
446        self.pid.is_some_and(pid_alive)
447    }
448
449    pub fn is_resumable(&self, store: &Store, now_ms: u64, idle: Option<Duration>) -> bool {
450        if self.exited || self.parent_id.is_some() {
451            return false;
452        }
453        if self.process_attached() {
454            return false;
455        }
456        if self.cwd.as_ref().is_none_or(|cwd| cwd.is_empty()) {
457            return false;
458        }
459        if self.status.is_busy() {
460            return true;
461        }
462        if !matches!(self.status, AgentStatus::Idle | AgentStatus::Done) {
463            return false;
464        }
465        let Some(idle) = idle else {
466            return false;
467        };
468        idle_age_ms(self, store, now_ms) <= idle.as_millis() as u64
469    }
470}
471
472fn created_or(created_ms: u64, fallback_ms: u64) -> u64 {
473    if created_ms > 0 {
474        created_ms
475    } else {
476        fallback_ms
477    }
478}
479
480fn idle_age_ms(session: &ListedSession, store: &Store, now_ms: u64) -> u64 {
481    let anchor = store.previous_heartbeat_ms.unwrap_or(now_ms);
482    if session.last_report_ms > anchor {
483        now_ms.saturating_sub(session.last_report_ms)
484    } else {
485        anchor.saturating_sub(session.last_report_ms)
486    }
487}
488
489fn touch_session(session: &mut AgentSession, payload: &Value, provider: &str) {
490    // Child hooks may carry the parent's transcript_path. Never show it as the
491    // child's conversation; use the explicitly supplied child transcript instead.
492    let keys: &[&str] = if session.parent_id.is_some() {
493        &["agent_transcript_path"]
494    } else {
495        &["transcript_path"]
496    };
497    if matches!(provider, "claude" | "codex")
498        && let Some(path) = string_field(payload, keys).filter(|path| !path.is_empty())
499    {
500        session.transcript_path = Some(path.to_string());
501    }
502    session.last_report_ms = now_ms();
503    if let Some(cwd) = cwd_field(payload) {
504        session.cwd = Some(cwd);
505    }
506    if let Some(title) = title_field(payload) {
507        session.title = Some(title);
508    }
509    if let Some(pid) = u32_field(payload, &["pid"]) {
510        session.pid = Some(pid);
511    }
512    if session.cmdline.is_empty()
513        && let Some(sid) = crate::status::session_key(payload)
514    {
515        session.cmdline = providers::resume_cmd(provider, &sid);
516    }
517    let name = string_field(payload, &["hook_event_name", "hookEventName"]).unwrap_or("");
518    if name.eq_ignore_ascii_case("SessionEnd") {
519        session.exited = true;
520    }
521}
522
523fn string_list(payload: &Value, key: &str) -> Vec<String> {
524    payload
525        .get(key)
526        .and_then(Value::as_array)
527        .map(|items| {
528            items
529                .iter()
530                .filter_map(Value::as_str)
531                .map(str::to_string)
532                .collect()
533        })
534        .unwrap_or_default()
535}
536
537pub fn now_ms() -> u64 {
538    SystemTime::now()
539        .duration_since(UNIX_EPOCH)
540        .map(|d| d.as_millis() as u64)
541        .unwrap_or(0)
542}
543
544#[cfg(test)]
545#[path = "store_tests.rs"]
546mod tests;