@@ -135,6 +135,208 @@ class DiffResult:
135135 summary : dict [str , int ]
136136
137137
138+ @dataclass
139+ class MergeStats :
140+ """Outcome of a union merge of remote + local JSONL records."""
141+
142+ remote_total : int = 0
143+ local_total : int = 0
144+ merged_total : int = 0
145+ added : int = 0 # local-only records added
146+ updated : int = 0 # records present in both where the local copy won (superset)
147+ carried_forward : int = 0 # remote-only records preserved (re-redacted)
148+ unchanged : int = 0 # records present in both where the remote copy won (re-redacted)
149+ malformed_preserved : int = 0 # unparseable lines carried through verbatim
150+
151+ def changelog_line (self ) -> str :
152+ line = (
153+ f"Merge: added { self .added } , updated { self .updated } , "
154+ f"carried_forward { self .carried_forward } , unchanged { self .unchanged } "
155+ f"(remote { self .remote_total } -> merged { self .merged_total } )"
156+ )
157+ if self .malformed_preserved :
158+ line += f"; preserved { self .malformed_preserved } unparseable line(s) verbatim"
159+ return line
160+
161+
162+ def merge_identity_key (obj : dict [str , Any ]) -> tuple [Any , ...]:
163+ """Dedup key for the union merge.
164+
165+ Uses ``(source, session_id)`` when ``session_id`` is truthy, otherwise falls
166+ back to the full ``identity_key()`` tuple. Keying on ``session_id`` avoids the
167+ ``start_time`` format drift (H1) and anonymized ``project`` drift (H2) bugs that
168+ would otherwise let one session appear under two keys (duplicate + leak).
169+ """
170+ session_id = obj .get ("session_id" )
171+ if session_id :
172+ return ("sid" , obj .get ("source" ), session_id )
173+ return ("identity" , * identity_key (obj ))
174+
175+
176+ def _message_count (obj : dict [str , Any ]) -> int :
177+ messages = obj .get ("messages" )
178+ return len (messages ) if isinstance (messages , list ) else 0
179+
180+
181+ def _end_time (obj : dict [str , Any ]) -> str :
182+ end_time = obj .get ("end_time" )
183+ return end_time if isinstance (end_time , str ) else ""
184+
185+
186+ def _record_prefers (candidate : dict [str , Any ], current : dict [str , Any ]) -> bool :
187+ """Return True if ``candidate`` should replace ``current`` in the union.
188+
189+ Tie-break order: more messages, then larger canonical byte size, then later
190+ ``end_time``. A tie on all three keeps ``current`` (caller decides which side
191+ that is so "unchanged vs updated" classification stays meaningful).
192+ """
193+ cand_messages = _message_count (candidate )
194+ cur_messages = _message_count (current )
195+ if cand_messages != cur_messages :
196+ return cand_messages > cur_messages
197+
198+ cand_bytes = len (canonical_record_bytes (candidate ))
199+ cur_bytes = len (canonical_record_bytes (current ))
200+ if cand_bytes != cur_bytes :
201+ return cand_bytes > cur_bytes
202+
203+ return _end_time (candidate ) > _end_time (current )
204+
205+
206+ def _load_raw_records (path : Path ) -> tuple [list [dict [str , Any ]], list [bytes ]]:
207+ """Load JSONL records RAW (no diff normalization, preserves ``originalFile``).
208+
209+ Returns ``(records, malformed_lines)``. A line that is not valid JSON (or not a
210+ JSON object) is returned verbatim in ``malformed_lines`` rather than raising or
211+ being dropped: the merge preserves it so a single corrupt remote line can never
212+ silently lose data nor permanently wedge all future pushes.
213+ """
214+ records : list [dict [str , Any ]] = []
215+ malformed : list [bytes ] = []
216+ with path .open ("rb" ) as handle :
217+ for line in handle :
218+ stripped = line .strip ()
219+ if not stripped :
220+ continue
221+ try :
222+ obj = orjson .loads (stripped )
223+ except ValueError : # orjson.JSONDecodeError subclasses ValueError
224+ malformed .append (stripped )
225+ continue
226+ if isinstance (obj , dict ):
227+ records .append (obj )
228+ else :
229+ malformed .append (stripped )
230+ return records , malformed
231+
232+
233+ def merge_jsonl_union (
234+ remote_path : Path ,
235+ local_path : Path ,
236+ output_path : Path ,
237+ * ,
238+ redact_fn : Callable [[dict [str , Any ]], dict [str , Any ]],
239+ ) -> MergeStats :
240+ """Union-merge remote + local JSONL into ``output_path``.
241+
242+ Rules (keyed by :func:`merge_identity_key`):
243+ - remote-only -> carry forward, re-redacted through ``redact_fn``
244+ - local-only -> add (already current-redacted upstream, passes through)
245+ - both -> keep the superset (see :func:`_record_prefers`)
246+
247+ Local records are written first when they win; carried-forward remote records
248+ are always re-redacted via ``redact_fn`` before being written, which closes the
249+ old-policy redaction-republish hole. The merge preserves first-seen ordering
250+ (remote order, then any local-only additions) and guarantees
251+ ``merged_total >= remote_total``.
252+
253+ ``redact_fn`` is injected so this module stays free of import cycles with the
254+ redaction pipeline.
255+ """
256+ remote_records , remote_malformed = _load_raw_records (remote_path )
257+ local_records , local_malformed = _load_raw_records (local_path )
258+ # Preserve unparseable lines verbatim (deduped by exact bytes) so a corrupt
259+ # remote line is never dropped (data loss) nor allowed to abort the push (wedge).
260+ malformed = list (dict .fromkeys (remote_malformed + local_malformed ))
261+
262+ # Totals are counted by UNIQUE merge key, not raw line count. A remote file
263+ # written by the old non-deduping uploader can contain duplicate
264+ # (source, session_id) lines; counting raw lines would make merged_total <
265+ # remote_total trip the union-invariant guard and permanently block publishing
266+ # even though no session was dropped. (Set precisely after the build below.)
267+ stats = MergeStats ()
268+
269+ # Build winning record per key, tracking origin (which side won) and which sides
270+ # the key appeared on, so we can classify the change and re-redact correctly.
271+ order : list [tuple [Any , ...]] = []
272+ winners : dict [tuple [Any , ...], dict [str , Any ]] = {}
273+ origin : dict [tuple [Any , ...], str ] = {} # "remote" or "local" (winning side)
274+ in_remote : set [tuple [Any , ...]] = set ()
275+ in_local : set [tuple [Any , ...]] = set ()
276+
277+ for record in remote_records :
278+ key = merge_identity_key (record )
279+ in_remote .add (key )
280+ if key not in winners :
281+ order .append (key )
282+ winners [key ] = record
283+ origin [key ] = "remote"
284+ elif _record_prefers (record , winners [key ]):
285+ winners [key ] = record
286+ origin [key ] = "remote"
287+
288+ for record in local_records :
289+ key = merge_identity_key (record )
290+ in_local .add (key )
291+ if key not in winners :
292+ order .append (key )
293+ winners [key ] = record
294+ origin [key ] = "local"
295+ elif _record_prefers (record , winners [key ]):
296+ winners [key ] = record
297+ origin [key ] = "local"
298+
299+ output_path .parent .mkdir (parents = True , exist_ok = True )
300+ merged_total = 0
301+ with output_path .open ("wb" ) as handle :
302+ for key in order :
303+ record = winners [key ]
304+ seen_remote = key in in_remote
305+ seen_local = key in in_local
306+ if origin [key ] == "remote" :
307+ # Winning side is a remote record: re-redact through the CURRENT
308+ # pipeline before publishing (closes the old-policy redaction hole).
309+ record = redact_fn (record )
310+ if seen_local :
311+ stats .unchanged += 1 # in both, remote copy won
312+ else :
313+ stats .carried_forward += 1 # remote-only
314+ else :
315+ # Winning side is a local record (already current-redacted upstream).
316+ if seen_remote :
317+ stats .updated += 1 # in both, local copy won (superset)
318+ else :
319+ stats .added += 1 # local-only
320+ handle .write (canonical_record_bytes (record ))
321+ handle .write (b"\n " )
322+ merged_total += 1
323+
324+ for raw in malformed :
325+ handle .write (raw )
326+ handle .write (b"\n " )
327+ merged_total += 1
328+
329+ # Malformed remote lines count toward remote_total (by UNIQUE bytes, matching
330+ # the unique-key counting above) so the union invariant merged_total >=
331+ # remote_total accounts for the records they represent without false-tripping
332+ # on duplicate corrupt lines.
333+ stats .remote_total = len (in_remote ) + len (set (remote_malformed ))
334+ stats .local_total = len (in_local ) + len (set (local_malformed ))
335+ stats .merged_total = merged_total
336+ stats .malformed_preserved = len (malformed )
337+ return stats
338+
339+
138340def clean_strings (obj : Any ) -> Any :
139341 if isinstance (obj , str ):
140342 text = ANSI_RE .sub ("" , obj )
0 commit comments