cctally 1.92.1 → 1.92.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,13 +10,31 @@ import pathlib
10
10
  import signal
11
11
  import sqlite3
12
12
  import sys
13
+ import typing
13
14
 
14
15
  import _cctally_core
15
16
  import _cctally_journal as _journal
17
+ import _lib_accounts
16
18
  import _lib_journal
19
+ import _lib_journal_router
17
20
  from _lib_json_envelope import stamp_schema_version
18
21
 
19
22
 
23
+ class _PrefixSnapshot(typing.NamedTuple):
24
+ """One pinned prefix, read exactly once (#496 S5 §4).
25
+
26
+ `audit_ends` maps each `journal_protocol_resolution` op id to the end
27
+ coordinate of its line. The already-resolved recovery branch needs those
28
+ coordinates and used to re-read the whole prefix through `_audit_high_water`
29
+ to find them, even though this pass had them in hand.
30
+ """
31
+
32
+ high_water: "tuple[str, int] | None"
33
+ prefix_hash: "str | None"
34
+ selection: object
35
+ audit_ends: dict
36
+
37
+
20
38
  def _read_only_high_water() -> "tuple[str, int] | None":
21
39
  """Capture the append-only prefix without creating a lock or sidecar."""
22
40
  segments = _journal.list_segments()
@@ -27,37 +45,99 @@ def _read_only_high_water() -> "tuple[str, int] | None":
27
45
 
28
46
 
29
47
  def _read_prefix(high_water):
48
+ """Stream the pinned prefix once, producing everything derived from it.
49
+
50
+ Returns `(records, evidence, prefix_hash, audit_ends)`. The prefix hash
51
+ comes from a `PrefixHashAccumulator` fed by the same bytes this pass reads,
52
+ the protocol evidence is captured from that accumulator rather than by
53
+ re-reading the prefix per resolution op, and the cutover account is captured
54
+ inline exactly as `rebuild_stats_index` captures it. Before this, those were
55
+ four separate whole-prefix traversals on top of this one (#496 S5 §4).
56
+
57
+ Every record stays decoded. Unlike the rebuild, the selector here feeds an
58
+ acknowledgement the repair command may then mint, and unlike the rebuild's
59
+ filtered retention there is no placeholder scheme to keep the `enumerate`
60
+ numbering identical — so the list is unfiltered, exactly as before.
61
+ """
30
62
  if high_water is None:
31
- return [], ()
63
+ return [], (), None, {}
64
+ segments = _journal.list_segments()
65
+ if high_water[0] not in segments:
66
+ raise OSError(
67
+ f"journal high-water segment is unavailable: {high_water[0]}"
68
+ )
32
69
  records = []
33
70
  evidence = []
71
+ audit_ends: dict = {}
34
72
  malformed = 0
35
73
  prior_high_water = None
36
- for segment, offset, raw in _journal._read_range(None, high_water):
74
+ cutover_captured = _journal._CUTOVER_UNSEEN
75
+ hasher = _lib_journal_router.PrefixHashAccumulator()
76
+ for segment, offset, raw in _journal._iter_range_with_segments(
77
+ None,
78
+ high_water,
79
+ segments,
80
+ on_segment=lambda name: hasher.begin_segment(name, prior_high_water),
81
+ on_bytes=hasher.extend,
82
+ ):
37
83
  record = _lib_journal.decode_line(raw)
38
84
  if record is None:
39
85
  malformed += 1
40
86
  prior_high_water = (segment, offset + len(raw) + 1)
41
87
  continue
