Skip to main content

opensession_core/
handoff_artifact.rs

1use std::cmp::Ordering;
2use std::path::Path;
3use std::time::UNIX_EPOCH;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::Session;
9
10pub const HANDOFF_ARTIFACT_VERSION: &str = "1";
11pub const HANDOFF_MERGE_POLICY_TIME_ASC: &str = "time_asc";
12
13#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "lowercase")]
15pub enum HandoffPayloadFormat {
16    Json,
17    Jsonl,
18}
19
20impl std::fmt::Display for HandoffPayloadFormat {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Self::Json => write!(f, "json"),
24            Self::Jsonl => write!(f, "jsonl"),
25        }
26    }
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct HandoffArtifactSource {
31    pub session_id: String,
32    pub tool: String,
33    pub model: String,
34    pub source_path: String,
35    pub source_mtime_ms: u64,
36    pub source_size: u64,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct HandoffArtifact {
41    pub version: String,
42    pub artifact_id: String,
43    pub created_at: DateTime<Utc>,
44    pub merge_policy: String,
45    pub sources: Vec<HandoffArtifactSource>,
46    pub payload_format: HandoffPayloadFormat,
47    pub payload: serde_json::Value,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub derived_markdown: Option<String>,
50}
51
52impl HandoffArtifact {
53    pub fn stale_reasons(&self) -> Vec<HandoffSourceStaleReason> {
54        stale_reasons(&self.sources)
55    }
56
57    pub fn is_stale(&self) -> bool {
58        !self.stale_reasons().is_empty()
59    }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63pub struct HandoffSourceStaleReason {
64    pub session_id: String,
65    pub source_path: String,
66    pub reason: String,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct SourceFingerprint {
71    pub mtime_ms: u64,
72    pub size: u64,
73}
74
75pub fn merge_time_order(
76    left_created_at: DateTime<Utc>,
77    left_session_id: &str,
78    right_created_at: DateTime<Utc>,
79    right_session_id: &str,
80) -> Ordering {
81    left_created_at
82        .cmp(&right_created_at)
83        .then_with(|| left_session_id.cmp(right_session_id))
84}
85
86pub fn sort_sessions_time_asc(sessions: &mut [Session]) {
87    sessions.sort_by(|left, right| {
88        merge_time_order(
89            left.context.created_at,
90            &left.session_id,
91            right.context.created_at,
92            &right.session_id,
93        )
94    });
95}
96
97pub fn source_fingerprint(path: &Path) -> std::io::Result<SourceFingerprint> {
98    let metadata = std::fs::metadata(path)?;
99    let mtime_ms = metadata
100        .modified()
101        .ok()
102        .and_then(|value| value.duration_since(UNIX_EPOCH).ok())
103        .map(|duration| duration.as_millis() as u64)
104        .unwrap_or(0);
105    Ok(SourceFingerprint {
106        mtime_ms,
107        size: metadata.len(),
108    })
109}
110
111pub fn source_from_session(
112    session: &Session,
113    source_path: &Path,
114) -> std::io::Result<HandoffArtifactSource> {
115    let fp = source_fingerprint(source_path)?;
116    Ok(HandoffArtifactSource {
117        session_id: session.session_id.clone(),
118        tool: session.agent.tool.clone(),
119        model: session.agent.model.clone(),
120        source_path: source_path.to_string_lossy().into_owned(),
121        source_mtime_ms: fp.mtime_ms,
122        source_size: fp.size,
123    })
124}
125
126pub fn stale_reasons(sources: &[HandoffArtifactSource]) -> Vec<HandoffSourceStaleReason> {
127    let mut reasons = Vec::new();
128    for source in sources {
129        let path = Path::new(&source.source_path);
130        let metadata = match std::fs::metadata(path) {
131            Ok(metadata) => metadata,
132            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
133                reasons.push(HandoffSourceStaleReason {
134                    session_id: source.session_id.clone(),
135                    source_path: source.source_path.clone(),
136                    reason: "missing_source_file".to_string(),
137                });
138                continue;
139            }
140            Err(_) => {
141                reasons.push(HandoffSourceStaleReason {
142                    session_id: source.session_id.clone(),
143                    source_path: source.source_path.clone(),
144                    reason: "unreadable_source_file".to_string(),
145                });
146                continue;
147            }
148        };
149
150        let current_mtime_ms = metadata
151            .modified()
152            .ok()
153            .and_then(|value| value.duration_since(UNIX_EPOCH).ok())
154            .map(|duration| duration.as_millis() as u64)
155            .unwrap_or(0);
156        let current_size = metadata.len();
157
158        if current_mtime_ms != source.source_mtime_ms || current_size != source.source_size {
159            reasons.push(HandoffSourceStaleReason {
160                session_id: source.session_id.clone(),
161                source_path: source.source_path.clone(),
162                reason: "source_fingerprint_changed".to_string(),
163            });
164        }
165    }
166    reasons
167}
168
169#[cfg(test)]
170mod tests {
171    use chrono::{Duration, Utc};
172
173    use crate::testing;
174
175    use super::*;
176
177    #[test]
178    fn sort_sessions_time_asc_orders_by_time_then_session_id() {
179        let now = Utc::now();
180        let mut s1 = Session::new("session-z".to_string(), testing::agent());
181        s1.context.created_at = now + Duration::seconds(10);
182        let mut s2 = Session::new("session-b".to_string(), testing::agent());
183        s2.context.created_at = now;
184        let mut s3 = Session::new("session-a".to_string(), testing::agent());
185        s3.context.created_at = now;
186
187        let mut sessions = vec![s1, s2, s3];
188        sort_sessions_time_asc(&mut sessions);
189
190        let ids = sessions
191            .into_iter()
192            .map(|session| session.session_id)
193            .collect::<Vec<_>>();
194        assert_eq!(
195            ids,
196            vec![
197                "session-a".to_string(),
198                "session-b".to_string(),
199                "session-z".to_string()
200            ]
201        );
202    }
203
204    #[test]
205    fn stale_reasons_detects_fingerprint_changes() {
206        let temp_path = std::env::temp_dir().join(format!(
207            "opensession-handoff-artifact-{}.jsonl",
208            Utc::now().timestamp_nanos_opt().unwrap_or_default()
209        ));
210        std::fs::write(&temp_path, b"before").expect("write temp file");
211
212        let mut session = Session::new("session-1".to_string(), testing::agent());
213        session.context.created_at = Utc::now();
214        let source = source_from_session(&session, &temp_path).expect("source fingerprint");
215        assert!(stale_reasons(&[source.clone()]).is_empty());
216
217        std::fs::write(&temp_path, b"after-after").expect("rewrite temp file");
218        let reasons = stale_reasons(&[source]);
219        assert_eq!(reasons.len(), 1);
220        assert_eq!(reasons[0].reason, "source_fingerprint_changed");
221
222        let _ = std::fs::remove_file(&temp_path);
223    }
224}