11use std:: {
22 env, fs,
3+ io:: { BufRead as _, BufReader } ,
34 path:: { Path , PathBuf } ,
45 time:: UNIX_EPOCH ,
56} ;
67
78use jiff:: tz:: TimeZone as JiffTimeZone ;
89use memchr:: memmem;
10+ use serde:: Deserialize ;
911
1012use crate :: {
11- MILLIS_PER_DAY , Result , cli:: SharedArgs , cli_error, date_range_bounds_ms, fast:: FxHashSet ,
12- home, parse_tz, path_utils:: expand_home_path,
13+ MILLIS_PER_DAY , Result ,
14+ cli:: SharedArgs ,
15+ cli_error, date_range_bounds_ms,
16+ fast:: { FxHashMap , FxHashSet } ,
17+ home, parse_tz,
18+ path_utils:: expand_home_path,
1319} ;
1420use crate :: { TimestampMs , parse_ts_timestamp} ;
1521use ccusage_adapter_common:: collect_usage_files;
@@ -90,17 +96,19 @@ pub(super) struct SinceFiles {
9096/// window.
9197///
9298/// Claude session JSONL files are append-only, so a file last written before
93- /// the window opened cannot hold an entry inside it. Files are kept or dropped
94- /// per session, the main transcript together with its subagent transcripts
95- /// and any copy in another config root, because deduplication matches
96- /// sidechain replays against their parent messages within a session; dropping
97- /// the parent file alone would let a replay count twice. Copies of a message
98- /// in other sessions, such as resumed transcripts, keep the original
99- /// timestamp, so they fall on the same side of the window as the original.
100- /// Only the file list is narrowed; entries parsed from a
101- /// surviving file are untouched. Every file is kept when `since` is unset or
102- /// unparsable, when the window opens ahead of the current clock, or when a
103- /// file's mtime cannot be read.
99+ /// the window opened cannot hold an entry inside it. Deduplication matches
100+ /// sidechain replays against their parent messages by session ID, which spans
101+ /// files (subagent and workflow transcripts, legacy flat `agent-*.jsonl`
102+ /// files, copies in another config root), so files are kept or dropped per
103+ /// connected group of session IDs: dropping a parent file alone would let its
104+ /// replay count twice. Copies of a message in other sessions, such as resumed
105+ /// transcripts, keep the original timestamp, so they fall on the same side of
106+ /// the window as the original.
107+ ///
108+ /// Only the file list is narrowed; entries parsed from a surviving file are
109+ /// untouched. Every file is kept when `since` is unset or unparsable, when the
110+ /// window opens ahead of the current clock, or when a file's mtime cannot be
111+ /// read.
104112///
105113/// @param files Discovered usage files.
106114/// @param shared Report arguments providing `since` and `timezone`.
@@ -127,60 +135,102 @@ pub(super) fn split_files_before_since(
127135 return keep_all ( files) ;
128136 }
129137 let threshold = since_ms. saturating_sub ( MTIME_PRUNE_MARGIN_MS ) ;
130- let live_sessions = files
131- . iter ( )
132- . filter ( |file| file_modified_millis ( file) . is_none_or ( |modified| modified >= threshold) )
133- . map ( |file| session_group_key ( file) )
134- . collect :: < FxHashSet < String > > ( ) ;
135- let ( kept, pruned) = files
136- . into_iter ( )
137- . partition ( |file| live_sessions. contains ( & session_group_key ( file) ) ) ;
138- SinceFiles { kept, pruned }
139- }
140138
141- /// Identifies the session a usage file belongs to, relative to `projects/`
142- /// so a session copied into another config root shares one key.
143- ///
144- /// `<session>.jsonl` and everything under `<session>/` (such as `subagents/`)
145- /// share a key. Legacy flat `agent-*.jsonl` sidechain transcripts sit beside
146- /// their parent, so they key on the `sessionId` recorded inside them. Paths
147- /// outside the `projects/<project>/<session>` layout key on themselves.
148- fn session_group_key ( path : & Path ) -> String {
149- let components = path
150- . components ( )
151- . map ( |component| component. as_os_str ( ) . to_string_lossy ( ) )
139+ // Union every file's session IDs so files sharing any ID share a group.
140+ let mut sessions = SessionGroups :: default ( ) ;
141+ let file_groups = files
142+ . iter ( )
143+ . map ( |file| {
144+ let ( path_session, _) = extract_session_parts ( file) ;
145+ let group = sessions. id ( & path_session) ;
146+ if let Some ( recorded) = recorded_session_id ( file) {
147+ let recorded = sessions. id ( & recorded) ;
148+ sessions. union ( group, recorded) ;
149+ }
150+ group
151+ } )
152152 . collect :: < Vec < _ > > ( ) ;
153- let relative = components
153+ let live_groups = files
154154 . iter ( )
155- . position ( |component| component == "projects" )
156- . map ( |index| & components[ index + 1 ..] ) ;
157- let Some ( [ project, session, rest @ ..] ) = relative else {
158- return path. to_string_lossy ( ) . into_owned ( ) ;
155+ . zip ( & file_groups)
156+ . filter ( |( file, _) | file_modified_millis ( file) . is_none_or ( |modified| modified >= threshold) )
157+ . map ( |( _, & group) | sessions. root ( group) )
158+ . collect :: < FxHashSet < _ > > ( ) ;
159+
160+ let mut split = SinceFiles {
161+ kept : Vec :: new ( ) ,
162+ pruned : Vec :: new ( ) ,
159163 } ;
160- let session = session . strip_suffix ( ".jsonl" ) . unwrap_or ( session ) ;
161- if rest . is_empty ( )
162- && session . starts_with ( "agent-" )
163- && let Some ( parent_session ) = recorded_session_id ( path )
164- {
165- return format ! ( "{project}/{parent_session}" ) ;
164+ for ( file , group ) in files . into_iter ( ) . zip ( file_groups ) {
165+ if live_groups . contains ( & sessions . root ( group ) ) {
166+ split . kept . push ( file ) ;
167+ } else {
168+ split . pruned . push ( file ) ;
169+ }
166170 }
167- format ! ( "{project}/{session}" )
171+ split
168172}
169173
170- /// Reads the first `sessionId` recorded in a transcript without parsing it.
174+ /// Union-find over session IDs.
175+ #[ derive( Default ) ]
176+ struct SessionGroups {
177+ ids : FxHashMap < String , usize > ,
178+ parents : Vec < usize > ,
179+ }
180+
181+ impl SessionGroups {
182+ fn id ( & mut self , session : & str ) -> usize {
183+ if let Some ( & id) = self . ids . get ( session) {
184+ return id;
185+ }
186+ let id = self . parents . len ( ) ;
187+ self . parents . push ( id) ;
188+ self . ids . insert ( session. to_string ( ) , id) ;
189+ id
190+ }
191+
192+ fn root ( & mut self , mut id : usize ) -> usize {
193+ while self . parents [ id] != id {
194+ // Path halving: point each visited node at its grandparent.
195+ self . parents [ id] = self . parents [ self . parents [ id] ] ;
196+ id = self . parents [ id] ;
197+ }
198+ id
199+ }
200+
201+ fn union ( & mut self , left : usize , right : usize ) {
202+ let ( left, right) = ( self . root ( left) , self . root ( right) ) ;
203+ self . parents [ left] = right;
204+ }
205+ }
206+
207+ /// Returns the first `sessionId` a transcript records, which is the session
208+ /// the loaders attribute its entries to. Transcripts record a single session,
209+ /// so reading stops at the first line that names one.
171210fn recorded_session_id ( path : & Path ) -> Option < String > {
172- use std:: io:: Read as _;
211+ #[ derive( Deserialize ) ]
212+ struct SessionIdProbe {
213+ #[ serde( rename = "sessionId" ) ]
214+ session_id : Option < String > ,
215+ }
173216
174- const MARKER : & [ u8 ] = br#""sessionId":""# ;
175- let mut head = Vec :: new ( ) ;
176- fs:: File :: open ( path)
177- . ok ( ) ?
178- . take ( 64 * 1024 )
179- . read_to_end ( & mut head)
180- . ok ( ) ?;
181- let start = memmem:: find ( & head, MARKER ) ? + MARKER . len ( ) ;
182- let end = memchr:: memchr ( b'"' , & head[ start..] ) ? + start;
183- String :: from_utf8 ( head[ start..end] . to_vec ( ) ) . ok ( )
217+ let mut reader = BufReader :: new ( fs:: File :: open ( path) . ok ( ) ?) ;
218+ let mut line = Vec :: new ( ) ;
219+ loop {
220+ line. clear ( ) ;
221+ if reader. read_until ( b'\n' , & mut line) . ok ( ) ? == 0 {
222+ return None ;
223+ }
224+ if memmem:: find ( & line, b"\" sessionId\" " ) . is_none ( ) {
225+ continue ;
226+ }
227+ if let Ok ( SessionIdProbe {
228+ session_id : Some ( session_id) ,
229+ } ) = serde_json:: from_slice ( & line)
230+ {
231+ return Some ( session_id) ;
232+ }
233+ }
184234}
185235
186236fn file_modified_millis ( path : & Path ) -> Option < i64 > {
@@ -463,4 +513,43 @@ mod tests {
463513 assert_eq ! ( split. kept, [ live_agent, live, other_root_copy] ) ;
464514 assert_eq ! ( split. pruned, [ stale_agent] ) ;
465515 }
516+
517+ #[ test]
518+ fn keeps_sessions_linked_by_a_recorded_session_id_in_another_directory ( ) {
519+ let fixture = fs_fixture ! ( {
520+ "projects/p/session-y.jsonl" : r#"{"sessionId":"session-y"}"# ,
521+ "projects/p/session-x/subagents/workflows/wf_1/agent-a.jsonl" : "{\" type\" :\" summary\" }\n {\" sessionId\" : \" session-y\" }" ,
522+ } ) ;
523+ let root = fixture. root ( ) ;
524+ let parent = root. join ( "projects/p/session-y.jsonl" ) ;
525+ let workflow_agent =
526+ root. join ( "projects/p/session-x/subagents/workflows/wf_1/agent-a.jsonl" ) ;
527+ set_file_modified ( & parent, ts ( "2026-09-01T12:00:00Z" ) ) ;
528+ set_file_modified ( & workflow_agent, ts ( "2026-09-21T12:00:00Z" ) ) ;
529+
530+ let split = split_files_before_since (
531+ vec ! [ parent. clone( ) , workflow_agent. clone( ) ] ,
532+ & shared_since ( Some ( "20260920" ) ) ,
533+ ts ( "2026-09-24T00:00:00Z" ) ,
534+ ) ;
535+
536+ assert_eq ! ( split. kept, [ parent, workflow_agent] ) ;
537+ assert ! ( split. pruned. is_empty( ) ) ;
538+ }
539+
540+ #[ test]
541+ fn reads_the_recorded_session_id_past_long_leading_lines ( ) {
542+ let padding = format ! (
543+ r#"{{"type":"summary","text":"{}"}}"# ,
544+ "x" . repeat( 100 * 1024 )
545+ ) ;
546+ let fixture = fs_fixture ! ( {
547+ "projects/p/agent-1.jsonl" : format!( "{padding}\n {{\" sessionId\" : \" parent\" }}" ) ,
548+ } ) ;
549+
550+ assert_eq ! (
551+ super :: recorded_session_id( & fixture. root( ) . join( "projects/p/agent-1.jsonl" ) ) . as_deref( ) ,
552+ Some ( "parent" )
553+ ) ;
554+ }
466555}
0 commit comments