cctally 1.92.2 → 1.93.0
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.
- package/CHANGELOG.md +43 -0
- package/bin/_cctally_cache.py +354 -0
- package/bin/_cctally_core.py +180 -3
- package/bin/_cctally_dashboard.py +71 -1
- package/bin/_cctally_dashboard_envelope.py +28 -2
- package/bin/_cctally_dashboard_share.py +75 -19
- package/bin/_cctally_dashboard_sources.py +12 -0
- package/bin/_cctally_db.py +89 -1
- package/bin/_cctally_doctor.py +31 -0
- package/bin/_cctally_forecast.py +4 -2
- package/bin/_cctally_journal.py +3482 -258
- package/bin/_cctally_journal_repair.py +123 -32
- package/bin/_cctally_milestone_history.py +4 -1
- package/bin/_cctally_project.py +8 -6
- package/bin/_cctally_quota.py +420 -20
- package/bin/_cctally_rederive.py +57 -23
- package/bin/_cctally_reporting.py +8 -6
- package/bin/_cctally_share.py +74 -37
- package/bin/_cctally_source_analytics.py +6 -8
- package/bin/_cctally_store.py +13 -2
- package/bin/_cctally_tui.py +53 -0
- package/bin/_lib_cache_coverage.py +547 -0
- package/bin/_lib_doctor.py +54 -2
- package/bin/_lib_journal.py +235 -95
- package/bin/_lib_journal_router.py +21 -0
- package/bin/_lib_segment_summary.py +374 -0
- package/bin/_lib_selector_state.py +959 -0
- package/bin/_lib_share.py +1073 -165
- package/bin/_lib_share_templates.py +35 -11
- package/bin/_lib_stats_wal.py +327 -0
- package/bin/_lib_view_models.py +2 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
- package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-DnWdv8um.css +0 -1
package/bin/_lib_journal.py
CHANGED
|
@@ -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
|
|
|
@@ -708,113 +748,167 @@ def _is_legacy_quota_arming_state(candidate: EffectiveEvent) -> bool:
|
|
|
708
748
|
)
|
|
709
749
|
|
|
710
750
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
*,
|
|
714
|
-
protocol_prefix_evidence=(),
|
|
715
|
-
) -> EffectiveSelection:
|
|
716
|
-
"""Validate correction batches and select one highest revision per evt id.
|
|
751
|
+
class SelectorFold:
|
|
752
|
+
"""The six accumulators `resolve_effective_events` builds over the stream.
|
|
717
753
|
|
|
718
|
-
|
|
754
|
+
Named and made seedable for #496 S5b: durable selector state reproduces
|
|
755
|
+
exactly these six, and an incremental pass has to continue the same fold
|
|
756
|
+
from them rather than re-running it over the whole journal prefix. Keeping
|
|
757
|
+
ONE implementation is the point — a second copy of the taint rules in the
|
|
758
|
+
incremental path would be a second thing to keep correct.
|
|
719
759
|
|
|
720
|
-
|
|
721
|
-
candidate becomes the provisional winner and the group is reported on
|
|
722
|
-
`EffectiveSelection.conflicts`. The journal is append-only, so a divergent
|
|
723
|
-
line can never be un-written; raising here wedged every rebuild forever.
|
|
724
|
-
- The seven enumerated **structural** correction-batch violations taint their
|
|
725
|
-
entire batch. No action from it is a candidate; the selector continues
|
|
726
|
-
with distinct valid batches and reports a bounded `ProtocolViolation`.
|
|
727
|
-
- Invalid marker/action field shapes and every other out-of-scope
|
|
728
|
-
`JournalProtocolError` remain fatal. Unknown record types remain ignored.
|
|
760
|
+
`completed` is filled by :func:`resolve_batches`, not by the record fold.
|
|
729
761
|
"""
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
762
|
+
|
|
763
|
+
__slots__ = (
|
|
764
|
+
"candidates",
|
|
765
|
+
"markers",
|
|
766
|
+
"actions",
|
|
767
|
+
"resolutions",
|
|
768
|
+
"tainted_batches",
|
|
769
|
+
"violations",
|
|
770
|
+
"violation_available_after",
|
|
771
|
+
"completed",
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
def __init__(self) -> None:
|
|
775
|
+
#: one entry per evt record and per action of a completed batch
|
|
776
|
+
self.candidates: list[EffectiveEvent] = []
|
|
777
|
+
#: batch id -> phase -> (normalized core, record digest, identity
|
|
778
|
+
#: digest, sequence)
|
|
779
|
+
self.markers: dict[str, dict[str, tuple[dict, str, str, int]]] = {}
|
|
780
|
+
#: batch id -> action seq -> (normalized record, record digest, sequence)
|
|
781
|
+
self.actions: dict[str, dict[int, tuple[dict, str, int]]] = {}
|
|
782
|
+
self.resolutions: list[tuple[int, dict]] = []
|
|
783
|
+
self.tainted_batches: set[str] = set()
|
|
784
|
+
self.violations: dict[tuple[str, str, str], ProtocolViolation] = {}
|
|
785
|
+
self.violation_available_after: dict[str, int] = {}
|
|
786
|
+
self.completed: set[str] = set()
|
|
787
|
+
|
|
788
|
+
def taint(self, violation: ProtocolViolation, *, available_after: int) -> None:
|
|
739
789
|
"""Taint one batch and retain every distinct violation identity."""
|
|
740
|
-
tainted_batches.add(violation.batch_id)
|
|
741
|
-
violations[
|
|
790
|
+
self.tainted_batches.add(violation.batch_id)
|
|
791
|
+
self.violations[
|
|
742
792
|
(violation.batch_id, violation.kind, violation.fingerprint)
|
|
743
793
|
] = violation
|
|
744
|
-
violation_available_after[violation.fingerprint] = min(
|
|
794
|
+
self.violation_available_after[violation.fingerprint] = min(
|
|
745
795
|
available_after,
|
|
746
|
-
violation_available_after.get(
|
|
796
|
+
self.violation_available_after.get(
|
|
747
797
|
violation.fingerprint,
|
|
748
798
|
available_after,
|
|
749
799
|
),
|
|
750
800
|
)
|
|
751
801
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
802
|
+
|
|
803
|
+
def fold_records(fold: SelectorFold, records, *, start_sequence: int = 0) -> int:
|
|
804
|
+
"""Phase 1 — accumulate one record stream into ``fold``.
|
|
805
|
+
|
|
806
|
+
``start_sequence`` is the sequence number the FIRST element takes. It is
|
|
807
|
+
non-zero only on an incremental pass continuing a durable prefix, and the
|
|
808
|
+
numbering is load-bearing: three of the seven structural violation kinds put
|
|
809
|
+
that number inside `ProtocolViolation.evidence`, which the fingerprint
|
|
810
|
+
hashes, and that fingerprint is stored durably and referenced by name from a
|
|
811
|
+
`journal_protocol_resolution` op.
|
|
812
|
+
|
|
813
|
+
Returns the sequence the NEXT stream must start at. Every element consumes
|
|
814
|
+
one sequence number, including a non-dict element, because the rebuild
|
|
815
|
+
appends an explicit placeholder for every valid decoded non-retained record.
|
|
816
|
+
"""
|
|
817
|
+
sequence = start_sequence
|
|
818
|
+
for record in records:
|
|
819
|
+
_fold_one(fold, record, sequence)
|
|
820
|
+
sequence += 1
|
|
821
|
+
return sequence
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _fold_one(fold: SelectorFold, record, sequence: int) -> None:
|
|
825
|
+
candidates = fold.candidates
|
|
826
|
+
markers = fold.markers
|
|
827
|
+
actions = fold.actions
|
|
828
|
+
resolutions = fold.resolutions
|
|
829
|
+
taint = fold.taint
|
|
830
|
+
if not isinstance(record, dict):
|
|
831
|
+
return
|
|
832
|
+
record_type = record.get("t")
|
|
833
|
+
if record_type == "evt":
|
|
834
|
+
candidates.append(_candidate_from_evt(record, sequence))
|
|
835
|
+
return
|
|
836
|
+
if (
|
|
837
|
+
record_type == "op"
|
|
838
|
+
and isinstance(record.get("payload"), dict)
|
|
839
|
+
and record["payload"].get("kind") == _PROTOCOL_RESOLUTION_KIND
|
|
840
|
+
):
|
|
841
|
+
resolutions.append(
|
|
842
|
+
(sequence, _validate_protocol_resolution(record))
|
|
843
|
+
)
|
|
844
|
+
return
|
|
845
|
+
if record_type == "correction_batch":
|
|
846
|
+
normalized = _validate_batch_marker(record)
|
|
847
|
+
batch_id = normalized["id"]
|
|
848
|
+
phase = record["phase"]
|
|
849
|
+
digest = _sha256_canonical(record)
|
|
850
|
+
marker_identity = dict(record)
|
|
851
|
+
marker_identity.pop("phase", None)
|
|
852
|
+
identity_digest = _sha256_canonical(marker_identity)
|
|
853
|
+
prior = markers.setdefault(batch_id, {}).get(phase)
|
|
854
|
+
if prior is not None and prior[1] != digest:
|
|
855
|
+
taint(
|
|
856
|
+
_protocol_violation(
|
|
857
|
+
batch_id,
|
|
858
|
+
"marker_conflict",
|
|
859
|
+
phase=phase,
|
|
860
|
+
firstRecordHash=prior[1],
|
|
861
|
+
conflictingRecordHash=digest,
|
|
862
|
+
),
|
|
863
|
+
available_after=max(prior[3], sequence),
|
|
766
864
|
)
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
batch_id = normalized["batch"]
|
|
799
|
-
seq = normalized["seq"]
|
|
800
|
-
digest = _sha256_canonical(record)
|
|
801
|
-
prior = actions.setdefault(batch_id, {}).get(seq)
|
|
802
|
-
if prior is not None and prior[1] != digest:
|
|
803
|
-
taint(
|
|
804
|
-
_protocol_violation(
|
|
805
|
-
batch_id,
|
|
806
|
-
"action_sequence_conflict",
|
|
807
|
-
actionSequence=seq,
|
|
808
|
-
firstRecordHash=prior[1],
|
|
809
|
-
conflictingRecordHash=digest,
|
|
810
|
-
),
|
|
811
|
-
available_after=max(prior[2], sequence),
|
|
812
|
-
)
|
|
813
|
-
if prior is None:
|
|
814
|
-
actions[batch_id][seq] = (normalized, digest, sequence)
|
|
865
|
+
if prior is None:
|
|
866
|
+
markers[batch_id][phase] = (
|
|
867
|
+
normalized,
|
|
868
|
+
digest,
|
|
869
|
+
identity_digest,
|
|
870
|
+
sequence,
|
|
871
|
+
)
|
|
872
|
+
return
|
|
873
|
+
if record_type == "correction":
|
|
874
|
+
normalized = _validate_correction_record(record)
|
|
875
|
+
batch_id = normalized["batch"]
|
|
876
|
+
seq = normalized["seq"]
|
|
877
|
+
digest = _sha256_canonical(record)
|
|
878
|
+
prior = actions.setdefault(batch_id, {}).get(seq)
|
|
879
|
+
if prior is not None and prior[1] != digest:
|
|
880
|
+
taint(
|
|
881
|
+
_protocol_violation(
|
|
882
|
+
batch_id,
|
|
883
|
+
"action_sequence_conflict",
|
|
884
|
+
actionSequence=seq,
|
|
885
|
+
firstRecordHash=prior[1],
|
|
886
|
+
conflictingRecordHash=digest,
|
|
887
|
+
),
|
|
888
|
+
available_after=max(prior[2], sequence),
|
|
889
|
+
)
|
|
890
|
+
if prior is None:
|
|
891
|
+
actions[batch_id][seq] = (normalized, digest, sequence)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def resolve_batches(fold: SelectorFold, *, batch_ids=None) -> None:
|
|
895
|
+
"""Phase 2 — decide completion or taint for each accumulated batch.
|
|
815
896
|
|
|
816
|
-
|
|
817
|
-
|
|
897
|
+
``batch_ids`` restricts the pass. An incremental caller passes the batches
|
|
898
|
+
whose durable status is not already `completed`: a completed batch's action
|
|
899
|
+
cores are deliberately dropped once it completes, so its verdict is carried
|
|
900
|
+
forward rather than re-derived, and a later record that conflicts with it is
|
|
901
|
+
detected in phase 1 from the retained whole-record digest instead.
|
|
902
|
+
"""
|
|
903
|
+
candidates = fold.candidates
|
|
904
|
+
markers = fold.markers
|
|
905
|
+
actions = fold.actions
|
|
906
|
+
tainted_batches = fold.tainted_batches
|
|
907
|
+
completed = fold.completed
|
|
908
|
+
taint = fold.taint
|
|
909
|
+
if batch_ids is None:
|
|
910
|
+
batch_ids = set(markers) | set(actions)
|
|
911
|
+
for batch_id in sorted(batch_ids):
|
|
818
912
|
batch_markers = markers.get(batch_id, {})
|
|
819
913
|
begin = batch_markers.get("begin")
|
|
820
914
|
commit = batch_markers.get("commit")
|
|
@@ -833,8 +927,15 @@ def resolve_effective_events(
|
|
|
833
927
|
if begin is None or commit is None:
|
|
834
928
|
continue
|
|
835
929
|
begin_core, _begin_hash, begin_identity, begin_sequence = begin
|
|
836
|
-
|
|
837
|
-
|
|
930
|
+
_commit_core, _commit_hash, commit_identity, commit_sequence = commit
|
|
931
|
+
# The identity digest covers the whole marker record MINUS its phase,
|
|
932
|
+
# and the normalized core is a pure function of fields inside that
|
|
933
|
+
# digest, so equal identities imply equal cores and unequal cores imply
|
|
934
|
+
# unequal identities. Comparing identities alone is therefore exactly
|
|
935
|
+
# the previous `core != core or identity != identity` condition, and it
|
|
936
|
+
# is what lets an incremental pass seed a marker from the durable row
|
|
937
|
+
# (#496 S5b §3.2), which stores the identity digest but not the core.
|
|
938
|
+
if begin_identity != commit_identity:
|
|
838
939
|
taint(
|
|
839
940
|
_protocol_violation(
|
|
840
941
|
batch_id,
|
|
@@ -932,6 +1033,45 @@ def resolve_effective_events(
|
|
|
932
1033
|
normalized, _digest, sequence = batch_actions[seq]
|
|
933
1034
|
candidates.append(_candidate_from_correction(normalized, sequence))
|
|
934
1035
|
|
|
1036
|
+
|
|
1037
|
+
def resolve_effective_events(
|
|
1038
|
+
records,
|
|
1039
|
+
*,
|
|
1040
|
+
protocol_prefix_evidence=(),
|
|
1041
|
+
accumulators=None,
|
|
1042
|
+
) -> EffectiveSelection:
|
|
1043
|
+
"""Validate correction batches and select one highest revision per evt id.
|
|
1044
|
+
|
|
1045
|
+
Three failure classes, deliberately asymmetric (#374 §5, #402 Task A):
|
|
1046
|
+
|
|
1047
|
+
- Divergent same-revision EVENTS are **quarantined**: the lowest-sequence
|
|
1048
|
+
candidate becomes the provisional winner and the group is reported on
|
|
1049
|
+
`EffectiveSelection.conflicts`. The journal is append-only, so a divergent
|
|
1050
|
+
line can never be un-written; raising here wedged every rebuild forever.
|
|
1051
|
+
- The seven enumerated **structural** correction-batch violations taint their
|
|
1052
|
+
entire batch. No action from it is a candidate; the selector continues
|
|
1053
|
+
with distinct valid batches and reports a bounded `ProtocolViolation`.
|
|
1054
|
+
- Invalid marker/action field shapes and every other out-of-scope
|
|
1055
|
+
`JournalProtocolError` remain fatal. Unknown record types remain ignored.
|
|
1056
|
+
|
|
1057
|
+
``accumulators`` (#496 S5b §3.1) is an optional out-dict. When supplied it
|
|
1058
|
+
is populated with the :class:`SelectorFold` this pass built, under the key
|
|
1059
|
+
``"fold"``, so a caller can persist the six accumulators the summary
|
|
1060
|
+
discards. It is an out-parameter rather than a new `EffectiveSelection`
|
|
1061
|
+
field precisely so the frozen result shape and every existing caller stay
|
|
1062
|
+
byte-unaffected.
|
|
1063
|
+
"""
|
|
1064
|
+
fold = SelectorFold()
|
|
1065
|
+
fold_records(fold, records)
|
|
1066
|
+
resolve_batches(fold)
|
|
1067
|
+
if accumulators is not None:
|
|
1068
|
+
accumulators["fold"] = fold
|
|
1069
|
+
candidates = fold.candidates
|
|
1070
|
+
resolutions = fold.resolutions
|
|
1071
|
+
violations = fold.violations
|
|
1072
|
+
violation_available_after = fold.violation_available_after
|
|
1073
|
+
completed = fold.completed
|
|
1074
|
+
|
|
935
1075
|
violation_by_fingerprint = {
|
|
936
1076
|
violation.fingerprint: violation for violation in violations.values()
|
|
937
1077
|
}
|
|
@@ -84,6 +84,27 @@ class LastSeenAccumulator:
|
|
|
84
84
|
if previous is None or at > previous:
|
|
85
85
|
self.stamped[key] = at
|
|
86
86
|
|
|
87
|
+
def merge(self, stamped, legacy_claude_at=None, legacy_codex_at=None):
|
|
88
|
+
"""Fold another accumulator's partial state into this one.
|
|
89
|
+
|
|
90
|
+
The fold is a per-key MAXIMUM over timestamps, and maximum is
|
|
91
|
+
associative and commutative, so a segment's partial contribution merges
|
|
92
|
+
in any order and produces the same map a single pass would. That is what
|
|
93
|
+
lets #496 S5b Stage 4 import an elided segment's stored contribution
|
|
94
|
+
instead of reading the segment (spec section 5.4).
|
|
95
|
+
"""
|
|
96
|
+
for key, at in dict(stamped).items():
|
|
97
|
+
if isinstance(key, str) and key and isinstance(at, str) and at:
|
|
98
|
+
self._bump(key, at)
|
|
99
|
+
if legacy_claude_at is not None and (
|
|
100
|
+
self.legacy_claude_at is None
|
|
101
|
+
or legacy_claude_at > self.legacy_claude_at):
|
|
102
|
+
self.legacy_claude_at = legacy_claude_at
|
|
103
|
+
if legacy_codex_at is not None and (
|
|
104
|
+
self.legacy_codex_at is None
|
|
105
|
+
or legacy_codex_at > self.legacy_codex_at):
|
|
106
|
+
self.legacy_codex_at = legacy_codex_at
|
|
107
|
+
|
|
87
108
|
def resolve(self, cutover_claude: str, unattributed: str) -> dict:
|
|
88
109
|
"""Apply the deferred legacy buckets and return the final MAX map."""
|
|
89
110
|
out = dict(self.stamped)
|