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}