cctally 1.112.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 +27 -0
  2. package/README.md +4 -5
  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 +209 -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
@@ -122,7 +122,8 @@ def claude_card_rate_violations(snapshot) -> list:
122
122
  A closed amount of exactly $0 proves a missing card only while no Claude
123
123
  card prices a used token class at zero. Base rates, any present long-
124
124
  context tier, the derived 1-hour cache-write rate and any Fast multiplier
125
- must all be positive; an empty list is that invariant.
125
+ must all be positive; an empty list is that invariant. A whole-request
126
+ (prompt-length) declaration must also be complete and well formed (#929).
126
127
  """
127
128
  _lib_pricing = _pricing()
128
129
  out = []
@@ -143,23 +144,73 @@ def claude_card_rate_violations(snapshot) -> list:
143
144
  _positive_rate(multiplier) and _positive_rate(tier_input)
144
145
  ):
145
146
  out.append((model, "cache_write_1h_above_200k_tokens"))
147
+ # Not the request hot path, so the unrecognised-field scan runs here
148
+ # too: a card carrying only such a field half-declares the tier (#929).
149
+ if (_lib_pricing._is_whole_request_card(card)
150
+ or _lib_pricing._unrecognised_whole_request_fields(card)):
151
+ out.extend(_whole_request_violations(
152
+ _lib_pricing, model, card, multiplier))
146
153
  stripped = _lib_pricing._strip_anthropic_model_prefix(model)
147
154
  if stripped in fast and not _positive_rate(fast[stripped]):
148
155
  out.append((model, "fast_multiplier"))
149
156
  return out
150
157
 
151
158
 
159
+ def _whole_request_violations(_lib_pricing, model, card, multiplier) -> list:
160
+ """The whole-request (prompt-length) tier's share of the invariant (#929).
161
+
162
+ Every higher rate must be present and positive, the threshold marker must
163
+ be the integer the `_above_100k_tokens` fields name, the card must not mix
164
+ in a marginal `_above_200k_tokens` tier, the derived higher 1-hour write
165
+ rate (higher input x multiplier) must be positive, and the card must carry
166
+ no `_above_100k_tokens` field other than the four recognised ones (the
167
+ selector and `check_table_shapes` refuse such a card too).
168
+ """
169
+ out = []
170
+ for _base, tier in _lib_pricing.CLAUDE_WHOLE_REQUEST_TIER_FIELDS:
171
+ if not _positive_rate(card.get(tier)):
172
+ out.append((model, tier))
173
+ key = _lib_pricing.CLAUDE_WHOLE_REQUEST_THRESHOLD_KEY
174
+ threshold = card.get(key)
175
+ if (key not in card or isinstance(threshold, bool)
176
+ or not isinstance(threshold, int)
177
+ or threshold != _lib_pricing.CLAUDE_WHOLE_REQUEST_THRESHOLD):
178
+ out.append((model, key))
179
+ if any(k.endswith("_above_200k_tokens") for k in card):
180
+ out.append((model, "whole_request_mixed_tiers"))
181
+ if not (_positive_rate(multiplier) and _positive_rate(
182
+ card.get("input_cost_per_token_above_100k_tokens"))):
183
+ out.append((model, "cache_write_1h_above_100k_tokens"))
184
+ for field in _lib_pricing._unrecognised_whole_request_fields(card):
185
+ out.append((model, field))
186
+ return out
187
+
188
+
152
189
  def claude_entry_rates_positive(card, entry, snapshot) -> bool:
153
190
  """True iff every rate that prices one of ``entry``'s tokens is positive.
154
191
 
155
192
  Mirrors `_lib_pricing._calculate_entry_cost` term by term: each token class
156
193
  is priced below the long-context threshold at its base rate and above it at
157
194
  a present tier rate, and a known 1-hour cache-write split prices its share
158
- at the derived input-times-multiplier rate.
195
+ at the derived input-times-multiplier rate. A whole-request card (#929) is
196
+ first resolved to the card the entry's prompt selects.
159
197
  """
160
198
  if card is None:
161
199
  return False
162
200
  _lib_pricing = _pricing()
201
+ # #929: a whole-request card prices this entry at the card its prompt
202
+ # selects, so judge THAT card; every check below then runs unchanged on
203
+ # it. A malformed declaration prices nothing reliably: refuse it.
204
+ if _lib_pricing._is_whole_request_card(card):
205
+ try:
206
+ card = _lib_pricing._select_claude_request_card(
207
+ card,
208
+ input_tokens=int(entry.input_tokens or 0),
209
+ cache_creation_tokens=int(entry.cache_creation_tokens or 0),
210
+ cache_read_tokens=int(entry.cache_read_tokens or 0),
211
+ )
212
+ except ValueError:
213
+ return False
163
214
  threshold = snapshot.tier_thresholds["claude"]
164
215
 
165
216
  def tiered(tokens, base_key, tier_key):
@@ -441,6 +441,14 @@ class DoctorState:
441
441
  # known file-level backup/sync root. Only provider/status are retained so
442
442
  # reports never expose the machine-specific data path.
443
443
  backup_sync_state: Optional[dict] = None
444
+ # #901: transcript-store page size, so `db.conversations_reclaimable`
445
+ # judges the paced-reclaim thresholds on observed bytes.
446
+ conversations_db_page_size: Optional[int] = None
447
+ # #901 §5.6: running dashboards' write telemetry, gathered read-only —
448
+ # the local instance's own snapshot in the dashboard process, or
449
+ # descriptor discovery plus one bounded loopback probe per live
450
+ # instance elsewhere. {"mode", "instances": [...]}; None = not gathered.
451
+ dashboard_disk_writes: Optional[dict] = None
444
452
 
445
453
 
446
454
  @dataclasses.dataclass(frozen=True)
@@ -1011,7 +1019,7 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
1011
1019
  # current index as a mismatch; keep this in lockstep with core
1012
1020
  # (#496 S5b §6.1). It stays a literal because this kernel is pure and
1013
1021
  # must not import `_cctally_core`.
1014
- epoch = 1016
1022
+ epoch = 1017
1015
1023
  mismatch = uv > legacy_head and uv != epoch
1016
1024
  return {"user_version": uv, "legacy_head": legacy_head, "epoch": epoch,
1017
1025
  "mismatch": mismatch}
@@ -2916,92 +2924,119 @@ def _check_db_reclaimable(s: DoctorState) -> CheckResult:
2916
2924
 
2917
2925
 
2918
2926
  def _check_db_conversations_reclaimable(s: DoctorState) -> CheckResult:
2919
- """Surface transcript-store free pages without mutating the DB (#320)."""
2927
+ """Transcript free space against the paced-reclaim policy (#320, #780,
2928
+ #901). Read-only; judged on the OBSERVED freelist, never on a record.
2929
+
2930
+ OK for retained slack below the reclaim start threshold — reusable pages
2931
+ kept on purpose (operator decision Q2). WARN for an eligible backlog being
2932
+ paced, including one whose attempts are failing (`reclaim_failure`); a
2933
+ scheduled wait is not "stuck". FAIL at the hard ceiling, where transcript
2934
+ rebuilds are refused."""
2935
+ import _lib_conversation_retention as _conv_retention
2936
+
2920
2937
  page_count = s.conversations_db_page_count
2921
2938
  freelist_count = s.conversations_db_freelist_count
2922
- ratio = None
2923
- if (
2924
- isinstance(page_count, int)
2925
- and not isinstance(page_count, bool)
2926
- and page_count > 0
2927
- and isinstance(freelist_count, int)
2928
- and not isinstance(freelist_count, bool)
2939
+ page_size = s.conversations_db_page_size
2940
+ measured = (
2941
+ all(isinstance(v, int) and not isinstance(v, bool)
2942
+ for v in (page_count, freelist_count, page_size))
2943
+ and page_count > 0 and page_size > 0
2929
2944
  and 0 <= freelist_count <= page_count
2930
- ):
2931
- ratio = freelist_count / page_count
2945
+ )
2946
+ geometry = (
2947
+ _conv_retention.StoreGeometry(page_count, freelist_count, page_size)
2948
+ if measured else None)
2949
+ record = (s.conversations_reclaim_pending
2950
+ if isinstance(s.conversations_reclaim_pending, dict) else None)
2951
+ episode = bool(record and record.get("eligible") is True)
2952
+ failure = record.get("last_failure") if record else None
2953
+ if not isinstance(failure, dict):
2954
+ failure = None
2955
+ free_bytes = geometry.free_bytes if geometry is not None else None
2932
2956
  details = {
2933
2957
  "conversations_db_page_count": page_count,
2934
2958
  "conversations_db_freelist_count": freelist_count,
2935
- "conversations_db_free_ratio": ratio,
2936
- "warn_ratio": DOCTOR_RECLAIMABLE_WARN_RATIO,
2959
+ "conversations_db_page_size": page_size,
2960
+ "conversations_db_free_ratio": (
2961
+ freelist_count / page_count if geometry is not None else None),
2962
+ "reclaimable_bytes": free_bytes,
2963
+ "reclaim_start_bytes": _conv_retention.RECLAIM_START_BYTES,
2964
+ "reclaim_start_ratio": _conv_retention.RECLAIM_START_RATIO,
2965
+ "reclaim_stop_bytes": _conv_retention.RECLAIM_STOP_BYTES,
2966
+ "reclaim_stop_ratio": _conv_retention.RECLAIM_STOP_RATIO,
2967
+ "reclaim_ceiling_bytes": _conv_retention.RECLAIM_CEILING_BYTES,
2968
+ "reclaim_episode_active": episode if record is not None else None,
2969
+ "reclaim_failure": failure,
2937
2970
  }
2938
- # #780: the reclaim backlog rides on this check rather than adding a new
2939
- # check id, and it contributes NOTHING — no detail key, no severity change
2940
- # — on a store with no pending record. That is every store that has never
2941
- # fallen behind, so an install in the ordinary state renders exactly what
2942
- # it rendered before.
2943
- pending = s.conversations_reclaim_pending
2944
- if pending:
2945
- import _lib_conversation_retention as _conv_retention
2946
-
2947
- backlog = _conv_retention.reclaim_backlog_bytes(pending)
2948
- details["reclaim_pending"] = pending
2949
- details["reclaim_backlog_bytes"] = backlog
2950
- details["reclaim_ceiling_bytes"] = _conv_retention.RECLAIM_CEILING_BYTES
2951
- if _conv_retention.reclaim_backlog_over_ceiling(pending):
2952
- return CheckResult(
2953
- id="db.conversations_reclaimable",
2954
- title="Reclaimable transcript space",
2955
- severity="fail",
2956
- summary=(
2957
- f"reclaim backlog {_gib_text(backlog)} is at or over the "
2958
- f"{_gib_text(_conv_retention.RECLAIM_CEILING_BYTES)} "
2959
- "ceiling; transcript rebuilds are refused"
2960
- ),
2961
- remediation=(
2962
- "Run `cctally db vacuum --db conversations` to drain the "
2963
- "backlog, then `cctally db checkpoint --db conversations`."
2964
- ),
2965
- details=details,
2966
- )
2967
- if _conv_retention.reclaim_backlog_escalated(pending):
2968
- return CheckResult(
2969
- id="db.conversations_reclaimable",
2970
- title="Reclaimable transcript space",
2971
- severity="warn",
2972
- summary=(
2973
- f"reclaim backlog {_gib_text(backlog)} is draining across "
2974
- "continuation passes"
2975
- ),
2976
- remediation=(
2977
- "No action is required unless it keeps growing; "
2978
- "`cctally db vacuum --db conversations` drains it now."
2979
- ),
2980
- details=details,
2981
- )
2982
- if ratio is not None and ratio >= DOCTOR_RECLAIMABLE_WARN_RATIO:
2971
+ title = "Reclaimable transcript space"
2972
+ if _conv_retention.backlog_over_ceiling(free_bytes):
2983
2973
  return CheckResult(
2984
- id="db.conversations_reclaimable",
2985
- title="Reclaimable transcript space",
2986
- severity="warn",
2974
+ id="db.conversations_reclaimable", title=title, severity="fail",
2987
2975
  summary=(
2988
- f"high — {ratio * 100:.1f}% of conversations.db pages are free"
2976
+ f"reclaim backlog {_gib_text(free_bytes)} is at or over the "
2977
+ f"{_gib_text(_conv_retention.RECLAIM_CEILING_BYTES)} "
2978
+ "ceiling; transcript rebuilds are refused"
2989
2979
  ),
2990
2980
  remediation=(
2991
- "Run `cctally db vacuum --db conversations` to reclaim disk space."
2981
+ "Run `cctally db vacuum --db conversations` to drain the "
2982
+ "backlog, then `cctally db checkpoint --db conversations`."
2992
2983
  ),
2993
2984
  details=details,
2994
2985
  )
2986
+ if _conv_retention.reclaim_episode_active(geometry, episode):
2987
+ # Q9: a paced wait is not a refused attempt. A planner refusal is an
2988
+ # in-memory pass record of the running dashboard (it writes nothing
2989
+ # durable), read from `writeIo.reclaimRefusal`; a connection-settings
2990
+ # or SQLite failure stays the durable `reclaim_failure`, as before.
2991
+ refusal = _latest_reclaim_refusal(s.dashboard_disk_writes)
2992
+ wait = ("refused" if refusal is not None
2993
+ else "failing" if failure is not None else "paced")
2994
+ remediation = (
2995
+ "No action is required unless it keeps growing; "
2996
+ "`cctally db vacuum --db conversations` drains it now.")
2997
+ if refusal is not None:
2998
+ remediation = (
2999
+ "Reclaim attempts are being refused "
3000
+ f"({refusal['reason'].replace('_', ' ')}); "
3001
+ "`cctally db vacuum --db conversations` reclaims the space "
3002
+ "at once.")
3003
+ return CheckResult(
3004
+ id="db.conversations_reclaimable", title=title, severity="warn",
3005
+ summary=(
3006
+ f"Reclaiming {_gib_text(free_bytes)} of free transcript "
3007
+ "space at a paced rate."
3008
+ ),
3009
+ remediation=remediation,
3010
+ details={**details, "reclaim_wait": wait,
3011
+ "reclaim_refusal": refusal},
3012
+ )
2995
3013
  return CheckResult(
2996
- id="db.conversations_reclaimable",
2997
- title="Reclaimable transcript space",
2998
- severity="ok",
2999
- summary="below threshold",
3000
- remediation=None,
3001
- details=details,
3014
+ id="db.conversations_reclaimable", title=title, severity="ok",
3015
+ summary="below threshold", remediation=None, details=details,
3002
3016
  )
3003
3017
 
3004
3018
 
3019
+ def _latest_reclaim_refusal(gathered) -> "dict | None":
3020
+ """The newest planner refusal any running dashboard reports in its
3021
+ `writeIo.reclaimRefusal`, with that dashboard's port; None when none
3022
+ does (or no dashboard runs)."""
3023
+ latest = None
3024
+ entries = gathered.get("instances") if isinstance(gathered, dict) else None
3025
+ for entry in entries or ():
3026
+ if not isinstance(entry, dict) or entry.get("probe") != "ok":
3027
+ continue
3028
+ refusal = (entry.get("write_io") or {}).get("reclaimRefusal")
3029
+ if (not isinstance(refusal, dict)
3030
+ or not isinstance(refusal.get("reason"), str)
3031
+ or not isinstance(refusal.get("at"), str)):
3032
+ continue
3033
+ candidate = {"reason": refusal["reason"], "at": refusal["at"],
3034
+ "port": entry.get("port")}
3035
+ if latest is None or candidate["at"] > latest["at"]:
3036
+ latest = candidate
3037
+ return latest
3038
+
3039
+
3005
3040
  #: A stuck reclaim entry has persisted past this, so no pass will clear it.
3006
3041
  _RECLAIM_STUCK_AFTER_SECONDS = 24 * 3600
3007
3042
 
@@ -4149,6 +4184,12 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
4149
4184
  ("telemetry", "Telemetry", (
4150
4185
  ("telemetry.state", "_check_telemetry"),
4151
4186
  )),
4187
+ # #901 §5.6. Never FAILs, so `doctor`'s exit code is unaffected. Last, so
4188
+ # every existing check keeps its order.
4189
+ ("performance", "Performance", (
4190
+ ("performance.dashboard_disk_writes",
4191
+ "_check_performance_dashboard_disk_writes"),
4192
+ )),
4152
4193
  )
4153
4194
 
4154
4195
 
@@ -4322,6 +4363,159 @@ def _check_quota_meter_drift(s: DoctorState) -> CheckResult:
4322
4363
  )
4323
4364
 
4324
4365
 
4366
+ _DISK_WRITES_ID = "performance.dashboard_disk_writes"
4367
+ _DISK_WRITES_TITLE = "Dashboard disk writes"
4368
+ _DISK_WRITES_REMEDIATION = (
4369
+ "Run `cctally dashboard-perf` for the breakdown, and report it if it "
4370
+ "persists."
4371
+ )
4372
+
4373
+
4374
+ def _words(reason) -> str:
4375
+ return str(reason or "unavailable").replace("_", " ")
4376
+
4377
+
4378
+ #: PR-9: probe failures doctor cannot overcome from here (it sends no token
4379
+ #: and probes over loopback only), each with its sentence fragment.
4380
+ _NOT_MEASURABLE_FROM_HERE = {
4381
+ "authentication_required": "needs its access token",
4382
+ "not_loopback_reachable": "is not reachable over loopback",
4383
+ }
4384
+
4385
+
4386
+ def _check_performance_dashboard_disk_writes(s: DoctorState) -> CheckResult:
4387
+ """Running dashboards' disk writes against the write budget, and
4388
+ transcript maintenance against its allowance (#901 §5.6, §5.7).
4389
+
4390
+ WARN when a measured running dashboard is over budget, when a live
4391
+ instance cannot supply valid telemetry, or when the durable maintenance
4392
+ ledger is over its allowance (so that leg needs no running dashboard).
4393
+ OK otherwise: none running, within budget, a statistic that has no
4394
+ verdict yet (named by its reason), a dashboard doctor cannot measure from
4395
+ here (a token-mode or non-loopback bind), or a platform with no counter.
4396
+ Never FAIL."""
4397
+ import _lib_write_budget as wb
4398
+
4399
+ gathered = (s.dashboard_disk_writes
4400
+ if isinstance(s.dashboard_disk_writes, dict)
4401
+ else {"mode": "error", "instances": []})
4402
+ record = (s.conversations_reclaim_pending
4403
+ if isinstance(s.conversations_reclaim_pending, dict) else {})
4404
+ maintenance = wb.maintenance_statistic(record.get("ledger"), s.now_utc)
4405
+ rows, over, cannot, within, warming, platform = [], [], [], [], [], []
4406
+ unmeasured = []
4407
+ for entry in gathered.get("instances") or ():
4408
+ if not isinstance(entry, dict):
4409
+ continue
4410
+ port = entry.get("port")
4411
+ write_io = entry.get("write_io") if entry.get("probe") == "ok" else None
4412
+ budget = (write_io or {}).get("budget") or {}
4413
+ if write_io is None:
4414
+ verdict, reason = "unavailable", entry.get("reason") or "unreachable"
4415
+ if reason in _NOT_MEASURABLE_FROM_HERE:
4416
+ # PR-9: doctor sends no token and probes over loopback only,
4417
+ # so these never answer it: a note, not a warning.
4418
+ unmeasured.append((port, reason))
4419
+ else:
4420
+ cannot.append((port, reason))
4421
+ elif write_io.get("status") != "ok":
4422
+ verdict = "unavailable"
4423
+ reason = write_io.get("reason") or "counter_error"
4424
+ if reason == "unsupported_platform":
4425
+ platform.append((port, reason))
4426
+ else:
4427
+ cannot.append((port, reason))
4428
+ else:
4429
+ verdict, reason = budget.get("verdict"), None
4430
+ if verdict == "over":
4431
+ over.append((port, write_io, budget))
4432
+ elif verdict == "ok":
4433
+ within.append((port, write_io))
4434
+ else:
4435
+ verdict = "insufficient"
4436
+ reason = (budget.get("reasons") or ["warming_up"])[0]
4437
+ warming.append((port, reason))
4438
+ rows.append({
4439
+ "instance_id": entry.get("instance_id"),
4440
+ "port": port,
4441
+ "pid": entry.get("pid"),
4442
+ "verdict": verdict,
4443
+ "reason": reason,
4444
+ "bytes_per_minute": (write_io or {}).get("bytesPerMinute"),
4445
+ "mean_bytes_per_publication": (write_io or {}).get(
4446
+ "meanBytesPerTick"),
4447
+ "bytes_per_minute_limit": budget.get("bytesPerMinuteLimit"),
4448
+ "mean_bytes_per_publication_limit": budget.get(
4449
+ "meanBytesPerTickLimit"),
4450
+ })
4451
+ rows.sort(key=lambda row: (row["port"] is None, row["port"] or 0))
4452
+ details = {
4453
+ "policy_version": wb.POLICY_VERSION,
4454
+ "mode": gathered.get("mode"),
4455
+ "instances": rows,
4456
+ "maintenance": maintenance.as_details(),
4457
+ }
4458
+ warn = []
4459
+ for port, write_io, budget in sorted(over, key=lambda item: item[0] or 0):
4460
+ reasons = budget.get("reasons") or []
4461
+ if ("publication_over_limit" in reasons
4462
+ and "rate_over_limit" not in reasons):
4463
+ warn.append(
4464
+ f"The running dashboard (port {port}) is writing "
4465
+ f"{wb.format_mib(write_io.get('meanBytesPerTick') or 0)} per "
4466
+ f"publication, over its "
4467
+ f"{wb.format_mib(budget.get('meanBytesPerTickLimit') or 0)} "
4468
+ f"per publication budget.")
4469
+ else:
4470
+ warn.append(
4471
+ f"The running dashboard (port {port}) is writing "
4472
+ f"{wb.format_mib(write_io.get('bytesPerMinute') or 0)}/min, "
4473
+ f"over its "
4474
+ f"{wb.format_mib(budget.get('bytesPerMinuteLimit') or 0)}/min "
4475
+ f"budget.")
4476
+ for port, reason in sorted(cannot, key=lambda item: item[0] or 0):
4477
+ warn.append(f"The running dashboard (port {port}) cannot report its "
4478
+ f"disk writes ({_words(reason)}).")
4479
+ if maintenance.verdict == "over":
4480
+ warn.append(
4481
+ f"Transcript maintenance was charged "
4482
+ f"{wb.format_size(maintenance.charged_bytes)} over the last "
4483
+ f"{wb.format_window_hm(maintenance.window_minutes)}, over its "
4484
+ f"{wb.format_size(maintenance.allowance_bytes)} allowance.")
4485
+ if warn:
4486
+ return CheckResult(
4487
+ id=_DISK_WRITES_ID, title=_DISK_WRITES_TITLE, severity="warn",
4488
+ summary=" ".join(warn), remediation=_DISK_WRITES_REMEDIATION,
4489
+ details=details)
4490
+ if within:
4491
+ port, write_io = sorted(within, key=lambda item: item[0] or 0)[0]
4492
+ summary = (f"Within budget: "
4493
+ f"{wb.format_mib(write_io.get('bytesPerMinute') or 0)}/min "
4494
+ f"over the last 5 minutes.")
4495
+ elif warming:
4496
+ # PR-8: the first instance's own reason; only `warming_up` is
4497
+ # "no samples yet".
4498
+ _port, reason = sorted(warming, key=lambda item: item[0] or 0)[0]
4499
+ summary = f"Disk writes: {wb.statistic_reason_phrase(reason)}."
4500
+ elif unmeasured:
4501
+ port, reason = sorted(unmeasured, key=lambda item: item[0] or 0)[0]
4502
+ summary = (f"Disk writes: the dashboard on port {port} "
4503
+ f"{_NOT_MEASURABLE_FROM_HERE[reason]}, so it is not "
4504
+ "measured from here.")
4505
+ elif platform:
4506
+ summary = f"Disk writes: unavailable ({_words(platform[0][1])})."
4507
+ elif gathered.get("mode") == "error":
4508
+ summary = "Disk writes: unavailable (gather failed)."
4509
+ elif gathered.get("mode") == "not_probed":
4510
+ summary = ("Disk writes: not measured here; run `cctally doctor` to "
4511
+ "check running dashboards.")
4512
+ else:
4513
+ summary = "No dashboard is running."
4514
+ return CheckResult(
4515
+ id=_DISK_WRITES_ID, title=_DISK_WRITES_TITLE, severity="ok",
4516
+ summary=summary, remediation=None, details=details)
4517
+
4518
+
4325
4519
  def _evaluate_one(check_id: str, check_fn_name: str,
4326
4520
  state: DoctorState) -> CheckResult:
4327
4521
  """Invoke a single check evaluator by name, catching any exception so
package/bin/_lib_fmt.py CHANGED
@@ -292,6 +292,17 @@ def _fmt_num(n: int) -> str:
292
292
  return f"{n:,}"
293
293
 
294
294
 
295
+ def _fmt_usd_accounting(value: float, places: int) -> str:
296
+ """`-$71.21`: the sign goes before the currency symbol (#886).
297
+
298
+ The CLI convention shared with `_lib_diff_kernel` and `_lib_share`'s
299
+ MoneyCell: an ASCII hyphen, no `+` for a plain amount, and a sign only
300
+ when `value < 0` — so a negative zero prints unsigned.
301
+ """
302
+ sign = "-" if value < 0 else ""
303
+ return f"{sign}${abs(value):.{places}f}"
304
+
305
+
295
306
  def _truncate_num(formatted: str, width: int) -> str:
296
307
  """Truncate a formatted number to fit width, replacing tail with '…'."""
297
308
  if len(formatted) <= width: