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
|
@@ -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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
156
|
+
return _PrefixSnapshot(high_water, prefix_hash, selection, audit_ends)
|
|
77
157
|
|
|
78
158
|
|
|
79
159
|
def _preview_payload(requested=()):
|
|
80
|
-
|
|
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),
|
|
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,
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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,
|
|
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,
|
|
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 =
|
|
378
|
-
audit_ids,
|
|
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,
|
|
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,
|
|
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:
|
|
@@ -30,7 +30,7 @@ import sys
|
|
|
30
30
|
from dataclasses import replace
|
|
31
31
|
|
|
32
32
|
from _cctally_core import make_week_ref, parse_iso_datetime
|
|
33
|
-
from _cctally_quota import codex_quota_breakdown
|
|
33
|
+
from _cctally_quota import assert_projection_readable, codex_quota_breakdown
|
|
34
34
|
from _lib_accounts import UNATTRIBUTED
|
|
35
35
|
from _lib_codex_pools import is_model_scoped_codex_quota
|
|
36
36
|
from _lib_dashboard_sources import dashboard_resource_key
|
|
@@ -677,6 +677,8 @@ def _load_codex_cycles(stats_conn, root_keys, *, include_orphaned=False,
|
|
|
677
677
|
# `quota_window_blocks.account_key` is NOT NULL DEFAULT 'unattributed'.
|
|
678
678
|
account_clause = "" if account_key is None else "AND account_key=? "
|
|
679
679
|
account_params = () if account_key is None else (account_key,)
|
|
680
|
+
# BEFORE the SQL, per #496 S5b section 4.7.
|
|
681
|
+
assert_projection_readable(stats_conn)
|
|
680
682
|
rows = stats_conn.execute(
|
|
681
683
|
"SELECT source_root_key, logical_limit_key, observed_slot, "
|
|
682
684
|
" window_minutes, limit_id, limit_name, account_key, "
|
|
@@ -808,6 +810,7 @@ def _codex_five_hour_rows(stats_conn, cyc, *, include_orphaned=False) -> list:
|
|
|
808
810
|
never-combine rule.
|
|
809
811
|
"""
|
|
810
812
|
orphan_clause = "" if include_orphaned else "AND orphaned_at IS NULL "
|
|
813
|
+
assert_projection_readable(stats_conn)
|
|
811
814
|
return stats_conn.execute(
|
|
812
815
|
"SELECT source_root_key, logical_limit_key, observed_slot, "
|
|
813
816
|
" window_minutes, limit_id, limit_name, account_key, "
|
package/bin/_cctally_project.py
CHANGED
|
@@ -768,12 +768,14 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
768
768
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
769
769
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
770
770
|
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
771
|
-
# Privacy invariant (Section 8.4 / 5.3):
|
|
772
|
-
#
|
|
773
|
-
# `project-1` / `project-2` / ...; `--reveal-projects`
|
|
774
|
-
# The builder populates `ProjectCell.label` /
|
|
775
|
-
# / `ChartPoint.x_label` with REAL names;
|
|
776
|
-
# the
|
|
771
|
+
# Privacy invariant (Section 8.4 / 5.3): `_lib_share.render()` prepares
|
|
772
|
+
# the RAW snapshot the wrapper hands it, so default output anonymizes
|
|
773
|
+
# project labels to `project-1` / `project-2` / ...; `--reveal-projects`
|
|
774
|
+
# opts back in. The builder populates `ProjectCell.label` /
|
|
775
|
+
# `ChartPoint.project_label` / `ChartPoint.x_label` with REAL names;
|
|
776
|
+
# `render()` is the chokepoint that rewrites them. (It is NOT `_scrub()`:
|
|
777
|
+
# that function is retained for backward compatibility and no production
|
|
778
|
+
# path calls it.)
|
|
777
779
|
if getattr(args, "format", None):
|
|
778
780
|
# Note: --breakdown is a no-op under --format (snapshot focuses on
|
|
779
781
|
# the headline per-project usage table + HBar chart; per-model
|