42
- _journal._capture_protocol_prefix_evidence(
43
- record,
44
- prior_high_water,
45
- evidence,
46
- )
88
+ record_end = (segment, offset + len(raw) + 1)
89
+ if record.get("t") == "op":
90
+ _journal._capture_protocol_prefix_evidence(
91
+ record,
92
+ prior_high_water,
93
+ evidence,
94
+ hasher=hasher,
95
+ )
96
+ payload = record.get("payload")
97
+ if (
98
+ isinstance(payload, dict)
99
+ and payload.get("kind")
100
+ == _lib_journal._PROTOCOL_RESOLUTION_KIND
101
+ ):
102
+ audit_ends[record.get("id")] = record_end
103
+ # First cutover op wins, exactly as `find_accounts_cutover_op` scans.
104
+ if (
105
+ cutover_captured is _journal._CUTOVER_UNSEEN
106
+ and record.get("id") == _journal.CUTOVER_OP_ID
107
+ ):
108
+ cutover_captured = _journal._cutover_value_of(record)
47
109
  records.append(record)
48
- prior_high_water = (segment, offset + len(raw) + 1)
110
+ prior_high_water = record_end
111
+ prefix_hash = hasher.digest_at(high_water)
112
+ # The accumulator buffers the segment it is reading — 410 MB on the
113
+ # maintainer's journal — so it is dropped the moment its pass ends, before
114
+ # the normalization loop below, exactly as `rebuild_stats_index` drops it.
115
+ # It is dropped before the raise too, so a malformed prefix does not pin the
116
+ # buffer on the traceback while the exception unwinds.
117
+ hasher = None
49
118
  if malformed:
50
119
  raise _lib_journal.JournalProtocolError(
51
120
  f"journal prefix contains {malformed} malformed line(s)"
52
121
  )
53
- cutover_claude = _journal.resolve_cutover_claude_account()
122
+ # No suffix fallback, deliberately. `_read_only_high_water` pins the
123
+ # canonically-last segment at its full size, so this prefix IS the whole
124
+ # journal and an op the prefix does not contain is not in the journal at
125
+ # all — which is exactly what `resolve_cutover_claude_account` used to
126
+ # answer by re-reading every segment. An op appended in the window between
127
+ # that pin and this loop is therefore NOT seen, where the whole-journal scan
128
+ # would have found it; that divergence is accepted, because the account then
129
+ # matches the prefix the fingerprints are computed over and `_apply`'s
130
+ # conflict check catches a preview that has gone stale. The rebuild's
131
+ # `_resolve_cutover_for_rebuild` cannot be reused here: its fallback calls
132
+ # `journal_high_water`, which takes the leaf lock and so CREATES
133
+ # `journal.lock`, and the preview must leave no sidecar behind.
134
+ if cutover_captured is _journal._CUTOVER_UNSEEN or cutover_captured is None:
135
+ cutover_claude = _lib_accounts.UNATTRIBUTED
136
+ else:
137
+ cutover_claude = cutover_captured
54
138
  for record in records:
55
139
  _journal._normalize_legacy_account_stamp(record, cutover_claude)
56
- return records, tuple(evidence)
57
-
58
-
59
- def _prefix_hash(high_water) -> "str | None":
60
- return _journal.journal_prefix_hash(high_water)
140
+ return records, tuple(evidence), prefix_hash, audit_ends
61
141
 
62
142
 
63
143
  def _high_water_dict(high_water):
@@ -66,18 +146,21 @@ def _high_water_dict(high_water):
66
146
  return {"segment": high_water[0], "offset": high_water[1]}
67
147
 
68
148
 
69
- def _selection_snapshot():
149
+ def _selection_snapshot() -> _PrefixSnapshot:
70
150
  high_water = _read_only_high_water()
71
- records, evidence = _read_prefix(high_water)
151
+ records, evidence, prefix_hash, audit_ends = _read_prefix(high_water)
72
152
  selection = _lib_journal.resolve_effective_events(
73
153
  records,
74
154
  protocol_prefix_evidence=evidence,
75
155
  )
76
- return high_water, _prefix_hash(high_water), selection
156
+ return _PrefixSnapshot(high_water, prefix_hash, selection, audit_ends)
77
157
 
78
158
 
79
159
  def _preview_payload(requested=()):
