cctally 1.99.0 → 1.100.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.
@@ -906,6 +906,7 @@ def _sync_failure_envelope(
906
906
 
907
907
  def attributed(
908
908
  database: str, *, corruption: bool = False, leg: str | None = None,
909
+ sqlite_busy: bool = False,
909
910
  ) -> bool:
910
911
  for item in attributions or ():
911
912
  item_database = (
@@ -920,9 +921,15 @@ def _sync_failure_envelope(
920
921
  item.get("leg") if isinstance(item, dict)
921
922
  else getattr(item, "leg", None)
922
923
  )
924
+ item_busy = (
925
+ item.get("sqlite_busy") if isinstance(item, dict)
926
+ else getattr(item, "sqlite_busy", False)
927
+ )
923
928
  if item_database == database and (
924
929
  not corruption or bool(item_corruption)
925
- ) and (leg is None or item_leg == leg):
930
+ ) and (leg is None or item_leg == leg) and (
931
+ not sqlite_busy or bool(item_busy)
932
+ ):
926
933
  return True
927
934
  return False
928
935
 
@@ -977,6 +984,34 @@ def _sync_failure_envelope(
977
984
  "action": None,
978
985
  }
979
986
 
987
+ # #583 S2 §7. A locked cache.db is named rather than left in the generic
988
+ # bucket. Typed attribution only — a blanket uncorrupted-cache branch
989
+ # would be too broad, because ANY exception raised while reading cache.db
990
+ # produces that attribution and every one would then be told to
991
+ # checkpoint. `database="stats_or_cache"` deliberately does NOT reach here:
992
+ # that value means ownership was not established, and a guess would
993
+ # produce a confidently wrong remedy.
994
+ # Corruption outranks busy, and the gate is required rather than implied by
995
+ # branch order: `attributed(...)` scans the WHOLE attribution list, so
996
+ # without it a tick carrying a corruption-shaped cache attribution AND a
997
+ # separate busy one rendered "cache database busy" and dropped the
998
+ # corruption message — the more urgent of the two, whose remedy is not
999
+ # interchangeable with a checkpoint. The gate names the TYPED corruption
1000
+ # attribution only: a busy attribution still outranks the legacy raw-text
1001
+ # corruption legs below, which is what Preserve 10 asks for.
1002
+ if attributed("cache", sqlite_busy=True) and not attributed(
1003
+ "cache", corruption=True
1004
+ ):
1005
+ return {
1006
+ "kind": "cache_busy",
1007
+ "label": "⚠ cache database busy",
1008
+ "detail": (
1009
+ "The dashboard could not complete sync because cache.db "
1010
+ "stayed locked."
1011
+ ),
1012
+ "action": "cctally db checkpoint",
1013
+ }
1014
+
980
1015
  text = error.casefold()
981
1016
  if (
982
1017
  "stale maintenance marker" in text
@@ -1018,6 +1053,43 @@ def _sync_failure_envelope(
1018
1053
  }
1019
1054
 
1020
1055
 
1056
+ def _sync_activity_envelope(activity: "dict | None") -> dict:
1057
+ """Serialize `_SnapshotRef`'s queue/activity state (#583 S2 spec 6.2).
1058
+
1059
+ Always returns a complete object: a snapshot built before the reference
1060
+ stamped one carries ``None``, and an absent object must read as idle
1061
+ rather than as a missing key the client has to special-case.
1062
+
1063
+ ``server_epoch`` is a fixed-length, per-process token with no data content.
1064
+ It is excluded from `bin/cctally-snapshot-measure`'s stable digest and
1065
+ sentinelized by `bin/cctally-dashboard-test`, the same treatment
1066
+ ``data_version`` and ``doctor`` already get. Everything else here is real
1067
+ published content and stays in the digest.
1068
+ """
1069
+ act = activity or {}
1070
+ return {
1071
+ "server_epoch": act.get("server_epoch") or "",
1072
+ "rebuilding": bool(act.get("rebuilding", False)),
1073
+ "requested_id": int(act.get("requested_id", 0)),
1074
+ "started_id": int(act.get("started_id", 0)),
1075
+ "settled_id": int(act.get("settled_id", 0)),
1076
+ "settled_status": act.get("settled_status"),
1077
+ # Tuples are not JSON. Convert here rather than relying on the encoder
1078
+ # rendering one as an array by luck. The isinstance filter is a
1079
+ # fail-open guard: `dict(w)` raises on a non-mapping, and this
1080
+ # serializer runs on every published frame, so one malformed warning
1081
+ # would take down the whole envelope rather than drop one entry. The
1082
+ # test is `Mapping`, not `dict`, because the thing that raises is a
1083
+ # NON-mapping; a mapping that is merely not a `dict` converts fine, and
1084
+ # rejecting it would silently drop a good warning — and this field is
1085
+ # the only place a queued request's deferred outcome is reported.
1086
+ "settled_warnings": [
1087
+ dict(w) for w in (act.get("settled_warnings") or ())
1088
+ if isinstance(w, Mapping)
1089
+ ],
1090
+ }
1091
+
1092
+
1021
1093
  def snapshot_to_envelope(snap: "DataSnapshot", *,
1022
1094
  now_utc: "dt.datetime",
1023
1095
  monotonic_now: "float | None" = None,
@@ -1588,6 +1660,14 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1588
1660
  # assembled); False on every complete/stable snapshot. ``getattr``
1589
1661
  # default keeps positionally-constructed fixture snapshots serializing.
1590
1662
  "hydrating": bool(getattr(snap, "hydrating", False)),
1663
+ # #583 S2: queue / activity state, owned by `_SnapshotRef`. Published
1664
+ # unconditionally so a client can always read it; an absent or empty
1665
+ # dict renders as the idle object. Monotonic identifiers rather than a
1666
+ # boolean, because `SSEHub.publish` is latest-wins and the frame that
1667
+ # would prove a particular request finished may never be delivered.
1668
+ "sync_activity": _sync_activity_envelope(
1669
+ getattr(snap, "sync_activity", None)
1670
+ ),
1591
1671
  "generated_at": _iso_z(snap.generated_at),
1592
1672
  # #300: the all-inputs data-version string at build time (changes iff any
1593
1673
  # DB leg the detail endpoints read changed; flat on an idle tick). The
@@ -1858,9 +1938,18 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1858
1938
 
1859
1939
 
1860
1940
  def _claude_source_session_rows(envelope: dict) -> list:
1861
- """The SOURCE-scoped Claude session row lists, both of the places the All
1862
- tab may read them from (``sourceRows.ts::collectSourceSessionRows`` prefers
1863
- the nested provider payload and falls back to the sibling source entry)."""
1941
+ """The SOURCE-scoped Claude session row lists the overlay must cover.
1942
+
1943
+ Since source schema 10 (#583 S3 §4) there is exactly ONE such list, on the
1944
+ physical ``sources.claude`` entry. ``sources.all.data.providers.claude`` is
1945
+ published null, so the mirror candidate below is a no-op that the
1946
+ ``isinstance(data, Mapping)`` guard drops; it is retained because
1947
+ ``sourceRows.ts::collectSourceSessionRows`` still reads
1948
+ ``providers?.claude ?? env.sources.claude.data``, and a tab still running a
1949
+ version 9 bundle after an in-place ``execvp`` update would otherwise get an
1950
+ ungated row list. Removing the null stub and this candidate together is
1951
+ filed as a residual.
1952
+ """
1864
1953
  sources = envelope.get("sources")
1865
1954
  if not isinstance(sources, Mapping):
1866
1955
  return []
@@ -1930,6 +2019,11 @@ def _codex_source_session_rows(envelope: dict) -> list:
1930
2019
  carries one ``account_scopes[*].sessions`` child per account. The client
1931
2020
  swaps that child into view when an account chip is focused, so the private
1932
2021
  label overlay must cover it under the same per-request transcript gate.
2022
+
2023
+ Since source schema 10 (#583 S3 §4) those lists all live on the physical
2024
+ ``sources.codex`` entry. ``sources.all.data.providers.codex`` is published
2025
+ null, so the mirror candidate below is a no-op the ``isinstance`` guard
2026
+ drops; it is retained for the same version 9 reason as the Claude twin.
1933
2027
  """
1934
2028
  sources = envelope.get("sources")
1935
2029
  if not isinstance(sources, Mapping):
@@ -0,0 +1,433 @@
1
+ """`cctally dashboard-perf` — read a running dashboard's tick cost (#583 S1 §3.3).
2
+
3
+ Reads the loopback diagnostic `/api/debug/backend` and renders the three
4
+ things an operator on a slow install needs: the publish period stated
5
+ SEPARATELY for the Codex-active and Codex-idle regimes, the tick-cost
6
+ breakdown with the ingest and builder halves named, and the dispatch mix. It
7
+ also arms and disarms the deep phase trace on the running process, so one
8
+ command covers both halves of F38 and neither answer needs a restart.
9
+
10
+ The command reaches only an IP-literal loopback host, and it is DELIBERATELY
11
+ STRICTER than the server about that. `_lib_transcript_access.is_loopback`
12
+ treats `localhost` and `::1` as loopback names before it tries to parse an IP
13
+ literal, so the endpoint serves `Host: localhost:8789` with 200. This command
14
+ still refuses a name: a name is resolved by the operating system, the client
15
+ cannot verify that the resolver's answer is this machine, and an unambiguous
16
+ target is worth more than the convenience. A hostname that is not a loopback
17
+ name is refused by the server too.
18
+
19
+ It also sends an `Origin` header matching the `Host` it calls, because
20
+ `_check_origin_csrf` rejects a request that carries none. Retaining that check
21
+ is deliberate — the loopback and anti-rebinding gates do not stop a malicious
22
+ page aiming a form POST at `http://127.0.0.1:8789` — and a non-browser client
23
+ can set arbitrary headers regardless, so satisfying it costs nothing.
24
+
25
+ Exit codes follow `docs/cli-contract.md`: 0 for a decoded HTTP 200, 2 for
26
+ argument validation including a non-loopback target, and 3 for connection,
27
+ HTTP, authentication, timeout or malformed-response failures — "no dashboard
28
+ is running" among them.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import ipaddress
33
+ import json
34
+ import sys
35
+ import urllib.error
36
+ import urllib.request
37
+
38
+ from _lib_dashboard_json import encode_dashboard_json, encode_dashboard_json_bytes
39
+
40
+ _TIMEOUT_SECONDS = 5.0
41
+ _DIAGNOSTIC_PATH = "/api/debug/backend"
42
+ _TRACE_PATH = "/api/debug/backend/trace"
43
+
44
+ #: The two regimes the period is reported for. `not_observed` is discarded:
45
+ #: a tick no build's Codex decision reached says nothing about either cost
46
+ #: regime, and folding it into one of them would misreport that regime.
47
+ _REPORTED_REGIMES = ("active", "idle")
48
+
49
+ _REGIME_LABELS = {"active": "Codex-active", "idle": "Codex-idle"}
50
+
51
+
52
+ def _cctally():
53
+ return sys.modules["cctally"]
54
+
55
+
56
+ class DashboardPerfError(Exception):
57
+ """A staged failure: exit 3. Carries the message the user sees."""
58
+
59
+
60
+ def resolve_loopback_target(host: str) -> str:
61
+ """Return `host` when it is an IP-literal loopback address, else raise.
62
+
63
+ A hostname is rejected even when it resolves to loopback, because the
64
+ endpoint's own anti-rebinding gate requires an IP-literal `Host`.
65
+ """
66
+ try:
67
+ address = ipaddress.ip_address(host)
68
+ except ValueError:
69
+ raise ValueError(
70
+ f"--host must be an IP literal such as 127.0.0.1 or ::1 (got "
71
+ f"{host!r}); a hostname is refused by the endpoint's "
72
+ f"anti-rebinding gate"
73
+ ) from None
74
+ if not address.is_loopback:
75
+ raise ValueError(
76
+ f"--host must be a loopback address (got {host!r}); "
77
+ f"dashboard-perf never contacts a LAN address"
78
+ )
79
+ return host
80
+
81
+
82
+ def _authority(host: str, port: int) -> str:
83
+ if ":" in host: # an IPv6 literal needs brackets
84
+ return f"[{host}]:{port}"
85
+ return f"{host}:{port}"
86
+
87
+
88
+ def _request(host, port, path, *, token, body=None):
89
+ """One loopback HTTP round trip. Raises DashboardPerfError on any failure."""
90
+ authority = _authority(host, port)
91
+ url = f"http://{authority}{path}"
92
+ data = None
93
+ headers = {"Origin": f"http://{authority}", "Accept": "application/json"}
94
+ if body is not None:
95
+ data = encode_dashboard_json_bytes(body)
96
+ headers["Content-Type"] = "application/json"
97
+ if token:
98
+ headers["Authorization"] = f"Bearer {token}"
99
+ request = urllib.request.Request(url, data=data, headers=headers,
100
+ method="POST" if data else "GET")
101
+ try:
102
+ with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as resp:
103
+ raw = resp.read()
104
+ except urllib.error.HTTPError as exc:
105
+ detail = {401: "authentication required — pass --token",
106
+ 403: "refused by the loopback gate",
107
+ 404: "this dashboard predates dashboard-perf"}.get(
108
+ exc.code, "unexpected response")
109
+ raise DashboardPerfError(
110
+ f"{url} answered HTTP {exc.code}: {detail}") from None
111
+ except urllib.error.URLError as exc:
112
+ raise DashboardPerfError(
113
+ f"cannot reach {url}: {exc.reason}. Is a dashboard running on "
114
+ f"port {port}?") from None
115
+ except OSError as exc:
116
+ raise DashboardPerfError(f"cannot reach {url}: {exc}") from None
117
+ try:
118
+ return json.loads(raw.decode("utf-8"))
119
+ except (ValueError, UnicodeDecodeError):
120
+ raise DashboardPerfError(
121
+ f"{url} returned a malformed response") from None
122
+
123
+
124
+ # ── the per-regime derivation (spec §3.3) ───────────────────────────────────
125
+
126
+
127
+ def summarise_regime_periods(records) -> dict:
128
+ """Partition the ring by `codex_regime` and describe each reported regime.
129
+
130
+ Discards `not_observed` and every record whose `period_ns` is null — the
131
+ first publish of a process has no predecessor, so it measures no period.
132
+
133
+ Reports the MEDIAN rather than the mean, because one startup or recovery
134
+ outlier otherwise dominates a 64-sample window; the observed range is
135
+ reported beside it so the outlier is still visible.
136
+ """
137
+ # Imported HERE, not at module scope. `bin/cctally` re-exports this
138
+ # module eagerly, so a top-level import is paid by `statusline` on every
139
+ # Claude Code prompt and by `hook-tick` on every tool batch — measured at
140
+ # about 2.6 ms on `cctally --version` — for one `median` call in a command
141
+ # neither of them runs. The other importers in this tree
142
+ # (`_cctally_forecast.py`, `_lib_cache_report.py`) do the same.
143
+ import statistics
144
+
145
+ buckets = {regime: [] for regime in _REPORTED_REGIMES}
146
+ for record in records or ():
147
+ regime = record.get("codex_regime")
148
+ period = record.get("period_ns")
149
+ if regime in buckets and period is not None:
150
+ buckets[regime].append(int(period))
151
+ summary = {}
152
+ for regime, samples in buckets.items():
153
+ if not samples:
154
+ summary[regime] = {"samples": 0, "median_ns": None,
155
+ "min_ns": None, "max_ns": None}
156
+ continue
157
+ summary[regime] = {
158
+ "samples": len(samples),
159
+ "median_ns": int(statistics.median(samples)),
160
+ "min_ns": min(samples),
161
+ "max_ns": max(samples),
162
+ }
163
+ return summary
164
+
165
+
166
+ def summarise_all_periods(records) -> dict:
167
+ """The publish period over EVERY tick, whatever its Codex regime.
168
+
169
+ The two regime rows refine this; they do not gate it. A dispatch-`idle`
170
+ tick returns before `_tui_build_source_bundle`, so no build reaches the
171
+ Codex decision and the tick is stamped `not_observed`, which the regime
172
+ partition discards — correctly, because a tick that observed no decision
173
+ says nothing about either cost regime. On a mostly-idle install that left
174
+ the flagship figure reading `no samples yet` on both rows while every
175
+ record after the first carried a correct `period_ns`, so the operator
176
+ learned nothing about an install whose period was perfectly well measured.
177
+ """
178
+ samples = [int(r["period_ns"]) for r in records or ()
179
+ if r.get("period_ns") is not None]
180
+ if not samples:
181
+ return {"samples": 0, "median_ns": None, "min_ns": None,
182
+ "max_ns": None}
183
+ import statistics
184
+
185
+ return {
186
+ "samples": len(samples),
187
+ "median_ns": int(statistics.median(samples)),
188
+ "min_ns": min(samples),
189
+ "max_ns": max(samples),
190
+ }
191
+
192
+
193
+ def _seconds(ns) -> str:
194
+ return "—" if ns is None else f"{ns / 1_000_000_000:.2f}s"
195
+
196
+
197
+ def _millis(ns) -> str:
198
+ return "—" if ns is None else f"{ns / 1_000_000:.0f}ms"
199
+
200
+
201
+ def _mean_or_none(values):
202
+ return int(sum(values) / len(values)) if values else None
203
+
204
+
205
+ def _median(values):
206
+ import statistics
207
+
208
+ return int(statistics.median(values))
209
+
210
+
211
+ def _render_conversation_sync(tick: dict) -> list:
212
+ """The second work loop's cost, beside the first's (#583 S4 / F5).
213
+
214
+ The share is `sum(cpu_ns) / sum(period_ns)` over the passes that carry a
215
+ forward period — a same-process ratio, so it is a measurement rather than a
216
+ machine-speed assertion. `period_ns` is `start[i+1] - start[i]`, so the
217
+ NEWEST retained pass has none: its successor has not started yet. That pass
218
+ contributes to neither sum, which is what keeps numerator and denominator
219
+ spanning the same window. Over the passes that do pair, the periods
220
+ telescope to `start[last] - start[first]` and each pass's CPU falls inside
221
+ its own period, so the ratio is the loop's true duty over that span.
222
+
223
+ Rows are read defensively. A running dashboard older or newer than this
224
+ reader can publish a different field set, and a diagnostic must degrade
225
+ rather than take itself down. A period of zero or less is dropped together
226
+ with its CPU: it cannot be a real interval, and a negative one would
227
+ subtract from the denominator.
228
+ """
229
+ rows = tick.get("conversation_sync") or []
230
+ lines = ["", "Conversation sync loop"]
231
+ if not rows:
232
+ # The same literal the regime rows use. A loop with no samples must
233
+ # never render as a zero, which cannot be told apart from a measured
234
+ # idle loop. Under `--no-sync` the thread never starts, so this is that
235
+ # mode's correct and permanent reading.
236
+ lines.append(f" {'passes':<14} no samples yet")
237
+ return lines
238
+ durations = [int(r.get("duration_ns") or 0) for r in rows]
239
+ cpus = [int(r.get("cpu_ns") or 0) for r in rows]
240
+ paired = []
241
+ for record in rows:
242
+ cpu = record.get("cpu_ns")
243
+ period = record.get("period_ns")
244
+ if cpu is None or period is None or int(period) <= 0:
245
+ continue
246
+ paired.append((int(cpu), int(period)))
247
+ lines.append(
248
+ f" {'wall':<14} mean {_millis(_mean_or_none(durations))} "
249
+ f"over {len(rows)} pass(es)")
250
+ lines.append(f" {'thread cpu':<14} mean {_millis(_mean_or_none(cpus))}")
251
+ if paired:
252
+ periods = [p for _, p in paired]
253
+ lines.append(
254
+ f" {'period':<14} median {_seconds(_median(periods))} "
255
+ f"(range {_seconds(min(periods))}–{_seconds(max(periods))})")
256
+ share = sum(c for c, _ in paired) / sum(periods)
257
+ lines.append(f" {'cpu share':<14} {share * 100:.1f}% of one core")
258
+ else:
259
+ lines.append(f" {'period':<14} no samples yet")
260
+ statuses: dict = {}
261
+ for record in rows:
262
+ raw = record.get("status")
263
+ # A row with no status is malformed, not an outcome named `None`.
264
+ key = raw if isinstance(raw, str) and raw else "malformed"
265
+ statuses[key] = statuses.get(key, 0) + 1
266
+ detail = " · ".join(f"{k} {v}" for k, v in sorted(statuses.items()))
267
+ lines.append(f" {'status':<14} {detail}")
268
+ return lines
269
+
270
+
271
+ def render_dashboard_perf(payload: dict) -> str:
272
+ """The human report. Pure — takes the decoded diagnostic, returns text."""
273
+ tick = payload.get("tick") or {}
274
+ tracing = payload.get("tracing") or {}
275
+ records = tick.get("records") or []
276
+ lines = ["cctally dashboard-perf", ""]
277
+
278
+ lines.append("Publish period")
279
+ overall = summarise_all_periods(records)
280
+ if overall["samples"] == 0:
281
+ lines.append(f" {'all ticks':<14} no samples yet (0 of "
282
+ f"{len(records)} retained ticks qualify)")
283
+ else:
284
+ lines.append(
285
+ f" {'all ticks':<14} median {_seconds(overall['median_ns'])} "
286
+ f"over {overall['samples']} sample(s), "
287
+ f"range {_seconds(overall['min_ns'])}–"
288
+ f"{_seconds(overall['max_ns'])}")
289
+ summary = summarise_regime_periods(records)
290
+ for regime in _REPORTED_REGIMES:
291
+ stats = summary[regime]
292
+ label = _REGIME_LABELS[regime]
293
+ if stats["samples"] == 0:
294
+ # Stated, never inferred. A zero or a dash cannot distinguish
295
+ # "measured and fast" from "not measured", and telling those two
296
+ # apart is the whole point of this surface.
297
+ lines.append(f" {label:<14} no samples yet (0 of {len(records)} "
298
+ f"retained ticks qualify)")
299
+ continue
300
+ lines.append(
301
+ f" {label:<14} median {_seconds(stats['median_ns'])} "
302
+ f"over {stats['samples']} sample(s), "
303
+ f"range {_seconds(stats['min_ns'])}–{_seconds(stats['max_ns'])}")
304
+ lines.append("")
305
+
306
+ lines.append("Tick cost (exclusive halves; the remainder is orchestration)")
307
+ if records:
308
+ ingest = [r["ingest_ns"] for r in records if r.get("ingest_ran")]
309
+ builder = [r.get("builder_ns", 0) for r in records]
310
+ duration = [r.get("duration_ns", 0) for r in records]
311
+ lines.append(
312
+ f" ingest mean {_millis(_mean_or_none(ingest))} "
313
+ f"over {len(ingest)} tick(s) that ingested")
314
+ lines.append(
315
+ f" builder mean {_millis(_mean_or_none(builder))} "
316
+ f"over {len(builder)} tick(s)")
317
+ lines.append(
318
+ f" whole tick mean {_millis(_mean_or_none(duration))}")
319
+ # #583 S5 §2.4: the cache.db read pin, measured at its own BEGIN and
320
+ # ROLLBACK boundaries. Reported separately from `builder` because it
321
+ # is a SUBSET of builder time rather than a third exclusive half, and
322
+ # `.get(..., 0)` keeps an older record without the field readable.
323
+ pin = [r.get("cache_pin_ns", 0) or 0 for r in records]
324
+ lines.append(
325
+ f" cache pin mean {_millis(_mean_or_none(pin))} "
326
+ f"held inside builder")
327
+ newest = records[-1]
328
+ lines.append(
329
+ f" newest tick seq {newest.get('seq')} "
330
+ f"{newest.get('dispatch')}/{newest.get('codex_regime')}"
331
+ f"{'/cold' if newest.get('cold') else '/warm'} "
332
+ f"ingest {_millis(newest.get('ingest_ns'))} "
333
+ f"builder {_millis(newest.get('builder_ns'))} "
334
+ f"pin {_millis(newest.get('cache_pin_ns', 0) or 0)} "
335
+ f"total {_millis(newest.get('duration_ns'))}")
336
+ else:
337
+ lines.append(" no ticks recorded yet")
338
+ standalone = tick.get("standalone")
339
+ if standalone:
340
+ lines.append(
341
+ f" standalone builder {_millis(standalone.get('builder_ns'))} "
342
+ f"total {_millis(standalone.get('duration_ns'))} "
343
+ f"(the last build made outside a refresh tick)")
344
+ lines.extend(_render_conversation_sync(tick))
345
+ lines.append("")
346
+
347
+ counts = tick.get("dispatch_counts") or {}
348
+ lines.append(
349
+ f"Dispatch mix full {counts.get('full', 0)} · "
350
+ f"idle {counts.get('idle', 0)} · degraded {counts.get('degraded', 0)} "
351
+ f"(of {tick.get('tick_seq', 0)} completed ticks)")
352
+
353
+ failures = tick.get("cache_open_failures") or {}
354
+ if any(failures.values()):
355
+ detail = " · ".join(f"{k} {v}" for k, v in sorted(failures.items()))
356
+ lines.append(f"Group A cache-open failures {detail}")
357
+ lines.append(" These are silent: each one falls back to the wide "
358
+ "from-scratch fetch with byte-identical output.")
359
+ else:
360
+ lines.append("Group A cache-open failures none")
361
+
362
+ applied = tracing.get("applied")
363
+ requested = tracing.get("requested")
364
+ applies_at = tracing.get("applies_at", "none")
365
+ state = "on" if applied else "off"
366
+ lines.append(f"Phase trace applied {state} · requested "
367
+ f"{'on' if requested else 'off'} · applies_at {applies_at}")
368
+ if payload.get("phases") is not None:
369
+ # `generated_at` IS the stored tree's instant, from the same slot the
370
+ # tree comes from — which is what makes a stale tree readable as stale
371
+ # rather than as the last tick.
372
+ lines.append(f" a stored phase tree is available, generated at "
373
+ f"{payload.get('generated_at')}")
374
+ else:
375
+ lines.append(" no phase tree stored — arm one with "
376
+ "`cctally dashboard-perf --trace on`")
377
+ return "\n".join(lines) + "\n"
378
+
379
+
380
+ # ── the command ─────────────────────────────────────────────────────────────
381
+
382
+
383
+ def cmd_dashboard_perf(args) -> int:
384
+ c = _cctally()
385
+ as_json = bool(getattr(args, "json", False))
386
+ try:
387
+ host = resolve_loopback_target(getattr(args, "host", None)
388
+ or "127.0.0.1")
389
+ except ValueError as exc:
390
+ print(f"dashboard-perf: {exc}", file=sys.stderr)
391
+ return 2
392
+ port = c._resolve_dashboard_port(getattr(args, "port", None))
393
+ if not isinstance(port, int) or not 1 <= port <= 65535:
394
+ # Argument validation, so exit 2. Left unchecked, a nonsensical port
395
+ # failed at connect time and reported itself as a transport failure,
396
+ # which `docs/cli-contract.md` reserves for a real one.
397
+ print(f"dashboard-perf: --port must be between 1 and 65535 (got "
398
+ f"{getattr(args, 'port', None)!r})", file=sys.stderr)
399
+ return 2
400
+
401
+ token = getattr(args, "token", None)
402
+ trace = getattr(args, "trace", None)
403
+ try:
404
+ trace_result = None
405
+ if trace is not None:
406
+ trace_result = _request(
407
+ host, port, _TRACE_PATH, token=token,
408
+ body={"enabled": trace == "on"},
409
+ )
410
+ payload = _request(host, port, _DIAGNOSTIC_PATH, token=token)
411
+ except DashboardPerfError as exc:
412
+ if as_json:
413
+ print(encode_dashboard_json(c.stamp_schema_version(
414
+ {"status": "error", "error": str(exc), "diagnostic": None})))
415
+ else:
416
+ print(f"dashboard-perf: {exc}", file=sys.stderr)
417
+ return 3
418
+
419
+ if as_json:
420
+ # `diagnostic` passes the server payload through VERBATIM and stays
421
+ # explicitly opaque, consistent with what `bin/_lib_perf.py` promises
422
+ # about phase names. The stamped wrapper is the stable part.
423
+ print(encode_dashboard_json(c.stamp_schema_version(
424
+ {"status": "ok", "diagnostic": payload})))
425
+ return 0
426
+
427
+ if trace_result is not None:
428
+ print(f"dashboard-perf: trace {trace} requested "
429
+ f"(requested={trace_result.get('requested')}, "
430
+ f"applied={trace_result.get('applied')}, "
431
+ f"applies_at={trace_result.get('applies_at')})")
432
+ sys.stdout.write(render_dashboard_perf(payload))
433
+ return 0