1use std::collections::{HashMap, HashSet};
19use std::io::{Read, Seek, SeekFrom, Write};
20use std::path::PathBuf;
21
22use serde_json::{json, Value};
23use supercode_interchange::sidecar::ms_to_rfc3339;
24
25use crate::mail_route::{LiveSession, LiveSessions};
26use crate::mailbox::MailAddress;
27use crate::HarnessHomes;
28
29const LAUNCHER_PROGRAMS: &[&str] = &[
32 "codex",
33 "claude",
34 "node",
35 "bun",
36 "deno",
37 "sh",
38 "bash",
39 "zsh",
40 "fish",
41 "dash",
42 "nohup",
43 "env",
44 "setsid",
45 "timeout",
46 "npx",
47 "npm",
48 "caffeinate",
49];
50
51const HEARTBEAT_MS: i64 = 9 * 60 * 1000;
54const JOURNAL_MAX_BYTES: u64 = 16 * 1024 * 1024;
56
57fn now_ms() -> i64 {
58 std::time::SystemTime::now()
59 .duration_since(std::time::UNIX_EPOCH)
60 .map(|elapsed| elapsed.as_millis() as i64)
61 .unwrap_or_default()
62}
63
64pub fn journal_path() -> PathBuf {
66 crate::teams::teams_home().join("sessions-tree.jsonl")
67}
68
69#[derive(Debug, Clone, Default)]
73struct Launch {
74 key: String,
75 by: Option<String>,
76 at: i64,
78 resumes: Option<String>,
80 kind: Option<String>,
82}
83
84#[derive(Debug, Default)]
87struct Journal {
88 origins: HashMap<String, Launch>,
89 panes: HashMap<String, Launch>,
90}
91
92fn resumed_by_key(key: &str) -> Option<String> {
95 let rest = key.strip_prefix("open:")?;
96 let rest = rest.strip_prefix("native:").unwrap_or(rest);
97 let (_, id) = rest.split_once(':')?;
98 (!id.is_empty()).then(|| id.to_string())
99}
100
101fn read_journal() -> Journal {
102 let mut journal = Journal::default();
103 let Ok(text) = std::fs::read_to_string(crate::teams::teams_home().join("launches.jsonl"))
104 else {
105 return journal;
106 };
107 let mut issued: HashMap<(String, String), i64> = HashMap::new();
110 for line in text.lines() {
111 let Ok(record) = serde_json::from_str::<Value>(line) else {
112 continue;
113 };
114 let (Some(key), Some(t)) = (record["key"].as_str(), record["t"].as_i64()) else {
115 continue;
116 };
117 let pane = record["pane"].as_str().map(str::to_string);
118 let at = *issued
119 .entry((key.to_string(), pane.clone().unwrap_or_default()))
120 .or_insert(t);
121 let resumes = record["resumes"]
122 .as_str()
123 .map(str::to_string)
124 .or_else(|| resumed_by_key(key));
125 let launch = Launch {
126 key: key.to_string(),
127 by: record["by"].as_str().map(str::to_string),
128 at,
129 resumes: resumes.clone(),
130 kind: record["kind"].as_str().map(str::to_string),
131 };
132 if let (Some(native), None) = (record["native_session"].as_str(), &resumes) {
134 journal
135 .origins
136 .entry(native.to_string())
137 .or_insert_with(|| launch.clone());
138 }
139 if let Some(pane) = pane {
140 journal.panes.insert(pane, launch);
141 }
142 }
143 journal
144}
145
146#[derive(Debug, Clone)]
148struct Process {
149 ppid: u32,
150 program: String,
151}
152
153#[cfg(unix)]
154fn processes() -> HashMap<u32, Process> {
155 let Ok(output) = std::process::Command::new("ps")
156 .args(["-axo", "pid=,ppid=,comm="])
157 .output()
158 else {
159 return HashMap::new();
160 };
161 String::from_utf8_lossy(&output.stdout)
162 .lines()
163 .filter_map(|line| {
164 let mut words = line.split_whitespace();
165 let pid = words.next()?.parse().ok()?;
166 let ppid = words.next()?.parse().ok()?;
167 let command = words.collect::<Vec<_>>().join(" ");
168 let program = command
169 .rsplit('/')
170 .next()
171 .unwrap_or_default()
172 .trim_start_matches('-')
173 .to_string();
174 Some((pid, Process { ppid, program }))
175 })
176 .collect()
177}
178
179#[cfg(not(unix))]
180fn processes() -> HashMap<u32, Process> {
181 HashMap::new()
182}
183
184fn ancestry(pid: u32, table: &HashMap<u32, Process>) -> Vec<u32> {
186 let mut chain = vec![pid];
187 let mut current = pid;
188 while let Some(process) = table.get(¤t) {
189 if process.ppid <= 1 || chain.contains(&process.ppid) || chain.len() > 64 {
190 break;
191 }
192 chain.push(process.ppid);
193 current = process.ppid;
194 }
195 chain
196}
197
198fn orphaned(pid: u32, table: &HashMap<u32, Process>) -> bool {
201 let chain = ancestry(pid, table);
202 let top = chain.last().and_then(|top| table.get(top));
203 top.is_some_and(|top| top.ppid == 1)
204 && chain.iter().all(|pid| {
205 table
206 .get(pid)
207 .is_some_and(|process| LAUNCHER_PROGRAMS.contains(&process.program.as_str()))
208 })
209}
210
211#[cfg(target_os = "macos")]
214fn starter_environment(pid: u32) -> HashMap<String, String> {
215 let Ok(output) = std::process::Command::new("ps")
217 .args(["-E", "-o", "command=", "-p", &pid.to_string()])
218 .output()
219 else {
220 return HashMap::new();
221 };
222 let text = String::from_utf8_lossy(&output.stdout);
223 let variables = starter_variables(text.split_whitespace());
224 variables
225}
226
227#[cfg(target_os = "linux")]
228fn starter_environment(pid: u32) -> HashMap<String, String> {
229 let Ok(environ) = std::fs::read(format!("/proc/{pid}/environ")) else {
230 return HashMap::new();
231 };
232 let variables = starter_variables(
233 environ
234 .split(|byte| *byte == 0)
235 .filter_map(|entry| std::str::from_utf8(entry).ok()),
236 );
237 variables
238}
239
240#[cfg(not(any(target_os = "macos", target_os = "linux")))]
241fn starter_environment(_pid: u32) -> HashMap<String, String> {
242 HashMap::new()
243}
244
245#[cfg(any(target_os = "macos", target_os = "linux"))]
246fn starter_variables<'a>(entries: impl Iterator<Item = &'a str>) -> HashMap<String, String> {
247 entries
248 .filter_map(|entry| entry.split_once('='))
249 .filter(|(name, value)| {
250 matches!(
251 *name,
252 "CLAUDE_CODE_SESSION_ID" | "CODEX_THREAD_ID" | "SUPERCODE_TEAMS_PANE"
253 ) && !value.is_empty()
254 })
255 .map(|(name, value)| (name.to_string(), value.to_string()))
256 .collect()
257}
258
259fn is_daemon_pane(name: &str) -> bool {
261 name.strip_prefix("p_").is_some_and(|rest| {
262 !rest.is_empty()
263 && rest
264 .chars()
265 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
266 })
267}
268
269#[derive(Debug, Clone)]
273struct StartedBy {
274 parent: Option<String>,
276 evidence: &'static str,
278 recorded: bool,
280 detail: Option<String>,
281 launch_key: Option<String>,
282 at: Option<i64>,
283}
284
285impl StartedBy {
286 fn none() -> Self {
287 Self {
288 parent: None,
289 evidence: "none",
290 recorded: false,
291 detail: None,
292 launch_key: None,
293 at: None,
294 }
295 }
296
297 fn from_launch(launch: &Launch, detail: &str) -> Self {
298 Self {
299 parent: launch.by.clone(),
300 evidence: "launch",
301 recorded: true,
302 detail: Some(detail.to_string()),
303 launch_key: Some(launch.key.clone()),
304 at: Some(launch.at),
305 }
306 }
307
308 fn json(&self) -> Value {
309 let kind = match (&self.parent, self.evidence) {
310 (Some(_), _) => "session",
311 (None, "launch" | "pane_launch") => "person",
312 _ => "unrecorded",
313 };
314 let mut value = json!({"kind": kind, "evidence": self.evidence, "recorded": self.recorded});
315 if let Some(detail) = &self.detail {
316 value["detail"] = json!(detail);
317 }
318 if let Some(key) = &self.launch_key {
319 value["launch_key"] = json!(key);
320 }
321 if let Some(at) = self.at {
322 value["at"] = json!(ms_to_rfc3339(at));
323 }
324 value
325 }
326}
327
328pub fn live_tree(homes: &HarnessHomes, ask_other_machines: bool) -> Value {
332 let roots_read = std::thread::spawn(crate::mail_route::daemon_pane_roots);
333 let codex_read = std::thread::spawn(crate::codex_peer::session_processes);
334 let live = LiveSessions::read(homes);
335 let pane_roots = roots_read.join().unwrap_or_default();
336 let codex_pids = codex_read.join().unwrap_or_default();
337 let table = processes();
338 let journal = read_journal();
339 let machine = crate::mailbox::local_machine_name();
340
341 let pid_of = |session: &LiveSession| -> Option<u32> {
342 session.pid.or_else(|| {
343 (session.address.harness == "codex")
344 .then(|| codex_pids.get(&session.address.session_id).copied())
345 .flatten()
346 })
347 };
348 let root_panes: HashMap<u32, &String> = pane_roots
349 .iter()
350 .map(|(pane, root)| (*root, pane))
351 .collect();
352 let pane_of = |session: &LiveSession| -> Option<String> {
353 if let Some(pane) = session
354 .tmux
355 .as_deref()
356 .and_then(|tmux| tmux.split(':').next())
357 .filter(|name| is_daemon_pane(name))
358 {
359 return Some(pane.to_string());
360 }
361 let pid = pid_of(session)?;
362 ancestry(pid, &table)
363 .iter()
364 .find_map(|ancestor| root_panes.get(ancestor).map(|pane| pane.to_string()))
365 };
366
367 let sessions = live.all();
368 let pids: Vec<Option<u32>> = sessions.iter().map(pid_of).collect();
369 let panes: Vec<Option<String>> = sessions.iter().map(pane_of).collect();
370 let by_pid: HashMap<u32, String> = sessions
371 .iter()
372 .zip(&pids)
373 .filter_map(|(session, pid)| pid.map(|pid| (pid, session.address.to_string())))
374 .collect();
375 let process_parents: Vec<Option<String>> = pids
377 .iter()
378 .map(|pid| {
379 ancestry((*pid)?, &table)
380 .into_iter()
381 .skip(1)
382 .find_map(|ancestor| by_pid.get(&ancestor).cloned())
383 })
384 .collect();
385 let by_pane: HashMap<&str, String> = sessions
387 .iter()
388 .zip(&panes)
389 .zip(&process_parents)
390 .filter(|(_, parent)| parent.is_none())
391 .filter_map(|((session, pane), _)| {
392 pane.as_deref()
393 .map(|pane| (pane, session.address.to_string()))
394 })
395 .collect();
396 let address_of = |harness: &str, id: &str| {
397 MailAddress::new(&machine, harness, id)
398 .ok()
399 .map(|a| a.to_string())
400 };
401
402 let mut rows: Vec<Value> = Vec::new();
403 for (((session, pid), pane), process_parent) in
404 sessions.iter().zip(&pids).zip(&panes).zip(&process_parents)
405 {
406 let id = session.address.session_id.as_str();
407 let address = session.address.to_string();
408 let pane_launch = pane.as_deref().and_then(|pane| journal.panes.get(pane));
409 let resume = pane_launch.filter(|launch| launch.resumes.as_deref() == Some(id));
410 let started = if let Some(origin) = journal.origins.get(id) {
411 StartedBy::from_launch(origin, "its launch recorded this session")
412 } else if let Some(first) = resume.and_then(|_| first_recorded_start(&address)) {
415 first
416 } else if let Some(parent) = process_parent {
417 StartedBy {
418 parent: Some(parent.clone()),
419 evidence: "process_parent",
420 recorded: false,
421 detail: Some("a parent process of it is that session's".into()),
422 launch_key: None,
423 at: None,
424 }
425 } else if let Some(launch) = pane_launch.filter(|_| resume.is_none()) {
428 let root = pane
432 .as_deref()
433 .and_then(|pane| pane_roots.get(pane))
434 .copied();
435 let launched = pid.is_some_and(|pid| {
436 Some(pid) == root
437 || (table.get(&pid).map(|process| process.ppid) == root
438 && launch.kind.as_deref() == Some(session.address.harness.as_str()))
439 });
440 let mut started = StartedBy::from_launch(launch, "its pane's launch ran it");
441 if !launched {
442 started.evidence = "pane_launch";
443 started.recorded = false;
444 started.detail = Some(
445 "it runs in that launch's pane; the launch ran another program there".into(),
446 );
447 }
448 started
449 } else if let Some(started) = pid.filter(|pid| orphaned(*pid, &table)).and_then(|pid| {
450 environment_starter(
451 pid,
452 &session.address,
453 pane.as_deref(),
454 &by_pane,
455 &by_pid,
456 &address_of,
457 )
458 }) {
459 started
460 } else {
461 StartedBy::none()
462 };
463 let mut row = json!({
464 "kind": "session",
465 "address": address,
466 "harness": session.address.harness,
467 "session_id": id,
468 "name": session.name.rsplit_once('@').map_or(session.name.as_str(), |(name, _)| name),
469 "folder": session.cwd,
470 "state": session.status,
471 "pane": pane,
472 "pid": pid,
473 "parent": started.parent,
474 "started_by": started.json(),
475 "resumed_by": Value::Null,
476 });
477 if let Some(resume) = resume {
478 row["resumed_by"] =
479 json!({"by": resume.by, "launch_key": resume.key, "at": ms_to_rfc3339(resume.at)});
480 }
481 rows.push(row);
482 }
483 for thread in live.codex_threads() {
484 let short: String = thread.address.session_id.chars().take(8).collect();
485 rows.push(json!({
486 "kind": "codex_thread",
487 "address": thread.address.to_string(),
488 "harness": "codex",
489 "session_id": thread.address.session_id,
490 "name": format!("codex-{short}"),
491 "folder": thread.cwd,
492 "state": thread.status,
493 "pane": Value::Null,
494 "pid": Value::Null,
495 "parent": thread.parent.to_string(),
496 "started_by": {"kind": "session", "evidence": "codex_thread", "recorded": true,
497 "detail": "Codex recorded it as a subagent thread of that conversation"},
498 "resumed_by": Value::Null,
499 }));
500 }
501 let tree = tree_of(rows, &machine, ask_other_machines);
502 json!({"machine": machine, "at": ms_to_rfc3339(now_ms()), "sessions": tree.0, "parents_elsewhere": tree.1})
503}
504
505fn environment_starter(
509 pid: u32,
510 own: &MailAddress,
511 own_pane: Option<&str>,
512 by_pane: &HashMap<&str, String>,
513 by_pid: &HashMap<u32, String>,
514 address_of: &dyn Fn(&str, &str) -> Option<String>,
515) -> Option<StartedBy> {
516 let environment = starter_environment(pid);
517 let mut named: Vec<(String, &'static str)> = Vec::new();
518 for (variable, harness) in [
519 ("CLAUDE_CODE_SESSION_ID", "claude-code"),
520 ("CODEX_THREAD_ID", "codex"),
521 ] {
522 if let Some(id) = environment
523 .get(variable)
524 .filter(|id| **id != own.session_id)
525 {
526 if let Some(address) = address_of(harness, id) {
527 named.push((address, variable));
528 }
529 }
530 }
531 if let Some(address) = environment
532 .get("SUPERCODE_TEAMS_PANE")
533 .filter(|pane| Some(pane.as_str()) != own_pane)
534 .and_then(|pane| by_pane.get(pane.as_str()))
535 .filter(|address| **address != own.to_string())
536 {
537 if !named.iter().any(|(named, _)| named == address) {
538 named.push((address.clone(), "SUPERCODE_TEAMS_PANE"));
539 }
540 }
541 let pid_of: HashMap<&String, u32> = by_pid
542 .iter()
543 .map(|(pid, address)| (address, *pid))
544 .collect();
545 let nearest = if named.len() > 1 {
546 named.iter().find(|(address, _)| {
547 pid_of.get(address).is_some_and(|pid| {
548 let theirs = starter_environment(*pid);
549 let theirs: HashSet<String> = theirs
550 .iter()
551 .filter_map(|(variable, id)| match variable.as_str() {
552 "CLAUDE_CODE_SESSION_ID" => address_of("claude-code", id),
553 "CODEX_THREAD_ID" => address_of("codex", id),
554 _ => None,
555 })
556 .collect();
557 named
558 .iter()
559 .filter(|(other, _)| other != address)
560 .all(|(other, _)| theirs.contains(other))
561 })
562 })
563 } else {
564 named.first()
565 };
566 let (parent, variable) = nearest?.clone();
567 Some(StartedBy {
568 parent: Some(parent),
569 evidence: "process_environment",
570 recorded: false,
571 detail: Some(format!(
572 "its parent process has exited; its environment's {variable} names that session"
573 )),
574 launch_key: None,
575 at: None,
576 })
577}
578
579fn tree_of(
582 mut rows: Vec<Value>,
583 machine: &str,
584 ask_other_machines: bool,
585) -> (Vec<Value>, Vec<Value>) {
586 let index: HashMap<String, usize> = rows
587 .iter()
588 .enumerate()
589 .filter_map(|(i, row)| Some((row["address"].as_str()?.to_string(), i)))
590 .collect();
591 for i in 0..rows.len() {
592 let mut seen = HashSet::from([i]);
594 let mut current = rows[i]["parent"]
595 .as_str()
596 .and_then(|parent| index.get(parent))
597 .copied();
598 while let Some(next) = current {
599 if next == i {
600 rows[i]["parent"] = Value::Null;
601 rows[i]["started_by"]["cycle"] = json!(true);
602 break;
603 }
604 if !seen.insert(next) {
605 break;
606 }
607 current = rows[next]["parent"]
608 .as_str()
609 .and_then(|parent| index.get(parent))
610 .copied();
611 }
612 }
613 let mut children: HashMap<String, Vec<String>> = HashMap::new();
614 let mut elsewhere: Vec<String> = Vec::new();
615 for row in &rows {
616 if let Some(parent) = row["parent"].as_str() {
617 children
618 .entry(parent.to_string())
619 .or_default()
620 .push(row["address"].as_str().unwrap_or_default().to_string());
621 if !index.contains_key(parent) && !elsewhere.iter().any(|known| known == parent) {
622 elsewhere.push(parent.to_string());
623 }
624 }
625 }
626 for row in &mut rows {
627 let address = row["address"].as_str().unwrap_or_default().to_string();
628 row["children"] = json!(children.remove(&address).unwrap_or_default());
629 }
630 let machines: HashSet<String> = elsewhere
633 .iter()
634 .filter_map(|address| MailAddress::parse(address).ok())
635 .map(|address| address.machine)
636 .filter(|other| other != machine && ask_other_machines)
637 .collect();
638 let asking: Vec<_> = machines
639 .into_iter()
640 .map(|other| {
641 std::thread::spawn(move || {
642 let listed = crate::mailbox::teams_mail(&other, &json!({"op": "list"}))
643 .ok()
644 .and_then(|answer| answer["sessions"].as_array().cloned());
645 (other, listed)
646 })
647 })
648 .collect();
649 let asked: Vec<(String, Option<Vec<Value>>)> = asking
650 .into_iter()
651 .filter_map(|handle| handle.join().ok())
652 .collect();
653 let elsewhere = elsewhere
654 .into_iter()
655 .map(|address| {
656 let parsed = MailAddress::parse(&address).ok();
657 let listing = parsed.as_ref().and_then(|parsed| {
658 asked
659 .iter()
660 .find(|(other, _)| *other == parsed.machine)
661 .and_then(|(_, listed)| listed.as_ref())
662 });
663 let row =
664 listing.and_then(|rows| rows.iter().find(|row| row["address"] == address.as_str()));
665 let name = row
666 .and_then(|row| row["name"].as_str())
667 .map(|name| {
668 name.rsplit_once('@')
669 .map_or(name, |(name, _)| name)
670 .to_string()
671 })
672 .or_else(|| parsed.as_ref().and_then(crate::mail_route::remembered_name));
673 let here = parsed.as_ref().is_some_and(|a| a.machine == machine);
674 json!({
675 "address": address,
676 "machine": parsed.as_ref().map(|a| a.machine.clone()),
677 "name": name,
678 "here": here,
680 "running": if here { Some(false) } else { listing.map(|_| row.is_some()) },
683 "state": row.and_then(|row| row["status"].as_str()),
684 "children": children.get(&address).cloned().unwrap_or_default(),
685 })
686 })
687 .collect();
688 (rows, elsewhere)
689}
690
691fn first_recorded_start(address: &str) -> Option<StartedBy> {
696 let path = journal_path();
697 for file in [path.with_extension("jsonl.1"), path] {
698 let Ok(text) = std::fs::read_to_string(&file) else {
699 continue;
700 };
701 for line in text.lines().filter(|line| line.contains(address)) {
702 let Ok(record) = serde_json::from_str::<Value>(line) else {
703 continue;
704 };
705 let found = record["sessions"]
706 .as_array()
707 .into_iter()
708 .flatten()
709 .find(|row| {
710 row["address"] == address
711 && row["started_by"]["recorded"] == true
712 && row["started_by"]["evidence"] == "launch"
713 && row["resumed_by"].is_null()
714 });
715 if let Some(row) = found {
716 let started = &row["started_by"];
717 return Some(StartedBy {
718 parent: row["parent"].as_str().map(str::to_string),
719 evidence: "launch",
720 recorded: true,
721 detail: Some(format!(
722 "its first launch, as the session tree recorded it at {}",
723 record["t"].as_i64().map(ms_to_rfc3339).unwrap_or_default()
724 )),
725 launch_key: started["launch_key"].as_str().map(str::to_string),
726 at: started["at"]
727 .as_str()
728 .and_then(supercode_interchange::sidecar::rfc3339_to_ms),
729 });
730 }
731 }
732 }
733 None
734}
735
736fn shape(tree: &Value) -> Vec<(String, String, String)> {
739 let mut shape: Vec<(String, String, String)> = tree["sessions"]
740 .as_array()
741 .into_iter()
742 .flatten()
743 .map(|row| {
744 let text = |key: &str| row[key].as_str().unwrap_or_default().to_string();
745 (text("address"), text("parent"), text("pane"))
746 })
747 .collect();
748 shape.sort();
749 shape
750}
751
752fn journal_tail() -> (Option<Value>, Option<i64>) {
754 let Ok(mut file) = std::fs::File::open(journal_path()) else {
755 return (None, None);
756 };
757 let length = file.metadata().map(|meta| meta.len()).unwrap_or_default();
758 let start = length.saturating_sub(4 * 1024 * 1024);
759 if file.seek(SeekFrom::Start(start)).is_err() {
760 return (None, None);
761 }
762 let mut text = String::new();
763 if file.read_to_string(&mut text).is_err() {
764 return (None, None);
765 }
766 let mut last_at = None;
767 for line in text.lines().rev() {
768 let Ok(record) = serde_json::from_str::<Value>(line) else {
769 continue;
770 };
771 last_at = last_at.or_else(|| record["t"].as_i64());
772 if record.get("sessions").is_some() {
773 return (Some(record), last_at);
774 }
775 }
776 (None, last_at)
777}
778
779pub fn record(tree: &Value) -> std::io::Result<&'static str> {
782 let path = journal_path();
783 if let Some(parent) = path.parent() {
784 std::fs::create_dir_all(parent)?;
785 }
786 if std::fs::metadata(&path).is_ok_and(|meta| meta.len() > JOURNAL_MAX_BYTES) {
787 std::fs::rename(&path, path.with_extension("jsonl.1"))?;
788 }
789 let now = now_ms();
790 let (last, last_at) = journal_tail();
791 let line = match &last {
792 Some(last) if shape(last) == shape(tree) => {
793 if last_at.is_some_and(|at| now - at < HEARTBEAT_MS) {
794 return Ok("nothing");
795 }
796 json!({"t": now, "unchanged_since": last["t"]})
797 }
798 _ => json!({"t": now, "machine": tree["machine"], "sessions": tree["sessions"],
799 "parents_elsewhere": tree["parents_elsewhere"]}),
800 };
801 let written = if line.get("sessions").is_some() {
802 "tree"
803 } else {
804 "heartbeat"
805 };
806 let mut file = std::fs::OpenOptions::new()
807 .create(true)
808 .append(true)
809 .open(&path)?;
810 file.write_all(format!("{line}\n").as_bytes())?;
811 Ok(written)
812}
813
814pub fn recorded_tree(at_ms: i64) -> Option<Value> {
818 let path = journal_path();
819 let mut text = std::fs::read_to_string(path.with_extension("jsonl.1")).unwrap_or_default();
820 text.push_str(&std::fs::read_to_string(&path).unwrap_or_default());
821 let mut found: Option<Value> = None;
822 let mut confirmed: Option<i64> = None;
823 let mut next_change: Option<i64> = None;
824 for line in text.lines() {
825 let Ok(record) = serde_json::from_str::<Value>(line) else {
826 continue;
827 };
828 let Some(t) = record["t"].as_i64() else {
829 continue;
830 };
831 let full = record.get("sessions").is_some();
832 if t <= at_ms {
833 if full {
834 found = Some(record);
835 }
836 confirmed = Some(t);
837 } else if full {
838 next_change = Some(t);
839 break;
840 } else if found.is_some() && next_change.is_none() {
841 confirmed = Some(t);
843 }
844 }
845 let mut tree = found?;
846 let recorded_at = tree["t"].as_i64().unwrap_or_default();
847 tree["at"] = json!(ms_to_rfc3339(at_ms));
848 tree["recorded_at"] = json!(ms_to_rfc3339(recorded_at));
849 tree["confirmed_through"] = json!(confirmed.map(ms_to_rfc3339));
850 tree["next_change_at"] = json!(next_change.map(ms_to_rfc3339));
851 if let Some(object) = tree.as_object_mut() {
852 object.remove("t");
853 }
854 Some(tree)
855}