80
- high_water, prefix_hash, selection = _selection_snapshot()
160
+ snapshot = _selection_snapshot()
161
+ high_water = snapshot.high_water
162
+ prefix_hash = snapshot.prefix_hash
163
+ selection = snapshot.selection
81
164
  unacknowledged = {
82
165
  violation.fingerprint: violation
83
166
  for violation in selection.protocol_violations
@@ -115,7 +198,7 @@ def _preview_payload(requested=()):
115
198
  "rebuild": None,
116
199
  "errors": errors,
117
200
  }
118
- return stamp_schema_version(body, version=1), selection
201
+ return stamp_schema_version(body, version=1), snapshot
119
202
 
120
203
 
121
204
  def _rebuild_dict(result):
@@ -230,7 +313,7 @@ def _repair_failure_guidance(exc: Exception) -> str:
230
313
 
231
314
  def _post_audit_failure(requested, audit_ids, exc):
232
315
  """Report durable acknowledgement truth when index publication declined."""
233
- payload, _selection = _preview_payload(requested)
316
+ payload, _snapshot = _preview_payload(requested)
234
317
  payload["status"] = "failed"
235
318
  payload["errors"] = [_repair_failure_guidance(exc)]
236
319
  if len(audit_ids) == 1:
@@ -261,12 +344,19 @@ def _stats_has_acknowledgements(fingerprints) -> bool:
261
344
  return False
262
345
 
263
346
 
264
- def _audit_high_water(audit_ids, high_water):
265
- found = {}
266
- for segment, offset, raw in _journal._read_range(None, high_water):
267
- record = _lib_journal.decode_line(raw)
268
- if record is not None and record.get("id") in audit_ids:
269
- found[record["id"]] = (segment, offset + len(raw) + 1)
347
+ def _recovery_high_water(audit_ids, audit_ends):
348
+ """The last acknowledged audit record's end coordinate.
349
+
350
+ `audit_ends` is produced by the same streaming pass that produced the
351
+ selection, so an already-resolved recovery no longer re-reads the whole
352
+ prefix to find coordinates that pass already held (#496 S5 §4.1). The
353
+ ordering rule is the pre-change one: canonical segment order, then offset.
354
+ """
355
+ found = {
356
+ audit_id: audit_ends[audit_id]
357
+ for audit_id in audit_ids
358
+ if audit_id in audit_ends
359
+ }
270
360
  if set(found) != set(audit_ids):
271
361
  raise _journal.JournalError(
272
362
  "acknowledged protocol audit record is missing from the journal"
@@ -279,7 +369,8 @@ def _audit_high_water(audit_ids, high_water):
279
369
  def _apply(requested, initial_preview):
280
370
  _call_pause_hook("before-lock")
281
371
  with _repair_locks():
282
- preview, selection = _preview_payload(requested)
372
+ preview, snapshot = _preview_payload(requested)
373
+ selection = snapshot.selection
283
374
  if preview["errors"]:
284
375
  preview["status"] = "conflict"
285
376
  return preview, 2
@@ -347,7 +438,7 @@ def _apply(requested, initial_preview):
347
438
  {audit["id"]},
348
439
  exc,
349
440
  )
350
- final_payload, _final_selection = _preview_payload(requested)
441
+ final_payload, _final_snapshot = _preview_payload(requested)
351
442
  final_payload["status"] = "applied"
