Skip to main content

pulsedb/activity/
types.rs

1//! Data types for agent activity tracking.
2//!
3//! Activities represent the presence and current state of an agent
4//! within a collective. Unlike other PulseDB entities, activities are
5//! keyed by `(collective_id, agent_id)` composite rather than a UUID,
6//! since each agent can have at most one active session per collective.
7
8use serde::{Deserialize, Serialize};
9
10use crate::types::{CollectiveId, Timestamp};
11
12/// A stored agent activity — presence record within a collective.
13///
14/// Activities track which agents are currently operating in a collective,
15/// what they're working on, and when they last checked in. This enables
16/// agent coordination and discovery.
17///
18/// # Key Design
19///
20/// Activities are uniquely identified by `(collective_id, agent_id)`.
21/// Re-registering with the same pair replaces the existing activity
22/// (upsert semantics).
23///
24/// # Staleness
25///
26/// An activity is considered stale when `last_heartbeat` is older than
27/// `Config::activity::stale_threshold`. Stale activities are excluded
28/// from `get_active_agents()` results but remain in storage until
29/// explicitly ended or the collective is deleted.
30#[derive(Clone, Debug, Serialize, Deserialize)]
31pub struct Activity {
32    /// The agent's identifier (e.g., "claude-opus", "agent-47").
33    pub agent_id: String,
34
35    /// The collective this activity belongs to.
36    pub collective_id: CollectiveId,
37
38    /// What the agent is currently working on (max 1KB).
39    pub current_task: Option<String>,
40
41    /// Summary of the agent's current context (max 1KB).
42    pub context_summary: Option<String>,
43
44    /// When this activity was first registered.
45    pub started_at: Timestamp,
46
47    /// When the agent last sent a heartbeat.
48    pub last_heartbeat: Timestamp,
49}
50
51/// Input for registering a new agent activity.
52///
53/// The `started_at` and `last_heartbeat` timestamps are set automatically
54/// to `Timestamp::now()` when the activity is registered.
55///
56/// # Example
57///
58/// ```rust
59/// # fn main() -> pulsedb::Result<()> {
60/// # let dir = tempfile::tempdir().unwrap();
61/// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
62/// # let collective_id = db.create_collective("example")?;
63/// use pulsedb::NewActivity;
64///
65/// let activity = NewActivity {
66///     agent_id: "claude-opus".to_string(),
67///     collective_id,
68///     current_task: Some("Implementing error handling".to_string()),
69///     context_summary: Some("Working on src/error.rs".to_string()),
70/// };
71/// db.register_activity(activity)?;
72/// # Ok(())
73/// # }
74/// ```
75pub struct NewActivity {
76    /// The agent's identifier (non-empty, max 255 bytes).
77    pub agent_id: String,
78
79    /// The collective to register in (must exist).
80    pub collective_id: CollectiveId,
81
82    /// What the agent is currently working on (max 1KB).
83    pub current_task: Option<String>,
84
85    /// Summary of the agent's current context (max 1KB).
86    pub context_summary: Option<String>,
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn test_activity_bincode_roundtrip() {
95        let activity = Activity {
96            agent_id: "claude-opus".to_string(),
97            collective_id: CollectiveId::new(),
98            current_task: Some("Implementing feature X".to_string()),
99            context_summary: Some("Working on module Y".to_string()),
100            started_at: Timestamp::now(),
101            last_heartbeat: Timestamp::now(),
102        };
103
104        let bytes = bincode::serialize(&activity).unwrap();
105        let restored: Activity = bincode::deserialize(&bytes).unwrap();
106
107        assert_eq!(activity.agent_id, restored.agent_id);
108        assert_eq!(activity.collective_id, restored.collective_id);
109        assert_eq!(activity.current_task, restored.current_task);
110        assert_eq!(activity.context_summary, restored.context_summary);
111        assert_eq!(activity.started_at, restored.started_at);
112        assert_eq!(activity.last_heartbeat, restored.last_heartbeat);
113    }
114
115    #[test]
116    fn test_activity_with_optional_fields_roundtrip() {
117        let activity = Activity {
118            agent_id: "agent-minimal".to_string(),
119            collective_id: CollectiveId::new(),
120            current_task: None,
121            context_summary: None,
122            started_at: Timestamp::from_millis(1000),
123            last_heartbeat: Timestamp::from_millis(2000),
124        };
125
126        let bytes = bincode::serialize(&activity).unwrap();
127        let restored: Activity = bincode::deserialize(&bytes).unwrap();
128
129        assert_eq!(activity.agent_id, restored.agent_id);
130        assert!(restored.current_task.is_none());
131        assert!(restored.context_summary.is_none());
132        assert_eq!(activity.started_at, restored.started_at);
133        assert_eq!(activity.last_heartbeat, restored.last_heartbeat);
134    }
135}