1use std::collections::{BTreeMap, HashMap};
8use std::path::PathBuf;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use serde::{Deserialize, Serialize};
12
13use crate::claude_peer::{ClaudePeerSession, ClaudePeerStatus};
14#[cfg(feature = "adapter-api")]
15use crate::codex_peer::CodexPeerTracker;
16use crate::codex_peer::{live_rollouts, rollout_status, CodexPeerStatus};
17use crate::{HarnessHomes, HarnessId, SessionLocator};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum SessionPresence {
23 Persisted,
25 Running,
27 ShuttingDown,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum SessionTurnState {
35 Unknown,
37 Idle,
39 Working,
41 NeedsInput,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct SessionActivityEvidence {
48 pub source: String,
50 pub native_state: Option<String>,
52 pub observed_at_ms: u64,
54 pub harness_version: Option<String>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct SessionActivity {
61 pub harness: HarnessId,
63 pub session_id: String,
65 pub presence: SessionPresence,
67 pub turn: SessionTurnState,
69 pub evidence: SessionActivityEvidence,
71}
72
73impl SessionActivity {
74 pub(crate) fn same_state(&self, other: &Self) -> bool {
76 self.harness == other.harness
77 && self.session_id == other.session_id
78 && self.presence == other.presence
79 && self.turn == other.turn
80 && self.evidence.source == other.evidence.source
81 && self.evidence.native_state == other.evidence.native_state
82 && self.evidence.harness_version == other.evidence.harness_version
83 }
84
85 pub(crate) fn key(&self) -> (String, String) {
87 (self.harness.as_str().to_string(), self.session_id.clone())
88 }
89}
90
91#[cfg(feature = "adapter-api")]
94#[derive(Debug, Default)]
95pub(crate) struct SessionActivityMonitor {
96 codex: CodexPeerTracker,
97}
98
99#[cfg(feature = "adapter-api")]
100impl SessionActivityMonitor {
101 pub(crate) async fn resolve(
102 &mut self,
103 locators: &[SessionLocator],
104 homes: &HarnessHomes,
105 ) -> Result<Vec<SessionActivity>, crate::SdkError> {
106 let authorization = crate::RuntimeAuthorization::observer();
107 let entries = crate::LocalRuntimeRegistry::new()
108 .list(
109 &crate::RuntimeRegistryQuery {
110 persisted: Default::default(),
111 include_live: true,
112 include_persisted: false,
113 },
114 &authorization,
115 )
116 .await?;
117 let owned = entries
118 .into_iter()
119 .map(|entry| ((entry.source_harness, entry.source_session_id), entry.state))
120 .collect::<BTreeMap<_, _>>();
121 let claude = read_claude(locators, homes);
122 let codex = locators
123 .iter()
124 .any(|locator| locator.harness.as_str() == HarnessId::CODEX)
125 .then(|| self.codex.sample(&homes.codex))
126 .unwrap_or_default();
127 let mut activities = resolve_stock_with_evidence(locators, &claude, &codex)
128 .into_iter()
129 .map(|activity| (activity.key(), activity))
130 .collect::<BTreeMap<_, _>>();
131 let observed_at_ms = now_ms();
132 for locator in locators {
133 let key = (
134 locator.harness.as_str().to_string(),
135 locator.session_id.clone(),
136 );
137 if let Some(state) = owned.get(&key).copied() {
138 activities.insert(key, owned_activity(locator, state, observed_at_ms));
139 }
140 }
141 Ok(locators
142 .iter()
143 .filter_map(|locator| {
144 activities.remove(&(
145 locator.harness.as_str().to_string(),
146 locator.session_id.clone(),
147 ))
148 })
149 .collect())
150 }
151}
152
153pub(crate) fn resolve_stock_session_activities(
156 locators: &[SessionLocator],
157 homes: &HarnessHomes,
158) -> Vec<SessionActivity> {
159 let claude = read_claude(locators, homes);
160 let codex = locators
161 .iter()
162 .any(|locator| locator.harness.as_str() == HarnessId::CODEX)
163 .then(|| live_rollouts(&homes.codex))
164 .unwrap_or_default();
165 resolve_stock_with_evidence(locators, &claude, &codex)
166}
167
168fn read_claude(
169 locators: &[SessionLocator],
170 homes: &HarnessHomes,
171) -> HashMap<String, ClaudePeerSession> {
172 locators
173 .iter()
174 .any(|locator| locator.harness.as_str() == HarnessId::CLAUDE_CODE)
175 .then(|| {
176 crate::claude_peer::read_registry(&crate::claude_peer::registry_dir(homes))
177 .into_iter()
178 .map(|peer| (peer.session_id.clone(), peer))
179 .collect::<HashMap<_, _>>()
180 })
181 .unwrap_or_default()
182}
183
184fn resolve_stock_with_evidence(
185 locators: &[SessionLocator],
186 claude: &HashMap<String, ClaudePeerSession>,
187 codex: &HashMap<PathBuf, CodexPeerStatus>,
188) -> Vec<SessionActivity> {
189 let observed_at_ms = now_ms();
190
191 locators
192 .iter()
193 .map(|locator| {
194 if locator.harness.as_str() == HarnessId::CLAUDE_CODE {
195 if let Some(peer) = claude.get(&locator.session_id) {
196 return claude_activity(locator, peer, observed_at_ms);
197 }
198 }
199 if locator.harness.as_str() == HarnessId::CODEX {
200 if let Some(status) = rollout_status(&codex, locator.storage.path()) {
201 return codex_activity(locator, status, observed_at_ms);
202 }
203 }
204 persisted_activity(locator, observed_at_ms)
205 })
206 .collect()
207}
208
209#[cfg(feature = "adapter-api")]
210fn owned_activity(
211 locator: &SessionLocator,
212 state: crate::RuntimeRegistryState,
213 observed_at_ms: u64,
214) -> SessionActivity {
215 use crate::RuntimeRegistryState;
216 let (presence, turn) = match state {
217 RuntimeRegistryState::Persisted => (SessionPresence::Persisted, SessionTurnState::Unknown),
218 RuntimeRegistryState::Idle => (SessionPresence::Running, SessionTurnState::Idle),
219 RuntimeRegistryState::Busy => (SessionPresence::Running, SessionTurnState::Working),
220 RuntimeRegistryState::ShuttingDown => {
221 (SessionPresence::ShuttingDown, SessionTurnState::Unknown)
222 }
223 };
224 activity(
225 locator,
226 presence,
227 turn,
228 "supercode_runtime",
229 Some(state.as_str()),
230 None,
231 observed_at_ms,
232 )
233}
234
235fn claude_activity(
236 locator: &SessionLocator,
237 peer: &ClaudePeerSession,
238 observed_at_ms: u64,
239) -> SessionActivity {
240 let turn = match peer.status {
241 Some(ClaudePeerStatus::Busy) => SessionTurnState::Working,
242 Some(ClaudePeerStatus::Idle) => SessionTurnState::Idle,
243 None => SessionTurnState::Unknown,
244 };
245 activity(
246 locator,
247 SessionPresence::Running,
248 turn,
249 "claude_registry",
250 peer.status.map(|status| status.as_str()),
251 peer.version.as_deref(),
252 observed_at_ms,
253 )
254}
255
256fn codex_activity(
257 locator: &SessionLocator,
258 status: CodexPeerStatus,
259 observed_at_ms: u64,
260) -> SessionActivity {
261 let turn = match status {
262 CodexPeerStatus::Running => SessionTurnState::Unknown,
263 CodexPeerStatus::Idle => SessionTurnState::Idle,
264 CodexPeerStatus::Busy => SessionTurnState::Working,
265 };
266 activity(
267 locator,
268 SessionPresence::Running,
269 turn,
270 "codex_rollout",
271 Some(status.as_str()),
272 None,
273 observed_at_ms,
274 )
275}
276
277fn persisted_activity(locator: &SessionLocator, observed_at_ms: u64) -> SessionActivity {
278 activity(
279 locator,
280 SessionPresence::Persisted,
281 SessionTurnState::Unknown,
282 "persisted_store",
283 None,
284 None,
285 observed_at_ms,
286 )
287}
288
289fn activity(
290 locator: &SessionLocator,
291 presence: SessionPresence,
292 turn: SessionTurnState,
293 source: &str,
294 native_state: Option<&str>,
295 harness_version: Option<&str>,
296 observed_at_ms: u64,
297) -> SessionActivity {
298 SessionActivity {
299 harness: locator.harness.clone(),
300 session_id: locator.session_id.clone(),
301 presence,
302 turn,
303 evidence: SessionActivityEvidence {
304 source: source.to_string(),
305 native_state: native_state.map(str::to_string),
306 observed_at_ms,
307 harness_version: harness_version.map(str::to_string),
308 },
309 }
310}
311
312fn now_ms() -> u64 {
313 SystemTime::now()
314 .duration_since(UNIX_EPOCH)
315 .unwrap_or_default()
316 .as_millis()
317 .try_into()
318 .unwrap_or(u64::MAX)
319}
320
321#[cfg(all(test, feature = "adapter-api"))]
323pub(crate) fn resolve_fixture(
324 locator: &SessionLocator,
325 owned: Option<crate::RuntimeRegistryState>,
326) -> SessionActivity {
327 owned.map_or_else(
328 || persisted_activity(locator, 0),
329 |state| owned_activity(locator, state, 0),
330 )
331}
332
333#[cfg(all(test, feature = "adapter-api"))]
334mod tests {
335 use std::path::PathBuf;
336
337 use super::*;
338 use crate::{RuntimeRegistryState, StorageLocator};
339
340 #[test]
341 fn normalized_activity_keeps_presence_and_turn_orthogonal() {
342 let locator = SessionLocator {
343 harness: HarnessId("fixture".into()),
344 session_id: "session-1".into(),
345 storage: StorageLocator::File {
346 path: PathBuf::from("/not-read"),
347 },
348 };
349 let cases = [
350 (None, SessionPresence::Persisted, SessionTurnState::Unknown),
351 (
352 Some(RuntimeRegistryState::Idle),
353 SessionPresence::Running,
354 SessionTurnState::Idle,
355 ),
356 (
357 Some(RuntimeRegistryState::Busy),
358 SessionPresence::Running,
359 SessionTurnState::Working,
360 ),
361 (
362 Some(RuntimeRegistryState::ShuttingDown),
363 SessionPresence::ShuttingDown,
364 SessionTurnState::Unknown,
365 ),
366 ];
367 for (native, presence, turn) in cases {
368 let activity = resolve_fixture(&locator, native);
369 assert_eq!((activity.presence, activity.turn), (presence, turn));
370 assert_eq!(activity.harness, locator.harness);
371 assert_eq!(activity.session_id, locator.session_id);
372 assert!(!activity.evidence.source.contains('/'));
373 }
374 }
375}