352
443
  final_payload["selectedViolations"] = [
353
444
  violation.to_dict() for violation in to_acknowledge
@@ -374,8 +465,8 @@ def _apply(requested, initial_preview):
374
465
  if len(audit_ids) == 1:
375
466
  preview["auditId"] = next(iter(audit_ids))
376
467
  if not _stats_has_acknowledgements(requested):
377
- recovery_high_water = _audit_high_water(
378
- audit_ids, reviewed_high_water
468
+ recovery_high_water = _recovery_high_water(
469
+ audit_ids, snapshot.audit_ends
379
470
  )
380
471
  try:
381
472
  _call_rebuild_error_hook()
@@ -395,7 +486,7 @@ def _apply(requested, initial_preview):
395
486
  audit_ids,
396
487
  exc,
397
488
  )
398
- final_payload, _final_selection = _preview_payload(requested)
489
+ final_payload, _final_snapshot = _preview_payload(requested)
399
490
  final_payload["status"] = "recovered"
400
491
  if len(audit_ids) == 1:
401
492
  final_payload["auditId"] = next(iter(audit_ids))
@@ -408,7 +499,7 @@ def cmd_db_journal_repair(args) -> int:
408
499
  """Preview structural violations without mutating the journal or indexes."""
409
500
  try:
410
501
  requested = list(getattr(args, "violation", ()) or ())
411
- payload, _selection = _preview_payload(requested)
502
+ payload, _snapshot = _preview_payload(requested)
412
503
  except (OSError, _lib_journal.JournalProtocolError) as exc:
413
504
  if bool(getattr(args, "json", False)):
414
505
  try:
@@ -27,6 +27,7 @@ import _cctally_core
27
27
  import _cctally_journal as _journal
28
28
  import _cctally_record as _record
29
29
  import _lib_journal
30
+ import _lib_journal_router
30
31
  import _lib_json_envelope
31
32
  import _lib_rederive
32
33
 
@@ -441,40 +442,74 @@ def owned_conflicted_event_ids(selection) -> frozenset:
441
442
 
442
443
  def read_rederive_journal_prefix(
443
444
  high_water: "tuple[str, int] | None" = None,
444
- ) -> tuple[list[dict], "tuple[str, int] | None", list[tuple[str, int]]]:
445
- """Read and strictly decode one canonical journal prefix."""
445
+ ):
446
+ """Stream and strictly decode one canonical journal prefix.
447
+
448
+ Returns `(records, high_water, record_ends, protocol_prefix_evidence)`.
449
+ The evidence digests come from a `PrefixHashAccumulator` fed by the bytes
450
+ this pass is already reading. Before this, the prefix was materialized as
451
+ raw lines, materialized again as decoded records while the first form was
452
+ still referenced, walked a third time to produce the evidence, and re-read
453
+ from byte zero by `journal_prefix_hash` once per
454
+ `journal_protocol_resolution` op (#496 S5 §4).
455
+
456
+ Retention is DELIBERATELY unfiltered, unlike the rebuild's. The rebuild
457
+ keeps only the decision records and substitutes `None` placeholders for
458
+ everything else; the planner here reads every observation for cache
459
+ validation, desired-event derivation and preservation decisions, and walks
460
+ `records` in parallel with `record_ends`. Both lists stay complete and
461
+ aligned, so the win in this file is the removed double materialization and
462
+ the removed hash traversals, not reduced retention.
463
+ """
446
464
  if high_water is None:
447
465
  high_water = _journal.journal_high_water()
448
466
  if high_water is None:
449
- return [], None, []
467
+ return [], None, [], ()
468
+ segments = _journal.list_segments()
469
+ if high_water[0] not in segments:
470
+ raise OSError(
471
+ f"journal high-water segment is unavailable: {high_water[0]}"
472
+ )
450
473
  records: list[dict] = []
451
474
  record_ends: list[tuple[str, int]] = []
475
+ evidence: list = []
452
476
  malformed = 0
453
- for segment, offset, raw in _journal._read_range(None, high_water):
477
+ prior_high_water = None
478
+ hasher = _lib_journal_router.PrefixHashAccumulator()
479
+ for segment, offset, raw in _journal._iter_range_with_segments(
480
+ None,
481
+ high_water,
482
+ segments,
483
+ on_segment=lambda name: hasher.begin_segment(name, prior_high_water),
484
+ on_bytes=hasher.extend,
485
+ ):
454
486
  record = _lib_journal.decode_line(raw)
487
+ record_end = (segment, offset + len(raw) + 1)
455
488
  if record is None:
456
489
  malformed += 1
490
+ prior_high_water = record_end
457
491
  continue
492
+ if record.get("t") == "op":
493
+ _journal._capture_protocol_prefix_evidence(
494
+ record,
495
+ prior_high_water,
496
+ evidence,
497
+ hasher=hasher,
498
+ )
458
499
  records.append(record)
