cctally 1.64.0 → 1.65.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 +17 -0
- package/bin/_cctally_cache.py +60 -19
- package/bin/_cctally_dashboard.py +364 -38
- package/bin/_cctally_tui.py +445 -236
- package/bin/_cctally_update.py +5 -0
- package/bin/_lib_conversation_query.py +349 -36
- package/bin/_lib_perf.py +180 -0
- package/bin/_lib_transcript_access.py +23 -0
- package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
- package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-0jzYm75p.css +0 -1
- package/dashboard/static/assets/index-D_Ylyqsf.js +0 -80
package/bin/_lib_perf.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Opt-in backend phase-instrumentation collector (issue #276, Session A).
|
|
2
|
+
|
|
3
|
+
Stdlib-only. A thread-local nested-phase timing collector, gated on the
|
|
4
|
+
CCTALLY_PERF_TRACE env var. Near-noop when off: phase() returns a shared
|
|
5
|
+
_NULL_PHASE singleton — no allocation, no perf_counter, no stack push.
|
|
6
|
+
|
|
7
|
+
Two renderers sit on the same phase tree:
|
|
8
|
+
* flush_stderr(root) — CLI indented outline (stdout stays byte-identical).
|
|
9
|
+
* stash_last(root) — the dashboard freezes the completed tree (to_dict)
|
|
10
|
+
into a process-global slot for the loopback
|
|
11
|
+
/api/debug/backend endpoint to read.
|
|
12
|
+
|
|
13
|
+
This surface is a diagnostic, NOT a consumer contract: phase names, nesting,
|
|
14
|
+
and fields may change without a version bump.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
_FALSEY = {"", "0", "false", "no", "off"}
|
|
24
|
+
_ENABLED = os.environ.get("CCTALLY_PERF_TRACE", "").strip().lower() not in _FALSEY
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def enabled() -> bool:
|
|
28
|
+
return _ENABLED
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_enabled(value: bool) -> None:
|
|
32
|
+
"""Flip tracing at runtime (tests; not used by the dashboard, which reads
|
|
33
|
+
the env at import time)."""
|
|
34
|
+
global _ENABLED
|
|
35
|
+
_ENABLED = bool(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_tls = threading.local()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _stack():
|
|
42
|
+
s = getattr(_tls, "stack", None)
|
|
43
|
+
if s is None:
|
|
44
|
+
s = []
|
|
45
|
+
_tls.stack = s
|
|
46
|
+
return s
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Phase:
|
|
50
|
+
__slots__ = ("name", "elapsed_ms", "count", "meta", "children", "_start", "_stack")
|
|
51
|
+
|
|
52
|
+
def __init__(self, name, stack):
|
|
53
|
+
self.name = name
|
|
54
|
+
self.elapsed_ms = 0.0
|
|
55
|
+
self.count = None
|
|
56
|
+
self.meta = None
|
|
57
|
+
self.children = []
|
|
58
|
+
self._start = 0.0
|
|
59
|
+
self._stack = stack
|
|
60
|
+
|
|
61
|
+
def set_count(self, n):
|
|
62
|
+
self.count = int(n)
|
|
63
|
+
|
|
64
|
+
def set_meta(self, **kw):
|
|
65
|
+
if self.meta is None:
|
|
66
|
+
self.meta = {}
|
|
67
|
+
self.meta.update(kw)
|
|
68
|
+
|
|
69
|
+
def __enter__(self):
|
|
70
|
+
self._start = time.perf_counter()
|
|
71
|
+
self._stack.append(self)
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *exc):
|
|
75
|
+
self.elapsed_ms = (time.perf_counter() - self._start) * 1000.0
|
|
76
|
+
# Identity-aware unwind. If a nested phase leaked (its __exit__ was
|
|
77
|
+
# skipped — e.g. an exception escaped a manually CM-bracketed region),
|
|
78
|
+
# drop the leaked frames sitting above us so we never append a phase to
|
|
79
|
+
# its OWN children (which would make to_dict() self-recurse) and never
|
|
80
|
+
# strand a stack that hides the real root. Pop down to and including
|
|
81
|
+
# self; if self is not on the stack (double __exit__, or an ancestor
|
|
82
|
+
# already unwound us), do nothing.
|
|
83
|
+
stack = self._stack
|
|
84
|
+
if self not in stack:
|
|
85
|
+
return False
|
|
86
|
+
while stack and stack[-1] is not self:
|
|
87
|
+
stack.pop() # discard a leaked descendant frame
|
|
88
|
+
stack.pop() # pop self
|
|
89
|
+
if stack:
|
|
90
|
+
stack[-1].children.append(self)
|
|
91
|
+
else:
|
|
92
|
+
_tls.root = self # outermost phase closed -> the build root
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
def to_dict(self):
|
|
96
|
+
d = {"name": self.name, "elapsed_ms": round(self.elapsed_ms, 3)}
|
|
97
|
+
if self.count is not None:
|
|
98
|
+
d["count"] = self.count
|
|
99
|
+
if self.meta:
|
|
100
|
+
d["meta"] = dict(self.meta)
|
|
101
|
+
if self.children:
|
|
102
|
+
d["children"] = [c.to_dict() for c in self.children]
|
|
103
|
+
return d
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _NullPhase:
|
|
107
|
+
"""Shared no-op returned when tracing is off. No allocation per phase()."""
|
|
108
|
+
|
|
109
|
+
def set_count(self, n):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
def set_meta(self, **kw):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def __enter__(self):
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, *exc):
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_NULL_PHASE = _NullPhase()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def phase(name):
|
|
126
|
+
if not _ENABLED:
|
|
127
|
+
return _NULL_PHASE
|
|
128
|
+
return Phase(name, _stack())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def current_root():
|
|
132
|
+
return getattr(_tls, "root", None)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def reset_thread():
|
|
136
|
+
_tls.stack = []
|
|
137
|
+
_tls.root = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def flush_stderr(root):
|
|
141
|
+
if root is None:
|
|
142
|
+
return
|
|
143
|
+
lines = []
|
|
144
|
+
|
|
145
|
+
def walk(p, depth):
|
|
146
|
+
indent = " " * depth
|
|
147
|
+
extra = ""
|
|
148
|
+
if p.count is not None:
|
|
149
|
+
extra += f" (count={p.count})"
|
|
150
|
+
if p.meta:
|
|
151
|
+
extra += " " + " ".join(f"{k}={v}" for k, v in p.meta.items())
|
|
152
|
+
lines.append(f"{indent}{p.name} {p.elapsed_ms:.1f}ms{extra}")
|
|
153
|
+
for c in p.children:
|
|
154
|
+
walk(c, depth + 1)
|
|
155
|
+
|
|
156
|
+
walk(root, 0)
|
|
157
|
+
sys.stderr.write("backend-perf:\n" + "\n".join(lines) + "\n")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── process-global last-completed-tree slot (dashboard -> endpoint) ──────────
|
|
161
|
+
# The writer freezes the tree with to_dict() then binds the module global in
|
|
162
|
+
# ONE statement; once bound the dict is never mutated (the next build binds a
|
|
163
|
+
# fresh dict). Assignment is atomic under the GIL, so the HTTP reader thread
|
|
164
|
+
# always sees a whole, immutable "last completed build".
|
|
165
|
+
_LAST_BACKEND_PERF = None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def stash_last(root, *, generation=None, generated_at=None):
|
|
169
|
+
global _LAST_BACKEND_PERF
|
|
170
|
+
if root is None:
|
|
171
|
+
return
|
|
172
|
+
_LAST_BACKEND_PERF = {
|
|
173
|
+
"generated_at": generated_at,
|
|
174
|
+
"generation": generation,
|
|
175
|
+
"phases": root.to_dict(),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def last_backend_perf():
|
|
180
|
+
return _LAST_BACKEND_PERF
|
|
@@ -55,6 +55,29 @@ def is_loopback(host: str) -> bool:
|
|
|
55
55
|
return False
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def debug_backend_allowed(peer_ip, host) -> bool:
|
|
59
|
+
"""Gate for the loopback-only ``/api/debug/backend`` diagnostic endpoint
|
|
60
|
+
(issue #276, Session A).
|
|
61
|
+
|
|
62
|
+
STRICTER than the transcript gate — it never opens to the LAN. Two checks:
|
|
63
|
+
|
|
64
|
+
* PRIMARY: the TCP peer (``client_address[0]``) must be a loopback
|
|
65
|
+
literal. This is the unspoofable signal — the dashboard can bind
|
|
66
|
+
``0.0.0.0`` (``dashboard.bind = lan``), and a ``Host``-header-only
|
|
67
|
+
check would let a LAN client connect to the LAN socket while sending
|
|
68
|
+
``Host: 127.0.0.1``. The peer address cannot be spoofed that way.
|
|
69
|
+
* DEFENSE-IN-DEPTH: the ``Host`` authority must ALSO be an IP-literal
|
|
70
|
+
loopback (anti-DNS-rebinding) — a hostname ``Host`` (a rebinding
|
|
71
|
+
vector) is rejected even from a loopback peer.
|
|
72
|
+
|
|
73
|
+
``dashboard.expose_transcripts`` is NEVER consulted — this surface is
|
|
74
|
+
loopback-only, ALWAYS. Fail closed on a missing/empty peer or Host.
|
|
75
|
+
"""
|
|
76
|
+
if not is_loopback(peer_ip):
|
|
77
|
+
return False
|
|
78
|
+
return is_loopback(authority_host(host))
|
|
79
|
+
|
|
80
|
+
|
|
58
81
|
def transcripts_allowed(bind_host, expose: bool) -> bool:
|
|
59
82
|
"""Are transcripts served AT ALL on this bind? Loopback bind always; a
|
|
60
83
|
non-loopback (LAN) bind only under the explicit ``expose`` opt-in."""
|