agentmux_srv\backend/
session_backfill.rs1use crate::registry::Registry;
19use std::path::{Path, PathBuf};
20
21pub fn encode_project_slug(path: &str) -> String {
26 path.chars()
27 .map(|c| if matches!(c, '/' | '\\' | ':' | '.') { '-' } else { c })
28 .collect()
29}
30
31fn largest_with_size(projects_dir: &Path, slug: &str) -> Option<(u64, String)> {
34 let dir = projects_dir.join(slug);
35 let mut best: Option<(u64, String)> = None;
36 for entry in std::fs::read_dir(&dir).ok()?.flatten() {
37 let p = entry.path();
38 if p.extension().map_or(false, |e| e == "jsonl") {
39 if let (Ok(meta), Some(stem)) = (entry.metadata(), p.file_stem()) {
40 let stem = stem.to_string_lossy().to_string();
41 if best.as_ref().map_or(true, |(sz, _)| meta.len() > *sz) {
42 best = Some((meta.len(), stem));
43 }
44 }
45 }
46 }
47 best
48}
49
50pub fn largest_session_id(projects_dirs: &[PathBuf], slug: &str) -> Option<String> {
57 let mut best: Option<(u64, String)> = None;
58 for d in projects_dirs {
59 if let Some((sz, stem)) = largest_with_size(d, slug) {
60 if best.as_ref().map_or(true, |(b, _)| sz > *b) {
61 best = Some((sz, stem));
62 }
63 }
64 }
65 best.map(|(_, stem)| stem)
66}
67
68pub fn backfill_session_ids(reg: &Registry, shared_dir: &Path) -> usize {
73 let records = match reg.list_active() {
74 Ok(r) => r,
75 Err(_) => return 0,
76 };
77 let default_projects = shared_dir
78 .join("providers")
79 .join("claude")
80 .join("projects");
81 let mut count = 0;
82 for mut rec in records {
83 if rec.data.session_id.as_deref().map_or(false, |s| !s.is_empty()) {
84 continue; }
86 let Some(base) = rec.data.source_agents_base.as_deref() else {
87 continue;
88 };
89 let base = base.trim_end_matches(['/', '\\']);
90 let workspace = format!("{base}/{}", rec.data.working_dir);
91 let slug = encode_project_slug(&workspace);
92 let mut dirs = vec![default_projects.clone()];
97 if let Some(id) = rec.data.identity_id.as_deref() {
98 if !id.is_empty() && id != "default" {
99 dirs.push(
100 shared_dir
101 .join("identities")
102 .join(id)
103 .join("claude")
104 .join("projects"),
105 );
106 }
107 }
108 let Some(sid) = largest_session_id(&dirs, &slug) else {
109 continue;
110 };
111 rec.data.session_id = Some(sid.clone());
112 if reg.upsert(&rec).is_ok() {
113 count += 1;
114 tracing::info!(
115 instance = %rec.data.instance_name,
116 session_id = %sid,
117 "registry: backfilled session_id for cross-channel resume"
118 );
119 }
120 }
121 count
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::registry::{NamedAgentRecord, NamedAgentRecordV1};
128 use std::fs;
129
130 #[test]
131 fn encode_slug_matches_claude_convention() {
132 assert_eq!(
134 encode_project_slug(r"C:\Users\asafe\.agentmux\agents\naki-0612a"),
135 "C--Users-asafe--agentmux-agents-naki-0612a"
136 );
137 assert_eq!(
139 encode_project_slug("/home/u/.agentmux/agents/foo-bar"),
140 "-home-u--agentmux-agents-foo-bar"
141 );
142 }
143
144 #[test]
145 fn largest_session_beats_a_fresh_short_one() {
146 let tmp = tempfile::tempdir().unwrap();
147 let projects = tmp.path().to_path_buf();
148 let slug = "C--Users-x--agentmux-agents-naki";
149 let dir = projects.join(slug);
150 fs::create_dir_all(&dir).unwrap();
151 fs::write(dir.join("91f26930-long.jsonl"), vec![b'x'; 6_000_000]).unwrap();
153 fs::write(dir.join("e96ed91b-short.jsonl"), vec![b'x'; 15_000]).unwrap();
155 assert_eq!(
157 largest_session_id(&[projects.clone()], slug).as_deref(),
158 Some("91f26930-long")
159 );
160 assert_eq!(largest_session_id(&[projects], "nope"), None);
162 }
163
164 fn rec(id: &str, base: Option<&str>, wd: &str, sid: Option<&str>) -> NamedAgentRecord {
165 NamedAgentRecord {
166 schema_version: 3,
167 data: NamedAgentRecordV1 {
168 instance_id: id.to_string(),
169 instance_name: id.to_string(),
170 definition_id: "claude-code".to_string(),
171 identity_id: Some("default".to_string()),
172 memory_id: None,
173 session_id: sid.map(String::from),
174 working_dir: wd.to_string(),
175 source_agents_base: base.map(String::from),
176 created_at_ms: 1,
177 last_launched_at_ms: 1,
178 created_by_version: "(legacy)".to_string(),
179 last_launched_by_version: "(legacy)".to_string(),
180 },
181 }
182 }
183
184 #[test]
185 fn backfill_fills_empties_picks_largest_and_is_idempotent() {
186 let tmp = tempfile::tempdir().unwrap();
187 let shared = tmp.path();
188 let projects = shared.join("providers").join("claude").join("projects");
189 let base = r"C:\agents";
190 let slug = encode_project_slug(&format!("{base}/naki-0612a"));
192 let dir = projects.join(&slug);
193 fs::create_dir_all(&dir).unwrap();
194 fs::write(dir.join("LONG.jsonl"), vec![b'x'; 1_000_000]).unwrap();
195 fs::write(dir.join("short.jsonl"), vec![b'x'; 1_000]).unwrap();
196
197 let reg = Registry::open(shared.join("registry")).unwrap();
198 reg.upsert(&rec("naki", Some(base), "naki-0612a", None)).unwrap();
199 reg.upsert(&rec("keep", Some(base), "keep-x", Some("EXISTING"))).unwrap();
201 reg.upsert(&rec("notx", Some(base), "ghost-x", None)).unwrap();
203
204 let n = backfill_session_ids(®, shared);
205 assert_eq!(n, 1, "only the one empty record with a transcript is filled");
206
207 let by = |id: &str| {
208 reg.list_active()
209 .unwrap()
210 .into_iter()
211 .find(|r| r.data.instance_id == id)
212 .unwrap()
213 };
214 assert_eq!(by("naki").data.session_id.as_deref(), Some("LONG"), "largest session");
215 assert_eq!(by("keep").data.session_id.as_deref(), Some("EXISTING"), "untouched");
216 assert_eq!(by("notx").data.session_id, None, "no transcript → left null");
217
218 assert_eq!(backfill_session_ids(®, shared), 0);
220 }
221
222 #[test]
223 fn backfill_resolves_identity_bundle_sessions() {
224 let tmp = tempfile::tempdir().unwrap();
227 let shared = tmp.path();
228 let base = r"C:\agents";
229 let slug = encode_project_slug(&format!("{base}/bound-0612a"));
230 let idir = shared
231 .join("identities")
232 .join("bundle1")
233 .join("claude")
234 .join("projects")
235 .join(&slug);
236 fs::create_dir_all(&idir).unwrap();
237 fs::write(idir.join("BOUND.jsonl"), vec![b'x'; 500_000]).unwrap();
238
239 let reg = Registry::open(shared.join("registry")).unwrap();
240 let mut r = rec("bound", Some(base), "bound-0612a", None);
241 r.data.identity_id = Some("bundle1".to_string());
242 reg.upsert(&r).unwrap();
243
244 assert_eq!(backfill_session_ids(®, shared), 1);
245 let got = reg
246 .list_active()
247 .unwrap()
248 .into_iter()
249 .find(|x| x.data.instance_id == "bound")
250 .unwrap();
251 assert_eq!(
252 got.data.session_id.as_deref(),
253 Some("BOUND"),
254 "identity-bound session resolved from the bundle dir"
255 );
256 }
257}