459
- record_ends.append((segment, offset + len(raw) + 1))
500
+ record_ends.append(record_end)
501
+ prior_high_water = record_end
502
+ # Released before the raise below, so a malformed prefix does not pin the
503
+ # accumulator's buffered segment — 410 MB on the maintainer's journal — on
504
+ # the traceback while the exception unwinds. On the success path the frame
505
+ # dies two statements later, so this mirrors the repair reader, where the
506
+ # release genuinely precedes a loop over every decoded record.
507
+ hasher = None
460
508
  if malformed:
461
509
  raise _lib_rederive.RederiveConflict(
462
510
  f"journal prefix contains {malformed} malformed line(s)"
463
511
  )
464
- return records, high_water, record_ends
465
-
466
-
467
- def _protocol_prefix_evidence(records, record_ends):
468
- evidence = []
469
- prior_high_water = None
470
- for record, record_end in zip(records, record_ends):
471
- _journal._capture_protocol_prefix_evidence(
472
- record,
473
- prior_high_water,
474
- evidence,
475
- )
476
- prior_high_water = record_end
477
- return tuple(evidence)
512
+ return records, high_water, record_ends, tuple(evidence)
478
513
 
479
514
 
480
515
  def _read_only_journal_high_water() -> "tuple[str, int] | None":
