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/_cctally_share.py
CHANGED
|
@@ -1024,10 +1024,12 @@ def _build_project_snapshot(
|
|
|
1024
1024
|
Privacy invariant (Section 8.4 / Section 5.3): the builder populates
|
|
1025
1025
|
`ProjectCell.label` AND `ChartPoint.project_label` (and `x_label`,
|
|
1026
1026
|
which is the project axis on a HorizontalBarChart) with the REAL
|
|
1027
|
-
`display_key`.
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1027
|
+
`display_key`. `_lib_share.render()` is the chokepoint: it prepares
|
|
1028
|
+
the RAW snapshot it is handed, rewriting every project label to
|
|
1029
|
+
`project-1` / `project-2` / ... unless `--reveal-projects` is
|
|
1030
|
+
passed. (It is NOT `_scrub()`: that function is retained for
|
|
1031
|
+
backward compatibility and no production path calls it.) The
|
|
1032
|
+
Section 8.4 canary
|
|
1031
1033
|
test (`test_anonymized_output_contains_zero_original_tokens`) and
|
|
1032
1034
|
the wrapper-level regression
|
|
1033
1035
|
(`test_share_render_and_emit_scrubs_project_labels`) both anchor
|
|
@@ -1133,6 +1135,7 @@ def _build_project_snapshot(
|
|
|
1133
1135
|
x_value=cost,
|
|
1134
1136
|
y_value=cost,
|
|
1135
1137
|
project_label=proj_label,
|
|
1138
|
+
x_label_kind="project",
|
|
1136
1139
|
))
|
|
1137
1140
|
chart = (
|
|
1138
1141
|
_lib_share.HorizontalBarChart(
|
|
@@ -1377,27 +1380,43 @@ def _session_disambiguate_labels(
|
|
|
1377
1380
|
|
|
1378
1381
|
Sessions without collisions are absent from the returned dict;
|
|
1379
1382
|
callers fall back to the bare basename.
|
|
1383
|
+
|
|
1384
|
+
#503 S1: the algorithm itself now lives in the share kernel as
|
|
1385
|
+
``_lib_share.disambiguate_basenames``, because the kernel's reveal-mode
|
|
1386
|
+
preparation needs the same resolution on the dashboard path. This is the
|
|
1387
|
+
delegating wrapper, so "the kernel matches the CLI" is one implementation
|
|
1388
|
+
rather than two that agree by inspection. The kernel additionally widens
|
|
1389
|
+
the qualifier when the parents also repeat, where this function used to
|
|
1390
|
+
emit the same colliding label twice.
|
|
1391
|
+
|
|
1392
|
+
The kernel's input contract is one entry per DISTINCT identity, so this
|
|
1393
|
+
wrapper deduplicates by path before delegating and fans the resulting
|
|
1394
|
+
label back out to every session sharing that path. A session list
|
|
1395
|
+
normally carries one project across several rows, and handing those
|
|
1396
|
+
duplicates to the kernel makes its ordinal fallback invent
|
|
1397
|
+
``repo (parent) (1)`` / ``(2)`` / ``(3)`` for what is one project — which
|
|
1398
|
+
also splits that project's cost across three alias slots, because a
|
|
1399
|
+
session-derived ``ProjectCell`` carries no ``identity`` and the alias key
|
|
1400
|
+
is therefore the label itself.
|
|
1380
1401
|
"""
|
|
1381
|
-
|
|
1382
|
-
for s in sessions
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
if
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
augmented[idx] = f"{bn} ({parent})"
|
|
1400
|
-
return augmented
|
|
1402
|
+
_lib_share = _share_load_lib()
|
|
1403
|
+
paths = [(s.project_path or "") for s in sessions]
|
|
1404
|
+
distinct: list[str] = []
|
|
1405
|
+
index_of: dict[str, int] = {}
|
|
1406
|
+
for p in paths:
|
|
1407
|
+
if p not in index_of:
|
|
1408
|
+
index_of[p] = len(distinct)
|
|
1409
|
+
distinct.append(p)
|
|
1410
|
+
resolved = _lib_share.disambiguate_basenames(distinct)
|
|
1411
|
+
bare = [
|
|
1412
|
+
(os.path.basename(p) or p or "(unknown)") for p in paths
|
|
1413
|
+
]
|
|
1414
|
+
out: dict[int, str] = {}
|
|
1415
|
+
for idx, p in enumerate(paths):
|
|
1416
|
+
label = resolved[index_of[p]]
|
|
1417
|
+
if label != bare[idx]:
|
|
1418
|
+
out[idx] = label
|
|
1419
|
+
return out
|
|
1401
1420
|
|
|
1402
1421
|
|
|
1403
1422
|
def _build_session_snapshot(
|
|
@@ -1428,11 +1447,12 @@ def _build_session_snapshot(
|
|
|
1428
1447
|
|
|
1429
1448
|
Privacy invariant (Section 8.4 / Section 5.3): the builder populates
|
|
1430
1449
|
`ProjectCell.label`, `ChartPoint.project_label`, and
|
|
1431
|
-
`ChartPoint.x_label` with the REAL `project_path` basename.
|
|
1432
|
-
`
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1450
|
+
`ChartPoint.x_label` with the REAL `project_path` basename.
|
|
1451
|
+
`_lib_share.render()` is the chokepoint: it prepares the RAW
|
|
1452
|
+
snapshot it is handed, rewriting every project label to
|
|
1453
|
+
`project-1` / `project-2` / ... unless `--reveal-projects` is
|
|
1454
|
+
passed. (It is NOT `_scrub()`: that function is retained for
|
|
1455
|
+
backward compatibility and no production path calls it.)
|
|
1436
1456
|
|
|
1437
1457
|
Deviations from the plan sketch (which assumed dict rows with keys
|
|
1438
1458
|
`session_id` / `started_at` / `project_path` / `cost_usd` /
|
|
@@ -1530,6 +1550,7 @@ def _build_session_snapshot(
|
|
|
1530
1550
|
x_value=cost_usd,
|
|
1531
1551
|
y_value=cost_usd,
|
|
1532
1552
|
project_label=proj_label,
|
|
1553
|
+
x_label_kind="project",
|
|
1533
1554
|
))
|
|
1534
1555
|
chart = (
|
|
1535
1556
|
_lib_share.HorizontalBarChart(
|
|
@@ -1651,7 +1672,7 @@ def _share_iso(value) -> "str | None":
|
|
|
1651
1672
|
# control.
|
|
1652
1673
|
|
|
1653
1674
|
def _share_render_and_emit(snap, args) -> None:
|
|
1654
|
-
"""End-to-end:
|
|
1675
|
+
"""End-to-end: render -> emit -> optional open.
|
|
1655
1676
|
|
|
1656
1677
|
Lazy-imports `_lib_share` so non-share invocations don't pay the import
|
|
1657
1678
|
cost. The kernel module stays I/O-pure; this wrapper does all the
|
|
@@ -1680,13 +1701,29 @@ def _share_render_and_emit(snap, args) -> None:
|
|
|
1680
1701
|
# class-identity invariant this enforces.
|
|
1681
1702
|
_lib_share = _share_load_lib()
|
|
1682
1703
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1704
|
+
# No pre-scrub. `render()` owns the privacy contract and must receive the
|
|
1705
|
+
# RAW snapshot: a second aliasing pass over an already-aliased legacy
|
|
1706
|
+
# label (identity None) renumbers it by re-ranking (#503 S1).
|
|
1707
|
+
#
|
|
1708
|
+
# A privacy refusal is a MESSAGE, not a traceback. `render()` raises
|
|
1709
|
+
# `SharePrivacyViolation` rather than redacting, so this is a reachable
|
|
1710
|
+
# user-facing outcome and the CLI owes the user a sentence naming what it
|
|
1711
|
+
# found. Exit 3 per `docs/cli-contract.md`: the share surface's staged
|
|
1712
|
+
# family already uses 2 for a flag-combo error and 3 for a stage that
|
|
1713
|
+
# failed after the flags validated, which is exactly what this is. The
|
|
1714
|
+
# refusal precedes destination resolution, so nothing is written.
|
|
1715
|
+
try:
|
|
1716
|
+
rendered = _lib_share.render(
|
|
1717
|
+
snap,
|
|
1718
|
+
format=args.format,
|
|
1719
|
+
theme=args.theme,
|
|
1720
|
+
branding=not args.no_branding,
|
|
1721
|
+
reveal_projects=args.reveal_projects,
|
|
1722
|
+
)
|
|
1723
|
+
except _lib_share.SharePrivacyViolation as exc:
|
|
1724
|
+
print(f"cctally: refused to write a share artifact — {exc}",
|
|
1725
|
+
file=sys.stderr)
|
|
1726
|
+
sys.exit(3)
|
|
1690
1727
|
|
|
1691
1728
|
utc_date = snap.generated_at.astimezone(dt.timezone.utc).strftime("%Y-%m-%d")
|
|
1692
1729
|
kind, value = _resolve_destination(args, cmd=snap.cmd, generated_at_utc_date=utc_date)
|
|
@@ -1919,15 +1919,13 @@ def _emit_source_share(
|
|
|
1919
1919
|
claude_snap = build_source_share_snapshot(
|
|
1920
1920
|
command, claude, reveal_projects=reveal_projects,
|
|
1921
1921
|
)
|
|
1922
|
+
# RAW sections (#503 S1): `compose()` prepares them together under one
|
|
1923
|
+
# merged alias namespace, so `project-1` denotes the same project in
|
|
1924
|
+
# the Claude and the Codex section. A per-section pre-scrub gave each
|
|
1925
|
+
# section its own numbering.
|
|
1922
1926
|
sections = (
|
|
1923
|
-
lib.ComposedSection(
|
|
1924
|
-
|
|
1925
|
-
drift_detected=False,
|
|
1926
|
-
),
|
|
1927
|
-
lib.ComposedSection(
|
|
1928
|
-
snap=lib._scrub(codex_snap, reveal_projects=reveal_projects),
|
|
1929
|
-
drift_detected=False,
|
|
1930
|
-
),
|
|
1927
|
+
lib.ComposedSection(snap=claude_snap, drift_detected=False),
|
|
1928
|
+
lib.ComposedSection(snap=codex_snap, drift_detected=False),
|
|
1931
1929
|
)
|
|
1932
1930
|
content = lib.compose(
|
|
1933
1931
|
sections,
|
package/bin/_cctally_store.py
CHANGED
|
@@ -638,9 +638,17 @@ _STATS_OPEN_TIME_MAINTENANCE_WAIT_S = 30.0
|
|
|
638
638
|
|
|
639
639
|
|
|
640
640
|
@contextlib.contextmanager
|
|
641
|
-
def stats_open_time_guard(
|
|
641
|
+
def stats_open_time_guard(
|
|
642
|
+
*, live: bool = True, wait_seconds: "float | None" = None,
|
|
643
|
+
):
|
|
642
644
|
"""Hold maintenance-exclusive + the sanctioned scope across open-time DDL.
|
|
643
645
|
|
|
646
|
+
``wait_seconds`` overrides the generous default wait. #496 S5b's
|
|
647
|
+
quota-projection reconciliation passes ``0.0``: that path is optional work
|
|
648
|
+
on the open path, and waiting thirty seconds for a competitor to finish its
|
|
649
|
+
own maintenance is strictly worse than leaving the flag set for the next
|
|
650
|
+
armed open, which is the fail-closed direction anyway.
|
|
651
|
+
|
|
644
652
|
``live=False`` marks a ``_target_path`` (scratch) build: it enters the
|
|
645
653
|
sanctioned scope but takes NO flock, mirroring the divergence
|
|
646
654
|
``stats_open_guarded`` already documents. Two reasons, both concrete:
|
|
@@ -668,7 +676,10 @@ def stats_open_time_guard(*, live: bool = True):
|
|
|
668
676
|
)
|
|
669
677
|
acquired = False
|
|
670
678
|
try:
|
|
671
|
-
deadline = time.monotonic() +
|
|
679
|
+
deadline = time.monotonic() + (
|
|
680
|
+
_STATS_OPEN_TIME_MAINTENANCE_WAIT_S if wait_seconds is None
|
|
681
|
+
else float(wait_seconds)
|
|
682
|
+
)
|
|
672
683
|
while True:
|
|
673
684
|
try:
|
|
674
685
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -270,6 +270,11 @@ from _cctally_dashboard_sources import (
|
|
|
270
270
|
refresh_codex_source_clock,
|
|
271
271
|
resolve_dashboard_source_semantics,
|
|
272
272
|
)
|
|
273
|
+
# #496 S5b §4.7. Module level rather than lazy, unlike this module's other
|
|
274
|
+
# `_cctally_quota` uses: an `except` clause needs the name bound before the
|
|
275
|
+
# `try`, and `_cctally_dashboard_sources` above already imports that module at
|
|
276
|
+
# ITS module level, so this adds no edge to the import graph.
|
|
277
|
+
from _cctally_quota import QuotaProjectionIncomplete
|
|
273
278
|
from _lib_dashboard_sources import (
|
|
274
279
|
SOURCE_SCHEMA_VERSION,
|
|
275
280
|
CapabilityRecord,
|
|
@@ -2607,6 +2612,23 @@ def _tui_build_source_bundle(
|
|
|
2607
2612
|
raw_config if raw_config is not None else c.load_config(),
|
|
2608
2613
|
display_tz_name=display_tz_name,
|
|
2609
2614
|
)
|
|
2615
|
+
# `codex_stats_digest`'s relation table reads `quota_projection_state`
|
|
2616
|
+
# and `quota_window_blocks` from a pure kernel that may not import
|
|
2617
|
+
# `_cctally_quota`, so its callers gate it (#496 S5b section 4.7).
|
|
2618
|
+
#
|
|
2619
|
+
# This ONE call covers the second `codex_stats_digest` below as well,
|
|
2620
|
+
# and the reason is not that the gate is a property of the connection:
|
|
2621
|
+
# this connection's statement-scoped autocommit reads may see mixed
|
|
2622
|
+
# generations, which is exactly what `stats_generation_moved` exists to
|
|
2623
|
+
# catch, so the post-build read CAN see a generation this gate never
|
|
2624
|
+
# checked. It is safe because that digest is never rendered — it is only
|
|
2625
|
+
# compared against this one. A generation carrying an incomplete
|
|
2626
|
+
# projection produces a different digest, `stats_generation_moved` is
|
|
2627
|
+
# then true, and the build returns the prior bundle or raises. An EQUAL
|
|
2628
|
+
# digest means the relations did not move, so the post-build read saw
|
|
2629
|
+
# the generation this gate did check.
|
|
2630
|
+
from _cctally_quota import assert_projection_readable
|
|
2631
|
+
assert_projection_readable(stats_conn)
|
|
2610
2632
|
stats_digest = codex_stats_digest(stats_conn)
|
|
2611
2633
|
# #341 finding 9: the account registry/active-identity digest. Empty for
|
|
2612
2634
|
# every <=1-account install (byte-neutral — appended only when non-empty),
|
|
@@ -3254,6 +3276,13 @@ def _tui_build_snapshot_once(
|
|
|
3254
3276
|
with _perf.phase("signature"):
|
|
3255
3277
|
try:
|
|
3256
3278
|
dispatch_sig = _tui_compute_dispatch_signature(conn)
|
|
3279
|
+
except QuotaProjectionIncomplete as exc:
|
|
3280
|
+
# #496 S5b §4.7: ahead of the generic handler on purpose.
|
|
3281
|
+
# Below it the refusal is sanitized into a generic
|
|
3282
|
+
# stats-or-cache failure, which names no cause and no
|
|
3283
|
+
# remedy; the message this leg records carries both.
|
|
3284
|
+
capture_failure("quota-projection", "other", exc)
|
|
3285
|
+
dispatch_sig = None
|
|
3257
3286
|
except Exception as exc:
|
|
3258
3287
|
capture_failure("dispatch-signature", "stats_or_cache", exc)
|
|
3259
3288
|
dispatch_sig = None
|
|
@@ -3886,6 +3915,15 @@ def _tui_build_snapshot_once(
|
|
|
3886
3915
|
)
|
|
3887
3916
|
if source_bundle is None:
|
|
3888
3917
|
raise RuntimeError("source bundle builder returned no bundle")
|
|
3918
|
+
except QuotaProjectionIncomplete as exc:
|
|
3919
|
+
# #496 S5b §4.7: ahead of the generic handler on purpose. Below
|
|
3920
|
+
# it the refusal was sanitized into a generic stats-or-cache
|
|
3921
|
+
# failure and the bundle fell back to `prior_source_bundle`,
|
|
3922
|
+
# which is `None` on a cold start — a permanently blank Codex
|
|
3923
|
+
# source panel with no cause and no remedy stated. The message
|
|
3924
|
+
# this leg records carries both.
|
|
3925
|
+
capture_failure("quota-projection", "other", exc)
|
|
3926
|
+
source_bundle = prior_source_bundle
|
|
3889
3927
|
except Exception as exc:
|
|
3890
3928
|
# Public source warnings are stable/sanitized; the detailed
|
|
3891
3929
|
# exception remains only on the internal rebuild-error string.
|
|
@@ -4012,6 +4050,11 @@ def _tui_compute_dispatch_signature(stats_conn):
|
|
|
4012
4050
|
"""
|
|
4013
4051
|
c = _cctally()
|
|
4014
4052
|
sc = c._load_sibling("_lib_snapshot_cache")
|
|
4053
|
+
# Same gate as `_tui_build_source_bundle`, for the same reason: this is the
|
|
4054
|
+
# second caller of `codex_stats_digest`, whose relation table reads the two
|
|
4055
|
+
# projection tables from a pure kernel (#496 S5b section 4.7).
|
|
4056
|
+
from _cctally_quota import assert_projection_readable
|
|
4057
|
+
assert_projection_readable(stats_conn)
|
|
4015
4058
|
cache_conn = c.open_cache_db()
|
|
4016
4059
|
try:
|
|
4017
4060
|
return sc.compute_signature(
|
|
@@ -4202,6 +4245,16 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
|
|
|
4202
4245
|
)
|
|
4203
4246
|
except _StatsSnapshotCorruption:
|
|
4204
4247
|
raise
|
|
4248
|
+
except QuotaProjectionIncomplete as exc:
|
|
4249
|
+
# #496 S5b §4.7, same reason as the two handlers in
|
|
4250
|
+
# `_tui_build_snapshot_once`: the branch below classifies against
|
|
4251
|
+
# the connection that faulted and would report a stats-or-cache
|
|
4252
|
+
# fault, which is the wrong cause and the wrong remedy.
|
|
4253
|
+
errors.append(f"quota-projection: {exc}")
|
|
4254
|
+
idle_failures.append(SyncFailureAttribution(
|
|
4255
|
+
leg="quota-projection", database="other", corruption=False,
|
|
4256
|
+
))
|
|
4257
|
+
source_bundle = prior.source_bundle
|
|
4205
4258
|
except Exception as exc: # noqa: BLE001 — retain prior complete bundle
|
|
4206
4259
|
# #496 S3 §8 (F16). This branch read stats through
|
|
4207
4260
|
# `source_stats_conn` and swallowed the failure into a plain
|