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