@@ -603,12 +638,11 @@ def _preview_from_snapshot(
603
638
  if journal_high_water is None:
604
639
  journal_high_water = _read_only_journal_high_water()
605
640
  if journal_high_water is None:
606
- records, high_water, record_ends = [], None, []
641
+ records, high_water, record_ends, protocol_evidence = [], None, [], ()
607
642
  else:
608
- records, high_water, record_ends = read_rederive_journal_prefix(
609
- journal_high_water
643
+ records, high_water, record_ends, protocol_evidence = (
644
+ read_rederive_journal_prefix(journal_high_water)
610
645
  )
611
- protocol_evidence = _protocol_prefix_evidence(records, record_ends)
612
646
  with _open_cache_read_view() as cache:
613
647
  plan = plan_claude_usage(
614
648
  records,
@@ -242,6 +242,46 @@ def bootstrap_id(table: str, rowid: int) -> str:
242
242
  return f"b:{table}:{rowid}"
243
243
 
244
244
 
245
+ def reusable_bootstrap_name(candidate_digest, candidate_size, existing):
246
+ """The already-published bootstrap segment a cutover may reuse verbatim.
247
+
248
+ `existing` is `(name, byte_length_or_None, sha256_hex_or_None)` for EVERY
249
+ published segment the caller found. A `None` digest means the caller did not
250
+ read that segment, which it does only when the length already differs; a
251
+ `None` length means it could not stat the file at all. Neither can match, and
252
+ reporting the segment anyway is required — see the ordering rule below.
253
+
254
+ Reuse requires the CANONICALLY NEWEST bootstrap to be the exact match, on
255
+ both length and digest. A crash-after-rename retry re-exports byte-identical
256
+ lines, so reusing that orphan makes the retry idempotent on disk instead of
257
+ only idempotent on fold (#496 S5 §3), and timestamps increase monotonically,
258
+ so the orphan an immediately-prior attempt left IS the newest bootstrap.
259
+
260
+ Reusing an older match instead would stamp the cursor behind a bootstrap the
261
+ cursor does not cover, and the next ingest would fold that stale bootstrap's
262
+ records into stats.db. Writing a fresh segment is the pre-reuse behaviour and
263
+ restores the pre-reuse invariant, because a minted name always sorts last.
264
+
265
+ Returns None when the newest bootstrap does not match, which covers the
266
+ ordinary first-cutover path, the genuinely-differing-export path, and the
267
+ stale-match path alike.
268
+ """
269
+ bootstraps = [
270
+ (name, size, digest)
271
+ for name, size, digest in existing
272
+ if name.startswith(BOOTSTRAP_PREFIX)
273
+ ]
274
+ if not bootstraps:
275
+ return None
276
+ name, size, digest = max(
277
+ bootstraps, key=lambda entry: segment_sort_key(entry[0]))
278
+ if size is None or digest is None:
279
+ return None
280
+ if size == candidate_size and digest == candidate_digest:
281
+ return name
282
+ return None
283
+
284
+
245
285
  def evt_id(kind: str, *parts: object) -> str:
246
286
  """Natural-key id for an evt line: ``"<kind>:" + ":".join(str(p) …)``.
247
287
 
@@ -0,0 +1,234 @@
1
+ """Pure record-routing kernel for the stats rebuild's journal read pass (#496 S4).
2
+
3
+ The rebuild used to materialize the whole journal twice: `_read_range` built a
4
+ list of every raw line, and the decode loop built a second list of every parsed
5
+ record while the first was still referenced. On the maintainer's install that is
6
+ 1,954,007 lines and 1.72 GB producing an 8.08 GiB peak, of which the stats fold
7
+ consumes 99,289 records (5.08%).
8
+
9
+ `bin/_cctally_doctor.py`'s conflict scan already established the shape this
10
+ kernel generalizes: retain only what the effective selector consumes and drop
11
+ everything else as it is decoded (#374 review, measured at 4.3 GB of peak RSS
12
+ for an identical result).
13
+
14
+ No I/O and no imports from `_cctally_journal`, so the rules here are unit
15
+ testable without a journal on disk.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+
21
+
22
+ #: The record types the stats rebuild retains decoded. `resolve_effective_events`
23
+ #: acts only on evt / correction / correction_batch and the protocol-resolution
24
+ #: op; the op-fold stream takes only ops whose kind is in `FOLD_APPLIERS`.
25
+ #: Deliberately identical to `_cctally_doctor._CONFLICT_SCAN_RECORD_TYPES` — the
26
+ #: two must not drift, because both feed the same shared selector.
27
+ RETAINED_RECORD_TYPES = frozenset({"evt", "correction", "correction_batch", "op"})
28
+
29
+
30
+ class LastSeenAccumulator:
31
+ """Reproduce `_derive_account_last_seen`'s contribution set from a stream.
32
+
33
+ The rebuild normalizes every record and then takes `_account_of`, which
34
+ reads a top-level ``account`` or an ``account_observe`` op's
35
+ ``payload.account_key``. `_normalize_legacy_account_stamp` writes a
36
+ top-level ``account`` ONLY for ``t == "obs"``; a legacy evt or op instead
37
+ gets ``payload.account_key``, which `_account_of` does not read.
38
+
39
+ So exactly three classes contribute, and a provider-wide maximum over every
40
+ legacy line would over-count — advancing `last_seen_utc` from legacy events
41
+ and vendor-tagged budget events that contribute nothing today. The Claude
42
+ legacy bucket is deferred because the cutover account is not known until the
43
+ stream reaches it, at 92.9% of a production journal.
44
+ """
45
+
46
+ __slots__ = ("stamped", "legacy_claude_at", "legacy_codex_at")
47
+
48
+ def __init__(self) -> None:
49
+ self.stamped: dict = {}
50
+ self.legacy_claude_at = None
51
+ self.legacy_codex_at = None
52
+
53
+ def observe(self, record, provider_of_legacy) -> None:
54
+ """Fold one record. `provider_of_legacy` is `classify_legacy_provider`."""
55
+ at = record.get("at")
56
+ if not at:
57
+ return
58
+ account = record.get("account")
59
+ if isinstance(account, str) and account:
60
+ self._bump(account, at)
61
+ return
62
+ record_type = record.get("t")
63
+ if record_type == "op":
64
+ payload = record.get("payload") or {}
65
+ if payload.get("kind") == "account_observe":
66
+ key = payload.get("account_key")
67
+ if isinstance(key, str) and key:
68
+ self._bump(key, at)
69
+ return
70
+ if record_type != "obs":
71
+ # A legacy evt normalizes into `payload.account_key`, which
72
+ # `_account_of` ignores. Contributing here would move last-seen.
73
+ return
74
+ provider = provider_of_legacy(record)
75
+ if provider == "claude":
76
+ if self.legacy_claude_at is None or at > self.legacy_claude_at:
77
+ self.legacy_claude_at = at
78
+ elif provider == "codex":
79
+ if self.legacy_codex_at is None or at > self.legacy_codex_at:
80
+ self.legacy_codex_at = at
81
+
82
+ def _bump(self, key: str, at: str) -> None:
83
+ previous = self.stamped.get(key)
84
+ if previous is None or at > previous:
85
+ self.stamped[key] = at
86
+
87
+ def resolve(self, cutover_claude: str, unattributed: str) -> dict:
88
+ """Apply the deferred legacy buckets and return the final MAX map."""
89
+ out = dict(self.stamped)
90
+ if self.legacy_claude_at is not None:
91
+ previous = out.get(cutover_claude)
92
+ if previous is None or self.legacy_claude_at > previous:
93
+ out[cutover_claude] = self.legacy_claude_at
94
+ if self.legacy_codex_at is not None:
95
+ previous = out.get(unattributed)
96
+ if previous is None or self.legacy_codex_at > previous:
97
+ out[unattributed] = self.legacy_codex_at
98
+ return out
99
+
100
+
101
+ class PrefixEvidenceUnavailable(LookupError):
102
+ """A protocol-evidence digest was asked for a prefix the stream dropped.
103
+
104
+ Never expected: an evidence point is always the end of the line immediately
105
+ preceding a `journal_protocol_resolution` op, so it is either inside the
106
+ segment being streamed or the boundary registered at the last segment
107
+ transition. Raised rather than silently degraded, because the alternative is
108
+ a rebuild that quietly disagrees with `journal_prefix_hash`.
109
+ """
110
+
111
+
112
+ class PrefixHashAccumulator:
113
+ """`journal_prefix_hash` computed from the bytes the rebuild already read.
114
+
115
+ `journal_prefix_hash(prior_high_water)` does `path.read_bytes()[:size]` on
116
+ every segment through the prefix, so one `journal_protocol_resolution` op
117
+ re-reads the whole journal up to its own position and builds a full-segment
118
+ bytes transient. This accumulator reproduces the identical durable digest
119
+ from the bytes the single streaming pass is reading anyway (#496 S4 §5.2).
120
+
121
+ The framing is `journal_prefix_hash`'s, verbatim: per segment, the 4-byte
122
+ big-endian name length, the name, the 8-byte big-endian data length, then
123
+ the data. Completed segments are absorbed into a running `sha256`; only the
124
+ segment currently being streamed is buffered, so residency is bounded by one
125
+ segment. At a segment transition the caller passes the boundary the next
126
+ record's `prior_high_water` will name, which is the ONE offset in the
127
+ outgoing segment that can still be asked for; its digest is precomputed
128
+ before the buffer is released.
129
+ """
130
+
131
+ # A transition can only ever register one boundary, and consecutive empty
132
+ # segments re-register the same one. A handful of slots is therefore already
133
+ # generous; the cap exists so a pathological journal cannot grow this map.
134
+ # Eviction is safe because a registered boundary is only ever READ
135
+ # IMMEDIATELY AFTER the `begin_segment` that registered it: a resolution op
136
+ # can name a previous segment's end only when it is the first line of the
137
+ # new segment, and every later position falls in the `_current_name` branch
138
+ # of `digest_at`. So the cap bounds a map that never needs more than the
139
+ # most recent entry; widening it buys nothing and narrowing it below the
140
+ # runs of empty segments a journal can contain would start dropping the one
141
+ # entry that is still live.
142
+ _MAX_BOUNDARIES = 16
143
+
144
+ def __init__(self) -> None:
145
+ self._running = hashlib.sha256()
146
+ self._current_name = None
147
+ self._current = bytearray()
148
+ self._boundaries: dict = {}
149
+ self._boundary_order: list = []
150
+ #: Bytes fed into an evidence digest, reported as the `protocol_evidence`
151
+ #: traversal pass. Zero on any journal with no resolution op.
152
+ self.bytes_hashed = 0
153
+ self.digests_computed = 0
154
+
155
+ # -- feeding ----------------------------------------------------------
156
+
157
+ def begin_segment(self, name: str, boundary=None) -> None:
158
+ """Start `name`, absorbing whatever segment was open before it.
159
+
160
+ `boundary` is the `(segment, offset)` the next record's evidence would
161
+ name — i.e. the streaming loop's current `prior_high_water`. It is
162
+ resolved and cached HERE because the outgoing segment's bytes are gone
163
+ immediately afterwards.
164
+ """
165
+ if boundary is not None:
166
+ self._register_boundary(boundary)
167
+ if self._current_name is not None:
168
+ # `hashlib.update` accepts the buffer directly, so the outgoing
169
+ # segment is framed WITHOUT a copy of itself. The maintainer's
170
+ # largest segment is 410 MB, and copying it here briefly doubled
171
+ # that at every transition.
172
+ with memoryview(self._current) as data:
173
+ self._absorb(self._current_name, data)
174
+ self._current_name = name
175
+ self._current = bytearray()
176
+
177
+ def extend(self, data: bytes) -> None:
178
+ """Absorb raw bytes read from the segment currently open."""
179
+ self._current.extend(data)
180
+
181
+ # -- reading ----------------------------------------------------------
182
+
183
+ def digest_at(self, high_water) -> "str | None":
184
+ """`journal_prefix_hash(high_water)`, from the streamed bytes."""
185
+ if high_water is None:
186
+ return None
187
+ segment, offset = high_water
188
+ if segment == self._current_name:
189
+ digest = self._running.copy()
190
+ with memoryview(self._current)[:offset] as data:
191
+ self._frame(digest, segment, data)
192
+ self.bytes_hashed += len(data)
193
+ self.digests_computed += 1
194
+ return "sha256:" + digest.hexdigest()
195
+ cached = self._boundaries.get((segment, offset))
196
+ if cached is None:
197
+ raise PrefixEvidenceUnavailable(
198
+ f"no streamed prefix evidence for {segment}@{offset}"
199
+ )
200
+ self.bytes_hashed += cached[1]
201
+ self.digests_computed += 1
202
+ return cached[0]
203
+
204
+ # -- internals --------------------------------------------------------
205
+
206
+ def _register_boundary(self, boundary) -> None:
207
+ segment, offset = boundary
208
+ if segment != self._current_name:
209
+ # Already registered at an earlier transition (a run of empty
210
+ # segments leaves `prior_high_water` unchanged), or never streamed.
211
+ return
212
+ key = (segment, offset)
213
+ if key in self._boundaries:
214
+ return
215
+ digest = self._running.copy()
216
+ with memoryview(self._current)[:offset] as data:
217
+ self._frame(digest, segment, data)
218
+ size = len(data)
219
+ self._boundaries[key] = ("sha256:" + digest.hexdigest(), size)
220
+ self._boundary_order.append(key)
221
+ while len(self._boundary_order) > self._MAX_BOUNDARIES:
222
+ self._boundaries.pop(self._boundary_order.pop(0), None)
223
+
224
+ def _absorb(self, name, data) -> None:
225
+ self._frame(self._running, name, data)
226
+
227
+ @staticmethod
228
+ def _frame(digest, name: str, data) -> None:
229
+ """`data` is any bytes-like buffer; `hashlib` consumes it in place."""
230
+ encoded = name.encode("utf-8")
231
+ digest.update(len(encoded).to_bytes(4, "big"))
232
+ digest.update(encoded)
233
+ digest.update(len(data).to_bytes(8, "big"))
234
+ digest.update(data)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.92.1",
3
+ "version": "1.92.3",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {
@@ -103,6 +103,7 @@
103
103
  "bin/_lib_fmt.py",
104
104
  "bin/_lib_forecast.py",
105
105
  "bin/_lib_journal.py",
106
+ "bin/_lib_journal_router.py",
106
107
  "bin/_lib_json_envelope.py",
107
108
  "bin/_lib_jsonl.py",
108
109
  "bin/_lib_log.py",