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.
Files changed (58) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_cache.py +1683 -485
  4. package/bin/_cctally_core.py +223 -1
  5. package/bin/_cctally_dashboard.py +96 -18
  6. package/bin/_cctally_dashboard_perf.py +128 -9
  7. package/bin/_cctally_dashboard_sources.py +267 -204
  8. package/bin/_cctally_db.py +861 -18
  9. package/bin/_cctally_diagnosis_sources.py +22 -4
  10. package/bin/_cctally_doctor.py +136 -18
  11. package/bin/_cctally_five_hour.py +2 -2
  12. package/bin/_cctally_forecast.py +27 -6
  13. package/bin/_cctally_journal.py +67 -5
  14. package/bin/_cctally_milestone_history.py +82 -9
  15. package/bin/_cctally_percent_breakdown.py +3 -2
  16. package/bin/_cctally_quota.py +201 -33
  17. package/bin/_cctally_record.py +14 -0
  18. package/bin/_cctally_rederive.py +23 -9
  19. package/bin/_cctally_source_analytics.py +60 -15
  20. package/bin/_cctally_statusline.py +17 -0
  21. package/bin/_cctally_store.py +80 -5
  22. package/bin/_cctally_tui.py +4 -3
  23. package/bin/_cctally_weekrefs.py +128 -21
  24. package/bin/_lib_cache_report.py +35 -6
  25. package/bin/_lib_codex_conversation.py +32 -0
  26. package/bin/_lib_codex_conversation_query.py +164 -30
  27. package/bin/_lib_conversation_query.py +95 -56
  28. package/bin/_lib_conversation_retention.py +1605 -640
  29. package/bin/_lib_cost_provenance.py +53 -2
  30. package/bin/_lib_doctor.py +263 -69
  31. package/bin/_lib_fmt.py +11 -0
  32. package/bin/_lib_merge_gate.py +469 -51
  33. package/bin/_lib_perf.py +27 -1
  34. package/bin/_lib_pricing.py +193 -8
  35. package/bin/_lib_pricing_check.py +20 -4
  36. package/bin/_lib_reclaim_planner.py +792 -0
  37. package/bin/_lib_rederive.py +225 -77
  38. package/bin/_lib_snapshot_cache.py +21 -0
  39. package/bin/_lib_sqlite_close.py +81 -0
  40. package/bin/_lib_subscription_weeks.py +100 -17
  41. package/bin/_lib_tick_stats.py +217 -17
  42. package/bin/_lib_wal_checkpoint.py +1077 -0
  43. package/bin/_lib_write_budget.py +483 -0
  44. package/bin/_lib_write_io.py +703 -0
  45. package/bin/cctally +8 -0
  46. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  47. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  48. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  49. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  50. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  51. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  52. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  53. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  54. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  55. package/dashboard/static/dashboard.html +2 -2
  56. package/package.json +6 -1
  57. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  58. 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=_TIMEOUT_SECONDS) as resp:
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}?") from None
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}") from None
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") from None
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(payload))
643
+ sys.stdout.write(render_dashboard_perf(
644
+ payload, display_tz=c.resolve_display_tz(args, c.load_config())))
526
645
  return 0