Skip to main content

supercode_harness/
session_activity.rs

1//! Protocol-neutral activity for persisted and live harness sessions.
2//!
3//! Activity is deliberately separate from transcript freshness and UI
4//! attention. A process receipt proves presence; only a harness lifecycle
5//! boundary or runtime state proves whether a turn is working.
6
7use 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/// Whether a durable session currently has a proven live owner.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum SessionPresence {
23    /// The session is durable, but no live owner is proven.
24    Persisted,
25    /// A harness or Supercode runtime currently owns the session.
26    Running,
27    /// A Supercode-owned runtime is shutting down.
28    ShuttingDown,
29}
30
31/// Turn activity, independent of presence and frontend attention.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum SessionTurnState {
35    /// Presence is known but the harness exposes no trustworthy turn state.
36    Unknown,
37    /// The runtime is ready for user input.
38    Idle,
39    /// A model, tool, or scheduler turn is active.
40    Working,
41    /// The runtime has issued a structured request that needs a response.
42    NeedsInput,
43}
44
45/// Provenance for one normalized activity observation.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct SessionActivityEvidence {
48    /// Stable, non-sensitive evidence source.
49    pub source: String,
50    /// Harness-native state token, when one was published.
51    pub native_state: Option<String>,
52    /// Wall-clock time at which Supercode sampled the evidence.
53    pub observed_at_ms: u64,
54    /// Harness version attached to the evidence, when available.
55    pub harness_version: Option<String>,
56}
57
58/// Normalized lifecycle state for one harness-native session.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct SessionActivity {
61    /// Owning harness.
62    pub harness: HarnessId,
63    /// Harness-native durable session id.
64    pub session_id: String,
65    /// Live ownership, independent of turn state.
66    pub presence: SessionPresence,
67    /// Current turn state, independent of unread/attention state.
68    pub turn: SessionTurnState,
69    /// Why this state is trustworthy. Never contains a pid, path, socket, or token.
70    pub evidence: SessionActivityEvidence,
71}
72
73impl SessionActivity {
74    /// Compare transition-bearing state while ignoring the observation clock.
75    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    /// Stable subscription identity without exposing a persistence path.
86    pub(crate) fn key(&self) -> (String, String) {
87        (self.harness.as_str().to_string(), self.session_id.clone())
88    }
89}
90
91/// Stateful activity resolver. It caches only expensive process-ownership
92/// discovery; every lifecycle boundary is still sampled on each poll.
93#[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
153/// Resolve stock-harness activity without consulting Supercode-owned runtime
154/// receipts. Discovery and the subscription lane share this exact mapping.
155pub(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/// Internal test fixture for the evidence precedence table.
322#[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}