cctally 1.113.0 → 1.114.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 +22 -0
- package/README.md +4 -6
- package/bin/_cctally_cache.py +1683 -485
- package/bin/_cctally_core.py +223 -1
- package/bin/_cctally_dashboard.py +96 -18
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +267 -204
- package/bin/_cctally_db.py +861 -18
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +136 -18
- package/bin/_cctally_five_hour.py +2 -2
- package/bin/_cctally_forecast.py +27 -6
- package/bin/_cctally_journal.py +67 -5
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_quota.py +201 -33
- package/bin/_cctally_record.py +14 -0
- package/bin/_cctally_rederive.py +23 -9
- package/bin/_cctally_source_analytics.py +60 -15
- package/bin/_cctally_statusline.py +17 -0
- package/bin/_cctally_store.py +80 -5
- package/bin/_cctally_tui.py +4 -3
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +164 -30
- package/bin/_lib_conversation_query.py +95 -56
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_doctor.py +263 -69
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_merge_gate.py +469 -51
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +193 -8
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +225 -77
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +8 -0
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
|
@@ -31,6 +31,7 @@ from __future__ import annotations
|
|
|
31
31
|
|
|
32
32
|
import ipaddress
|
|
33
33
|
import json
|
|
34
|
+
import socket
|
|
34
35
|
import sys
|
|
35
36
|
import urllib.error
|
|
36
37
|
import urllib.request
|
|
@@ -40,6 +41,7 @@ from _lib_dashboard_json import encode_dashboard_json, encode_dashboard_json_byt
|
|
|
40
41
|
_TIMEOUT_SECONDS = 5.0
|
|
41
42
|
_DIAGNOSTIC_PATH = "/api/debug/backend"
|
|
42
43
|
_TRACE_PATH = "/api/debug/backend/trace"
|
|
44
|
+
WRITE_IO_PATH = "/api/debug/backend/write-io"
|
|
43
45
|
|
|
44
46
|
#: The two regimes the period is reported for. `not_observed` is discarded:
|
|
45
47
|
#: a tick no build's Codex decision reached says nothing about either cost
|
|
@@ -55,7 +57,14 @@ def _cctally():
|
|
|
55
57
|
|
|
56
58
|
|
|
57
59
|
class DashboardPerfError(Exception):
|
|
58
|
-
"""A staged failure: exit 3. Carries the message the user sees
|
|
60
|
+
"""A staged failure: exit 3. Carries the message the user sees and a
|
|
61
|
+
typed `reason` (`authentication_required`, `refused`, `endpoint_missing`,
|
|
62
|
+
`http_error`, `timeout`, `unreachable`, `malformed_response`) the doctor's
|
|
63
|
+
write-telemetry probe reports."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, *, reason: str = "http_error"):
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.reason = reason
|
|
59
68
|
|
|
60
69
|
|
|
61
70
|
def resolve_loopback_target(host: str) -> str:
|
|
@@ -86,7 +95,7 @@ def _authority(host: str, port: int) -> str:
|
|
|
86
95
|
return f"{host}:{port}"
|
|
87
96
|
|
|
88
97
|
|
|
89
|
-
def _request(host, port, path, *, token, body=None):
|
|
98
|
+
def _request(host, port, path, *, token, body=None, timeout=_TIMEOUT_SECONDS):
|
|
90
99
|
"""One loopback HTTP round trip. Raises DashboardPerfError on any failure."""
|
|
91
100
|
authority = _authority(host, port)
|
|
92
101
|
url = f"http://{authority}{path}"
|
|
@@ -100,26 +109,35 @@ def _request(host, port, path, *, token, body=None):
|
|
|
100
109
|
request = urllib.request.Request(url, data=data, headers=headers,
|
|
101
110
|
method="POST" if data else "GET")
|
|
102
111
|
try:
|
|
103
|
-
with urllib.request.urlopen(request, timeout=
|
|
112
|
+
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
|
104
113
|
raw = resp.read()
|
|
105
114
|
except urllib.error.HTTPError as exc:
|
|
106
115
|
detail = {401: "authentication required — pass --token",
|
|
107
116
|
403: "refused by the loopback gate",
|
|
108
117
|
404: "this dashboard predates dashboard-perf"}.get(
|
|
109
118
|
exc.code, "unexpected response")
|
|
119
|
+
reason = {401: "authentication_required", 403: "refused",
|
|
120
|
+
404: "endpoint_missing"}.get(exc.code, "http_error")
|
|
110
121
|
raise DashboardPerfError(
|
|
111
|
-
f"{url} answered HTTP {exc.code}: {detail}") from None
|
|
122
|
+
f"{url} answered HTTP {exc.code}: {detail}", reason=reason) from None
|
|
112
123
|
except urllib.error.URLError as exc:
|
|
124
|
+
timed_out = isinstance(exc.reason, (TimeoutError, socket.timeout))
|
|
113
125
|
raise DashboardPerfError(
|
|
114
126
|
f"cannot reach {url}: {exc.reason}. Is a dashboard running on "
|
|
115
|
-
f"port {port}?"
|
|
127
|
+
f"port {port}?",
|
|
128
|
+
reason="timeout" if timed_out else "unreachable") from None
|
|
129
|
+
except (TimeoutError, socket.timeout) as exc:
|
|
130
|
+
raise DashboardPerfError(f"cannot reach {url}: {exc}",
|
|
131
|
+
reason="timeout") from None
|
|
116
132
|
except OSError as exc:
|
|
117
|
-
raise DashboardPerfError(f"cannot reach {url}: {exc}"
|
|
133
|
+
raise DashboardPerfError(f"cannot reach {url}: {exc}",
|
|
134
|
+
reason="unreachable") from None
|
|
118
135
|
try:
|
|
119
136
|
return json.loads(raw.decode("utf-8"))
|
|
120
137
|
except (ValueError, UnicodeDecodeError):
|
|
121
138
|
raise DashboardPerfError(
|
|
122
|
-
f"{url} returned a malformed response"
|
|
139
|
+
f"{url} returned a malformed response",
|
|
140
|
+
reason="malformed_response") from None
|
|
123
141
|
|
|
124
142
|
|
|
125
143
|
# ── the per-regime derivation (spec §3.3) ───────────────────────────────────
|
|
@@ -310,7 +328,7 @@ def _render_conversation_sync(tick: dict) -> list:
|
|
|
310
328
|
return lines
|
|
311
329
|
|
|
312
330
|
|
|
313
|
-
def render_dashboard_perf(payload: dict) -> str:
|
|
331
|
+
def render_dashboard_perf(payload: dict, *, display_tz=None) -> str:
|
|
314
332
|
"""The human report. Pure — takes the decoded diagnostic, returns text."""
|
|
315
333
|
tick = payload.get("tick") or {}
|
|
316
334
|
tracing = payload.get("tracing") or {}
|
|
@@ -412,6 +430,12 @@ def render_dashboard_perf(payload: dict) -> str:
|
|
|
412
430
|
f" duty {combined['combined'] * 100:.1f}% of one core · "
|
|
413
431
|
f"ceiling {combined['ceiling'] * 100:.1f}% · {verdict}")
|
|
414
432
|
lines.append("")
|
|
433
|
+
write_rows = render_write_io_rows(
|
|
434
|
+
payload.get("writeIo"), records[-1] if records else None,
|
|
435
|
+
display_tz=display_tz)
|
|
436
|
+
if write_rows:
|
|
437
|
+
lines.extend(write_rows)
|
|
438
|
+
lines.append("")
|
|
415
439
|
|
|
416
440
|
counts = tick.get("dispatch_counts") or {}
|
|
417
441
|
lines.append(
|
|
@@ -470,6 +494,100 @@ def render_dashboard_perf(payload: dict) -> str:
|
|
|
470
494
|
return "\n".join(lines) + "\n"
|
|
471
495
|
|
|
472
496
|
|
|
497
|
+
# ── process disk writes (#901 spec §5.5, §5.7) ─────────────────────────────
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _reason_words(reason) -> str:
|
|
501
|
+
return str(reason or "unavailable").replace("_", " ")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def render_write_io_rows(write_io, newest_tick=None, *,
|
|
505
|
+
display_tz=None) -> list:
|
|
506
|
+
"""The §5.7 rows, in the approved wording. Pure; an older dashboard that
|
|
507
|
+
publishes no `writeIo` renders no rows rather than a guess.
|
|
508
|
+
|
|
509
|
+
``display_tz`` is the resolved ``display.tz`` (``resolve_display_tz``:
|
|
510
|
+
a zone, or None for host-local). The excluded deletion's "at HH:MM" is
|
|
511
|
+
rendered in it (#901 Amendment 19 PR-12); the maintenance window's
|
|
512
|
+
"since 09:00 UTC" names its zone and stays UTC, as §5.7 states it."""
|
|
513
|
+
import _lib_write_budget as wb
|
|
514
|
+
|
|
515
|
+
if not isinstance(write_io, dict):
|
|
516
|
+
return []
|
|
517
|
+
lines = []
|
|
518
|
+
budget = write_io.get("budget") or {}
|
|
519
|
+
if write_io.get("status") != "ok":
|
|
520
|
+
lines.append(
|
|
521
|
+
f"Disk writes unavailable ({_reason_words(write_io.get('reason'))})")
|
|
522
|
+
elif write_io.get("bytesPerMinute") is None:
|
|
523
|
+
# PR-8: the statistic's own reason; only warming up is "no samples".
|
|
524
|
+
reason = (budget.get("reasons") or ["warming_up"])[0]
|
|
525
|
+
lines.append(f"Disk writes {wb.statistic_reason_phrase(reason)}")
|
|
526
|
+
else:
|
|
527
|
+
lines.append(
|
|
528
|
+
f"Disk writes (process) {wb.format_mib(write_io['bytesPerMinute'])}"
|
|
529
|
+
f"/min over {wb.format_window_ms(write_io.get('windowSeconds') or 0)}")
|
|
530
|
+
mean = write_io.get("meanBytesPerTick")
|
|
531
|
+
publications = write_io.get("tickCount") or 0
|
|
532
|
+
if mean is None or publications < wb.MIN_PUBLICATIONS:
|
|
533
|
+
lines.append(
|
|
534
|
+
f"Per publication no samples yet (needs {wb.MIN_PUBLICATIONS} "
|
|
535
|
+
"publications)")
|
|
536
|
+
else:
|
|
537
|
+
lines.append(f"Per publication {wb.format_mib(mean)} (mean, "
|
|
538
|
+
f"{publications} publications)")
|
|
539
|
+
if newest_tick is None:
|
|
540
|
+
lines.append("Newest tick no samples yet")
|
|
541
|
+
elif newest_tick.get("process_write_bytes") is None:
|
|
542
|
+
lines.append("Newest tick unavailable "
|
|
543
|
+
f"({_reason_words(newest_tick.get('write_status'))})")
|
|
544
|
+
else:
|
|
545
|
+
lines.append(
|
|
546
|
+
f"Newest tick {wb.format_mib(newest_tick['process_write_bytes'])}")
|
|
547
|
+
if budget:
|
|
548
|
+
lines.append(
|
|
549
|
+
f"Write budget {budget.get('verdict')} (limits "
|
|
550
|
+
f"{wb.format_mib(budget.get('bytesPerMinuteLimit') or 0)}/min, "
|
|
551
|
+
f"{wb.format_mib(budget.get('meanBytesPerTickLimit') or 0)} per "
|
|
552
|
+
"publication)")
|
|
553
|
+
excluded = write_io.get("excludedDeletions") or {}
|
|
554
|
+
operations = int(excluded.get("operations") or 0)
|
|
555
|
+
if operations == 0:
|
|
556
|
+
lines.append("Excluded deletions none")
|
|
557
|
+
else:
|
|
558
|
+
rows = int(excluded.get("rows") or 0)
|
|
559
|
+
written = int(excluded.get("bytes") or 0)
|
|
560
|
+
per_row = (f" ({wb.format_kib(written / rows)}/row)" if rows else "")
|
|
561
|
+
at = excluded.get("lastEndedAt")
|
|
562
|
+
when = ""
|
|
563
|
+
if isinstance(at, str):
|
|
564
|
+
from _lib_display_tz import format_display_dt
|
|
565
|
+
|
|
566
|
+
when = " at " + format_display_dt(
|
|
567
|
+
at, display_tz, fmt="%H:%M", suffix=False)
|
|
568
|
+
noun = "conversation" if operations == 1 else "conversations"
|
|
569
|
+
lines.append(
|
|
570
|
+
f"Excluded deletions {operations} {noun}, {rows:,} rows, "
|
|
571
|
+
f"{wb.format_mib(written)}{per_row}{when}")
|
|
572
|
+
maintenance = write_io.get("maintenance")
|
|
573
|
+
if not isinstance(maintenance, dict):
|
|
574
|
+
lines.append("Maintenance charged no ledger yet")
|
|
575
|
+
else:
|
|
576
|
+
import datetime as _dt
|
|
577
|
+
|
|
578
|
+
start = _dt.datetime.fromisoformat(
|
|
579
|
+
maintenance["windowStart"].replace("Z", "+00:00"))
|
|
580
|
+
end = _dt.datetime.fromisoformat(
|
|
581
|
+
maintenance["windowEnd"].replace("Z", "+00:00"))
|
|
582
|
+
lines.append(
|
|
583
|
+
f"Maintenance charged {wb.format_size(maintenance['chargedBytes'])} "
|
|
584
|
+
f"over {wb.format_window_hm(maintenance['windowMinutes'])} "
|
|
585
|
+
f"(since {wb.format_since(start, end)}); allowance "
|
|
586
|
+
f"{wb.format_size(maintenance['allowanceBytes'])} incl. largest "
|
|
587
|
+
f"{wb.format_size(maintenance['largestChargeBytes'])}")
|
|
588
|
+
return lines
|
|
589
|
+
|
|
590
|
+
|
|
473
591
|
# ── the command ─────────────────────────────────────────────────────────────
|
|
474
592
|
|
|
475
593
|
|
|
@@ -522,5 +640,6 @@ def cmd_dashboard_perf(args) -> int:
|
|
|
522
640
|
f"(requested={trace_result.get('requested')}, "
|
|
523
641
|
f"applied={trace_result.get('applied')}, "
|
|
524
642
|
f"applies_at={trace_result.get('applies_at')})")
|
|
525
|
-
sys.stdout.write(render_dashboard_perf(
|
|
643
|
+
sys.stdout.write(render_dashboard_perf(
|
|
644
|
+
payload, display_tz=c.resolve_display_tz(args, c.load_config())))
|
|
526
645
|
return 0
|