1use crate::backend::claude_layout::project_dir_name;
21use crate::registry::Registry;
22use std::path::{Path, PathBuf};
23
24fn largest_with_size(projects_dir: &Path, slug: &str) -> Option<(u64, String)> {
27 let dir = projects_dir.join(slug);
28 let mut best: Option<(u64, String)> = None;
29 for entry in std::fs::read_dir(&dir).ok()?.flatten() {
30 let p = entry.path();
31 if p.extension().map_or(false, |e| e == "jsonl") {
32 if let (Ok(meta), Some(stem)) = (entry.metadata(), p.file_stem()) {
33 let stem = stem.to_string_lossy().to_string();
34 if best.as_ref().map_or(true, |(sz, _)| meta.len() > *sz) {
35 best = Some((meta.len(), stem));
36 }
37 }
38 }
39 }
40 best
41}
42
43pub fn largest_session_id(projects_dirs: &[PathBuf], slug: &str) -> Option<String> {
50 let mut best: Option<(u64, String)> = None;
51 for d in projects_dirs {
52 if let Some((sz, stem)) = largest_with_size(d, slug) {
53 if best.as_ref().map_or(true, |(b, _)| sz > *b) {
54 best = Some((sz, stem));
55 }
56 }
57 }
58 best.map(|(_, stem)| stem)
59}
60
61pub fn find_largest_session_for_working_dir(config_dir: &str, working_dir: &str) -> Option<String> {
92 if config_dir.is_empty() {
93 return None;
94 }
95 let expanded = crate::backend::base::expand_home_dir_safe(config_dir);
96 let projects_dir = expanded.join("projects");
97 let expanded_working_dir = crate::backend::blockcontroller::core::expand_home_dir(working_dir);
98 let slug = project_dir_name(&expanded_working_dir);
99 largest_session_id(&[projects_dir], &slug)
100}
101
102pub fn session_file_path(config_dir: &str, working_dir: &str, sid: &str) -> Option<PathBuf> {
112 if config_dir.is_empty() || sid.is_empty() {
113 return None;
114 }
115 let expanded = crate::backend::base::expand_home_dir_safe(config_dir);
116 let expanded_working_dir = crate::backend::blockcontroller::core::expand_home_dir(working_dir);
117 let slug = project_dir_name(&expanded_working_dir);
118 Some(
119 expanded
120 .join("projects")
121 .join(slug)
122 .join(format!("{sid}.jsonl")),
123 )
124}
125
126pub fn session_is_reachable(config_dir: &str, working_dir: &str, sid: &str) -> bool {
131 session_file_path(config_dir, working_dir, sid)
132 .map(|p| p.is_file())
133 .unwrap_or(false)
134}
135
136pub fn backfill_session_ids(reg: &Registry, shared_dir: &Path) -> usize {
141 let records = match reg.list_active() {
142 Ok(r) => r,
143 Err(_) => return 0,
144 };
145 let default_projects = shared_dir
146 .join("providers")
147 .join("claude")
148 .join("projects");
149 let mut count = 0;
150 for mut rec in records {
151 if rec.data.session_id.as_deref().map_or(false, |s| !s.is_empty()) {
152 continue; }
154 let Some(base) = rec.data.source_agents_base.as_deref() else {
155 continue;
156 };
157 let base = base.trim_end_matches(['/', '\\']);
158 let workspace = format!("{base}/{}", rec.data.working_dir);
159 let slug = project_dir_name(&workspace);
160 let mut dirs = vec![default_projects.clone()];
165 if let Some(id) = rec.data.identity_id.as_deref() {
166 if !id.is_empty() && id != "default" {
167 dirs.push(
168 shared_dir
169 .join("identities")
170 .join(id)
171 .join("claude")
172 .join("projects"),
173 );
174 }
175 }
176 let Some(sid) = largest_session_id(&dirs, &slug) else {
177 continue;
178 };
179 rec.data.session_id = Some(sid.clone());
180 if reg.upsert(&rec).is_ok() {
181 count += 1;
182 tracing::info!(
183 instance = %rec.data.instance_name,
184 session_id = %sid,
185 "registry: backfilled session_id for cross-channel resume"
186 );
187 }
188 }
189 count
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::registry::{NamedAgentRecord, NamedAgentRecordV1};
196 use std::fs;
197
198 #[test]
204 fn session_is_reachable_in_the_folder_the_cli_names_for_an_underscore_and_a_space() {
205 let tmp = tempfile::tempdir().unwrap();
206 let dir = tmp.path().join("projects").join("-home-u-my-project-dir");
207 fs::create_dir_all(&dir).unwrap();
208 fs::write(dir.join("sid-1.jsonl"), b"{}").unwrap();
209
210 assert!(session_is_reachable(
211 &tmp.path().to_string_lossy(),
212 "/home/u/my_project dir",
213 "sid-1",
214 ));
215 }
216
217 #[test]
218 fn largest_session_beats_a_fresh_short_one() {
219 let tmp = tempfile::tempdir().unwrap();
220 let projects = tmp.path().to_path_buf();
221 let slug = "C--Users-x--agentmux-agents-naki";
222 let dir = projects.join(slug);
223 fs::create_dir_all(&dir).unwrap();
224 fs::write(dir.join("91f26930-long.jsonl"), vec![b'x'; 6_000_000]).unwrap();
226 fs::write(dir.join("e96ed91b-short.jsonl"), vec![b'x'; 15_000]).unwrap();
228 assert_eq!(
230 largest_session_id(&[projects.clone()], slug).as_deref(),
231 Some("91f26930-long")
232 );
233 assert_eq!(largest_session_id(&[projects], "nope"), None);
235 }
236
237 #[test]
238 fn find_largest_session_for_working_dir_locates_the_real_transcript() {
239 let tmp = tempfile::tempdir().unwrap();
240 let config_dir = tmp.path();
241 let working_dir = r"C:\Users\user\.agentmux\agents\agentx-0623n";
242 let slug = project_dir_name(working_dir);
243 let dir = config_dir.join("projects").join(&slug);
244 fs::create_dir_all(&dir).unwrap();
245 fs::write(dir.join("972a6a4f-live.jsonl"), vec![b'x'; 2_800_000]).unwrap();
246
247 assert_eq!(
248 find_largest_session_for_working_dir(&config_dir.to_string_lossy(), working_dir).as_deref(),
249 Some("972a6a4f-live")
250 );
251 }
252
253 #[test]
254 fn find_largest_session_for_working_dir_is_none_for_an_empty_config_dir() {
255 assert_eq!(
258 find_largest_session_for_working_dir("", r"C:\Users\user\.agentmux\agents\agentx-0623n"),
259 None
260 );
261 }
262
263 #[test]
264 fn find_largest_session_for_working_dir_is_none_when_nothing_exists_there() {
265 let tmp = tempfile::tempdir().unwrap();
266 assert_eq!(
267 find_largest_session_for_working_dir(
268 &tmp.path().to_string_lossy(),
269 r"C:\Users\user\.agentmux\agents\nobody-here",
270 ),
271 None
272 );
273 }
274
275 #[test]
283 fn find_largest_session_for_working_dir_expands_a_tilde_config_dir() {
284 let home = dirs::home_dir().expect("test requires a resolvable home dir");
285 let rel = format!(".agentmux-test-tilde-expansion-{}", std::process::id());
286 let config_dir_abs = home.join(&rel);
287 let working_dir = r"C:\Users\test\.agentmux\agents\tilde-test";
288 let slug = project_dir_name(working_dir);
289 let dir = config_dir_abs.join("projects").join(&slug);
290 fs::create_dir_all(&dir).unwrap();
291 fs::write(dir.join("abc123-session.jsonl"), b"{}").unwrap();
292
293 let result = find_largest_session_for_working_dir(&format!("~/{rel}"), working_dir);
294
295 fs::remove_dir_all(&config_dir_abs).ok();
296
297 assert_eq!(
298 result.as_deref(),
299 Some("abc123-session"),
300 "a ~-prefixed config_dir must resolve to the real home directory, not be searched literally"
301 );
302 }
303
304 #[test]
315 fn find_largest_session_for_working_dir_expands_a_tilde_working_dir() {
316 let home = dirs::home_dir().expect("test requires a resolvable home dir");
317 let config_rel = format!(".agentmux-test-tilde-workdir-config-{}", std::process::id());
318 let config_dir_abs = home.join(&config_rel);
319 let working_dir_rel = format!(".agentmux-test-tilde-workdir-agent-{}", std::process::id());
321 let working_dir_tilde = format!("~/{working_dir_rel}");
322 let expanded_working_dir = home.join(&working_dir_rel);
323
324 let slug = project_dir_name(&expanded_working_dir.to_string_lossy());
325 let dir = config_dir_abs.join("projects").join(&slug);
326 fs::create_dir_all(&dir).unwrap();
327 fs::write(dir.join("def456-session.jsonl"), b"{}").unwrap();
328
329 let result = find_largest_session_for_working_dir(&config_dir_abs.to_string_lossy(), &working_dir_tilde);
330
331 fs::remove_dir_all(&config_dir_abs).ok();
332
333 assert_eq!(
334 result.as_deref(),
335 Some("def456-session"),
336 "a ~-prefixed working_dir (agent_open.rs's actual default cwd shape) must resolve to \
337 the real home directory, not be encoded literally with the tilde"
338 );
339 }
340
341 fn rec(id: &str, base: Option<&str>, wd: &str, sid: Option<&str>) -> NamedAgentRecord {
342 NamedAgentRecord {
343 schema_version: 3,
344 data: NamedAgentRecordV1 {
345 instance_id: id.to_string(),
346 instance_name: id.to_string(),
347 definition_id: "claude-code".to_string(),
348 identity_id: Some("default".to_string()),
349 memory_id: None,
350 session_id: sid.map(String::from),
351 working_dir: wd.to_string(),
352 source_agents_base: base.map(String::from),
353 created_at_ms: 1,
354 last_launched_at_ms: 1,
355 created_by_version: "(legacy)".to_string(),
356 last_launched_by_version: "(legacy)".to_string(),
357 },
358 }
359 }
360
361 #[test]
362 fn backfill_fills_empties_picks_largest_and_is_idempotent() {
363 let tmp = tempfile::tempdir().unwrap();
364 let shared = tmp.path();
365 let projects = shared.join("providers").join("claude").join("projects");
366 let base = r"C:\agents";
367 let slug = project_dir_name(&format!("{base}/naki-0612a"));
369 let dir = projects.join(&slug);
370 fs::create_dir_all(&dir).unwrap();
371 fs::write(dir.join("LONG.jsonl"), vec![b'x'; 1_000_000]).unwrap();
372 fs::write(dir.join("short.jsonl"), vec![b'x'; 1_000]).unwrap();
373
374 let reg = Registry::open(shared.join("registry")).unwrap();
375 reg.upsert(&rec("naki", Some(base), "naki-0612a", None)).unwrap();
376 reg.upsert(&rec("keep", Some(base), "keep-x", Some("EXISTING"))).unwrap();
378 reg.upsert(&rec("notx", Some(base), "ghost-x", None)).unwrap();
380
381 let n = backfill_session_ids(®, shared);
382 assert_eq!(n, 1, "only the one empty record with a transcript is filled");
383
384 let by = |id: &str| {
385 reg.list_active()
386 .unwrap()
387 .into_iter()
388 .find(|r| r.data.instance_id == id)
389 .unwrap()
390 };
391 assert_eq!(by("naki").data.session_id.as_deref(), Some("LONG"), "largest session");
392 assert_eq!(by("keep").data.session_id.as_deref(), Some("EXISTING"), "untouched");
393 assert_eq!(by("notx").data.session_id, None, "no transcript → left null");
394
395 assert_eq!(backfill_session_ids(®, shared), 0);
397 }
398
399 #[test]
400 fn backfill_resolves_account_home_sessions() {
401 let tmp = tempfile::tempdir().unwrap();
404 let shared = tmp.path();
405 let base = r"C:\agents";
406 let slug = project_dir_name(&format!("{base}/bound-0612a"));
407 let idir = shared
408 .join("identities")
409 .join("bundle1")
410 .join("claude")
411 .join("projects")
412 .join(&slug);
413 fs::create_dir_all(&idir).unwrap();
414 fs::write(idir.join("BOUND.jsonl"), vec![b'x'; 500_000]).unwrap();
415
416 let reg = Registry::open(shared.join("registry")).unwrap();
417 let mut r = rec("bound", Some(base), "bound-0612a", None);
418 r.data.identity_id = Some("bundle1".to_string());
419 reg.upsert(&r).unwrap();
420
421 assert_eq!(backfill_session_ids(®, shared), 1);
422 let got = reg
423 .list_active()
424 .unwrap()
425 .into_iter()
426 .find(|x| x.data.instance_id == "bound")
427 .unwrap();
428 assert_eq!(
429 got.data.session_id.as_deref(),
430 Some("BOUND"),
431 "identity-bound session resolved from the bundle dir"
432 );
433 }
434}