cctally 1.91.0 → 1.92.1
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 +51 -0
- package/README.md +4 -2
- package/bin/_cctally_cache.py +903 -74
- package/bin/_cctally_config.py +57 -0
- package/bin/_cctally_core.py +94 -14
- package/bin/_cctally_dashboard.py +217 -19
- package/bin/_cctally_dashboard_conversation.py +170 -20
- package/bin/_cctally_dashboard_envelope.py +2 -0
- package/bin/_cctally_db.py +481 -19
- package/bin/_cctally_doctor.py +18 -1
- package/bin/_cctally_journal.py +1156 -21
- package/bin/_cctally_journal_repair.py +6 -0
- package/bin/_cctally_parser.py +26 -0
- package/bin/_cctally_quota.py +171 -55
- package/bin/_cctally_record.py +13 -1
- package/bin/_cctally_rederive.py +4 -0
- package/bin/_cctally_statusline.py +6 -6
- package/bin/_cctally_store.py +1061 -40
- package/bin/_cctally_transcript.py +32 -2
- package/bin/_cctally_tui.py +54 -6
- package/bin/_lib_cache_report.py +8 -3
- package/bin/_lib_codex_conversation.py +851 -81
- package/bin/_lib_codex_conversation_query.py +2031 -96
- package/bin/_lib_codex_find_projection.py +517 -0
- package/bin/_lib_codex_harness_preamble.py +176 -0
- package/bin/_lib_codex_hooks.py +5 -3
- package/bin/_lib_codex_js_scan.py +254 -0
- package/bin/_lib_codex_landmarks.py +309 -0
- package/bin/_lib_codex_title_clean.py +116 -0
- package/bin/_lib_conversation_dispatch.py +168 -22
- package/bin/_lib_conversation_query.py +62 -2
- package/bin/_lib_conversation_watch.py +4 -2
- package/bin/_lib_doctor.py +64 -0
- package/bin/_lib_quota_alert_axes.py +31 -34
- package/bin/_lib_stats_damage.py +523 -0
- package/bin/_lib_stats_publish.py +243 -0
- package/bin/cctally +17 -3
- package/dashboard/static/assets/index-Dat-mza6.js +97 -0
- package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +8 -1
- package/dashboard/static/assets/index-CILAoEja.js +0 -90
|
@@ -265,12 +265,14 @@ import bisect
|
|
|
265
265
|
import contextlib
|
|
266
266
|
import dataclasses
|
|
267
267
|
import datetime as dt
|
|
268
|
+
import hmac
|
|
268
269
|
import io
|
|
269
270
|
import json
|
|
270
271
|
import os
|
|
271
272
|
import pathlib
|
|
272
273
|
import queue
|
|
273
274
|
import re
|
|
275
|
+
import secrets
|
|
274
276
|
import shutil
|
|
275
277
|
import signal as _signal
|
|
276
278
|
import socket
|
|
@@ -1773,6 +1775,33 @@ def _format_url(host: str, port: int) -> str:
|
|
|
1773
1775
|
return f"http://{host}:{port}/"
|
|
1774
1776
|
|
|
1775
1777
|
|
|
1778
|
+
def _dashboard_auth_url(url: str, token: "str | None") -> str:
|
|
1779
|
+
"""Append a per-run token as a fragment, never as a server-visible query."""
|
|
1780
|
+
if token is None:
|
|
1781
|
+
return url
|
|
1782
|
+
return f"{url}#token={urllib.parse.quote(token, safe='')}"
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
def _dashboard_lan_auth_token(
|
|
1786
|
+
bind_host: str, enabled: bool, *,
|
|
1787
|
+
token_factory=secrets.token_urlsafe) -> "str | None":
|
|
1788
|
+
"""Mint one process-local token only for authenticated non-loopback binds."""
|
|
1789
|
+
gate = sys.modules["cctally"]._load_sibling("_lib_transcript_access")
|
|
1790
|
+
if not enabled or gate.is_loopback(gate.authority_host(bind_host)):
|
|
1791
|
+
return None
|
|
1792
|
+
return token_factory(32)
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _dashboard_token_from_cookie(raw_cookie: str) -> "str | None":
|
|
1796
|
+
"""Return the sole dashboard session-token cookie, rejecting duplicates."""
|
|
1797
|
+
values = []
|
|
1798
|
+
for part in (raw_cookie or "").split(";"):
|
|
1799
|
+
name, separator, value = part.strip().partition("=")
|
|
1800
|
+
if separator and name == "cctally_dashboard_token":
|
|
1801
|
+
values.append(value)
|
|
1802
|
+
return values[0] if len(values) == 1 and values[0] else None
|
|
1803
|
+
|
|
1804
|
+
|
|
1776
1805
|
def _discover_lan_ip() -> "str | None":
|
|
1777
1806
|
"""Return the kernel's chosen IPv4 source address for off-host traffic.
|
|
1778
1807
|
|
|
@@ -4324,12 +4353,40 @@ def _debug_source_state_wire(bundle, source: str) -> dict:
|
|
|
4324
4353
|
}
|
|
4325
4354
|
|
|
4326
4355
|
|
|
4327
|
-
def
|
|
4356
|
+
def _debug_stats_fault(exc: BaseException, leg: str) -> dict:
|
|
4357
|
+
"""The wire form of a stats attribution these debug reads may report.
|
|
4358
|
+
|
|
4359
|
+
#496 S3 §8 records a DELIBERATE deviation from F16's literal wording. F16
|
|
4360
|
+
asks that every path meeting a corrupt index produce a typed attribution
|
|
4361
|
+
AND reach the heal. The two debug helpers deliver the attribution WITHOUT
|
|
4362
|
+
the heal: they are short-lived diagnostic reads that bypass the corruption
|
|
4363
|
+
boundary for cost, and making a debug endpoint able to trigger a rebuild is
|
|
4364
|
+
a worse outcome than making it honest. The dashboard's main build path
|
|
4365
|
+
already reaches the heal, so a fault attributed here is healed on the next
|
|
4366
|
+
tick.
|
|
4367
|
+
|
|
4368
|
+
The shape is exactly the mapping `_sync_failure_envelope`'s attribution
|
|
4369
|
+
reader already accepts, so this travels on the established vocabulary
|
|
4370
|
+
rather than inventing a second one. No database probe is needed: the
|
|
4371
|
+
connection these helpers were opening is the stats index by construction.
|
|
4372
|
+
"""
|
|
4373
|
+
return {
|
|
4374
|
+
"leg": leg,
|
|
4375
|
+
"database": "stats",
|
|
4376
|
+
"corruption": bool(_cctally()._is_sqlite_corruption_error(exc)),
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
|
|
4380
|
+
def _debug_source_counts(cache_conn, bundle, *, faults=None) -> dict:
|
|
4328
4381
|
"""Bounded, source-owned counts and opaque state for the debug endpoint.
|
|
4329
4382
|
|
|
4330
4383
|
Every table and predicate is fixed here. This deliberately reports no
|
|
4331
4384
|
values from rows: roots, paths, logical limits, conversation IDs, and
|
|
4332
4385
|
project labels never cross the diagnostic boundary.
|
|
4386
|
+
|
|
4387
|
+
``faults`` collects `_debug_stats_fault` mappings for a stats read this
|
|
4388
|
+
diagnostic could not complete, so the endpoint reports the fault instead of
|
|
4389
|
+
silently degrading a stats failure into a missing count.
|
|
4333
4390
|
"""
|
|
4334
4391
|
result = {
|
|
4335
4392
|
source: _debug_source_state_wire(bundle, source)
|
|
@@ -4359,8 +4416,9 @@ def _debug_source_counts(cache_conn, bundle) -> dict:
|
|
|
4359
4416
|
result[source]["tables"][table] = int(row[0])
|
|
4360
4417
|
except sqlite3.Error:
|
|
4361
4418
|
pass
|
|
4362
|
-
except sqlite3.Error:
|
|
4363
|
-
|
|
4419
|
+
except sqlite3.Error as exc:
|
|
4420
|
+
if faults is not None:
|
|
4421
|
+
faults.append(_debug_stats_fault(exc, "debug-source-counts"))
|
|
4364
4422
|
finally:
|
|
4365
4423
|
if stats_conn is not None:
|
|
4366
4424
|
stats_conn.close()
|
|
@@ -4390,7 +4448,7 @@ def _stats_ro_guarded():
|
|
|
4390
4448
|
)
|
|
4391
4449
|
|
|
4392
4450
|
|
|
4393
|
-
def _debug_cache_state(cache_conn) -> dict:
|
|
4451
|
+
def _debug_cache_state(cache_conn, *, faults=None) -> dict:
|
|
4394
4452
|
"""On-demand signature legs + pending-reingest flags + generation.
|
|
4395
4453
|
|
|
4396
4454
|
The signature legs are the canonical ``compute_signature`` fields (ints /
|
|
@@ -4404,8 +4462,10 @@ def _debug_cache_state(cache_conn) -> dict:
|
|
|
4404
4462
|
stats_conn = None
|
|
4405
4463
|
try:
|
|
4406
4464
|
stats_conn = _stats_ro_guarded()
|
|
4407
|
-
except sqlite3.Error:
|
|
4465
|
+
except sqlite3.Error as exc:
|
|
4408
4466
|
stats_conn = None
|
|
4467
|
+
if faults is not None:
|
|
4468
|
+
faults.append(_debug_stats_fault(exc, "debug-cache-state"))
|
|
4409
4469
|
try:
|
|
4410
4470
|
if stats_conn is not None:
|
|
4411
4471
|
sig = sc.compute_signature(
|
|
@@ -4530,6 +4590,7 @@ _GET_ROUTES = (
|
|
|
4530
4590
|
)
|
|
4531
4591
|
|
|
4532
4592
|
_POST_ROUTES = (
|
|
4593
|
+
("exact", "/api/auth", "_handle_post_auth", None, False),
|
|
4533
4594
|
("exact", "/api/sync", "_handle_post_sync", None, False),
|
|
4534
4595
|
("exact", "/api/settings", "_handle_post_settings", None, False),
|
|
4535
4596
|
("exact", "/api/alerts/test", "_handle_post_alerts_test", None, False),
|
|
@@ -4599,6 +4660,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4599
4660
|
# cmd_dashboard before serve_forever; the per-request gate
|
|
4600
4661
|
# (`_require_transcripts_allowed`) ANDs this with the request Host.
|
|
4601
4662
|
cctally_expose_transcripts: bool = False
|
|
4663
|
+
# Per-process LAN API token. None preserves the byte-stable loopback and
|
|
4664
|
+
# explicit opt-out behavior; a value gates every /api/* request.
|
|
4665
|
+
cctally_api_token: "str | None" = None
|
|
4602
4666
|
|
|
4603
4667
|
# Access log stays silent (deliberate — noisy in the parent terminal),
|
|
4604
4668
|
# but server errors are REAL as of #279 S2: log_error routes through
|
|
@@ -4678,7 +4742,43 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4678
4742
|
return True
|
|
4679
4743
|
return False
|
|
4680
4744
|
|
|
4745
|
+
def _require_api_auth(self) -> bool:
|
|
4746
|
+
"""Authenticate an API request before any route or method dispatch."""
|
|
4747
|
+
if not self.path.split("?", 1)[0].startswith("/api/"):
|
|
4748
|
+
return True
|
|
4749
|
+
token = type(self).cctally_api_token
|
|
4750
|
+
if token is None:
|
|
4751
|
+
return True
|
|
4752
|
+
|
|
4753
|
+
authorization = self.headers.get_all("Authorization", [])
|
|
4754
|
+
supplied = None
|
|
4755
|
+
if authorization:
|
|
4756
|
+
if (len(authorization) == 1
|
|
4757
|
+
and authorization[0].startswith("Bearer ")):
|
|
4758
|
+
candidate = authorization[0][len("Bearer "):]
|
|
4759
|
+
if candidate:
|
|
4760
|
+
supplied = candidate
|
|
4761
|
+
else:
|
|
4762
|
+
supplied = _dashboard_token_from_cookie(
|
|
4763
|
+
self.headers.get("Cookie", "")
|
|
4764
|
+
)
|
|
4765
|
+
if (supplied is not None and supplied.isascii()
|
|
4766
|
+
and hmac.compare_digest(supplied, token)):
|
|
4767
|
+
return True
|
|
4768
|
+
|
|
4769
|
+
encoded = encode_dashboard_json_bytes({"error": "unauthorized"})
|
|
4770
|
+
self.send_response(401)
|
|
4771
|
+
self.send_header("WWW-Authenticate", "Bearer")
|
|
4772
|
+
self.send_header("Content-Type", "application/json")
|
|
4773
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
4774
|
+
self.end_headers()
|
|
4775
|
+
if self.command != "HEAD":
|
|
4776
|
+
self.wfile.write(encoded)
|
|
4777
|
+
return False
|
|
4778
|
+
|
|
4681
4779
|
def do_GET(self) -> None: # noqa: N802 — stdlib API
|
|
4780
|
+
if not self._require_api_auth():
|
|
4781
|
+
return
|
|
4682
4782
|
if self._method_not_allowed_for_settings():
|
|
4683
4783
|
return
|
|
4684
4784
|
path = self.path.split("?", 1)[0]
|
|
@@ -4716,6 +4816,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4716
4816
|
self.send_error(404, "not found")
|
|
4717
4817
|
|
|
4718
4818
|
def do_POST(self) -> None: # noqa: N802 — stdlib API
|
|
4819
|
+
if not self._require_api_auth():
|
|
4820
|
+
return
|
|
4719
4821
|
# No _method_not_allowed_for_settings() guard here (gate P1-3): POST
|
|
4720
4822
|
# /api/settings must route to _handle_post_settings, not 405.
|
|
4721
4823
|
if not self._dispatch(_POST_ROUTES):
|
|
@@ -4728,21 +4830,55 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4728
4830
|
# For other paths we fall through to send_error(501) so the rest of
|
|
4729
4831
|
# the surface keeps stdlib semantics.
|
|
4730
4832
|
def do_PUT(self) -> None: # noqa: N802 — stdlib API
|
|
4833
|
+
if not self._require_api_auth():
|
|
4834
|
+
return
|
|
4731
4835
|
if self._method_not_allowed_for_settings():
|
|
4732
4836
|
return
|
|
4733
4837
|
self.send_error(501, "Unsupported method ('PUT')")
|
|
4734
4838
|
|
|
4735
4839
|
def do_DELETE(self) -> None: # noqa: N802 — stdlib API
|
|
4840
|
+
if not self._require_api_auth():
|
|
4841
|
+
return
|
|
4736
4842
|
if self._method_not_allowed_for_settings():
|
|
4737
4843
|
return
|
|
4738
4844
|
if not self._dispatch(_DELETE_ROUTES):
|
|
4739
4845
|
self.send_error(501, "Unsupported method ('DELETE')")
|
|
4740
4846
|
|
|
4741
4847
|
def do_PATCH(self) -> None: # noqa: N802 — stdlib API
|
|
4848
|
+
if not self._require_api_auth():
|
|
4849
|
+
return
|
|
4742
4850
|
if self._method_not_allowed_for_settings():
|
|
4743
4851
|
return
|
|
4744
4852
|
self.send_error(501, "Unsupported method ('PATCH')")
|
|
4745
4853
|
|
|
4854
|
+
def do_HEAD(self) -> None: # noqa: N802 — stdlib API
|
|
4855
|
+
if not self._require_api_auth():
|
|
4856
|
+
return
|
|
4857
|
+
if self._method_not_allowed_for_settings():
|
|
4858
|
+
return
|
|
4859
|
+
self.send_error(501, "Unsupported method ('HEAD')")
|
|
4860
|
+
|
|
4861
|
+
def do_OPTIONS(self) -> None: # noqa: N802 — stdlib API
|
|
4862
|
+
if not self._require_api_auth():
|
|
4863
|
+
return
|
|
4864
|
+
if self._method_not_allowed_for_settings():
|
|
4865
|
+
return
|
|
4866
|
+
self.send_error(501, "Unsupported method ('OPTIONS')")
|
|
4867
|
+
|
|
4868
|
+
def _handle_post_auth(self) -> None:
|
|
4869
|
+
"""Bridge an accepted Bearer token to a browser-native session cookie."""
|
|
4870
|
+
token = type(self).cctally_api_token
|
|
4871
|
+
if token is None:
|
|
4872
|
+
self.send_error(404, "not found")
|
|
4873
|
+
return
|
|
4874
|
+
self.send_response(204)
|
|
4875
|
+
self.send_header(
|
|
4876
|
+
"Set-Cookie",
|
|
4877
|
+
f"cctally_dashboard_token={token}; Path=/api; "
|
|
4878
|
+
"HttpOnly; SameSite=Strict",
|
|
4879
|
+
)
|
|
4880
|
+
self.end_headers()
|
|
4881
|
+
|
|
4746
4882
|
def _handle_post_sync(self) -> None:
|
|
4747
4883
|
"""Trigger refresh-usage + snapshot rebuild on user demand.
|
|
4748
4884
|
|
|
@@ -4956,13 +5092,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4956
5092
|
source_bundle = self.snapshot_ref.get().source_bundle
|
|
4957
5093
|
except Exception: # noqa: BLE001 -- diagnostics fail closed.
|
|
4958
5094
|
source_bundle = None
|
|
4959
|
-
|
|
5095
|
+
stats_faults: list[dict] = []
|
|
5096
|
+
sources = _debug_source_counts(None, source_bundle, faults=stats_faults)
|
|
4960
5097
|
try:
|
|
4961
5098
|
conn = open_cache_db()
|
|
4962
5099
|
try:
|
|
4963
5100
|
dataset = _debug_cache_table_counts(conn)
|
|
4964
|
-
cache_state = _debug_cache_state(conn)
|
|
4965
|
-
sources = _debug_source_counts(
|
|
5101
|
+
cache_state = _debug_cache_state(conn, faults=stats_faults)
|
|
5102
|
+
sources = _debug_source_counts(
|
|
5103
|
+
conn, source_bundle, faults=stats_faults
|
|
5104
|
+
)
|
|
4966
5105
|
finally:
|
|
4967
5106
|
conn.close()
|
|
4968
5107
|
except Exception: # noqa: BLE001 -- a diagnostic must not expose raw errors.
|
|
@@ -4975,6 +5114,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4975
5114
|
"phases": (last or {}).get("phases"),
|
|
4976
5115
|
"cache_state": cache_state,
|
|
4977
5116
|
"sources": sources,
|
|
5117
|
+
# Additive, and named rather than folded into `cache_state`: a
|
|
5118
|
+
# stats fault is not cache state, and #496 S3 §8 exists because a
|
|
5119
|
+
# stats failure reported as a cache one sends the user to
|
|
5120
|
+
# `cctally cache-sync --rebuild`.
|
|
5121
|
+
"stats_faults": stats_faults,
|
|
4978
5122
|
}
|
|
4979
5123
|
if body["phases"] is None:
|
|
4980
5124
|
body["note"] = "tracing_disabled"
|
|
@@ -5256,7 +5400,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5256
5400
|
"field": f"dashboard.{leaf}"},
|
|
5257
5401
|
)
|
|
5258
5402
|
return
|
|
5259
|
-
if leaf not in (
|
|
5403
|
+
if leaf not in (
|
|
5404
|
+
"cache_failure_markers", "live_tail", "lan_auth"
|
|
5405
|
+
):
|
|
5260
5406
|
self._respond_json(
|
|
5261
5407
|
400,
|
|
5262
5408
|
{"error": f"unknown dashboard settings key: {leaf}",
|
|
@@ -5264,7 +5410,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5264
5410
|
)
|
|
5265
5411
|
return
|
|
5266
5412
|
dashboard_validated = {}
|
|
5267
|
-
for _leaf in ("cache_failure_markers", "live_tail"):
|
|
5413
|
+
for _leaf in ("cache_failure_markers", "live_tail", "lan_auth"):
|
|
5268
5414
|
if _leaf in dashboard_block:
|
|
5269
5415
|
if not isinstance(dashboard_block[_leaf], bool):
|
|
5270
5416
|
self._respond_json(
|
|
@@ -5659,12 +5805,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5659
5805
|
# + live_tail) so the SettingsOverlay can repaint without a
|
|
5660
5806
|
# follow-up GET. Default true (opt-out) when nothing is persisted.
|
|
5661
5807
|
persisted_dash = merged.get("dashboard") or {}
|
|
5808
|
+
persisted_lan_auth = persisted_dash.get("lan_auth", True)
|
|
5662
5809
|
out["dashboard"] = {
|
|
5663
5810
|
"cache_failure_markers": bool(
|
|
5664
5811
|
persisted_dash.get("cache_failure_markers", True)
|
|
5665
5812
|
),
|
|
5666
5813
|
"live_tail": bool(persisted_dash.get("live_tail", True)),
|
|
5814
|
+
"lan_auth": (
|
|
5815
|
+
persisted_lan_auth
|
|
5816
|
+
if isinstance(persisted_lan_auth, bool)
|
|
5817
|
+
else True
|
|
5818
|
+
),
|
|
5667
5819
|
}
|
|
5820
|
+
if "lan_auth" in dashboard_validated:
|
|
5821
|
+
out["restart_required"] = ["dashboard.lan_auth"]
|
|
5668
5822
|
out["saved_at"] = (
|
|
5669
5823
|
dt.datetime.now(dt.timezone.utc)
|
|
5670
5824
|
.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -6918,15 +7072,28 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
|
|
|
6918
7072
|
display_tz_pref_override=display_tz_pref_override,
|
|
6919
7073
|
stats_heal_attempted=False,
|
|
6920
7074
|
)
|
|
6921
|
-
except c.
|
|
6922
|
-
return
|
|
7075
|
+
except c.StatsRebuildDeferred as exc:
|
|
7076
|
+
return _dashboard_stats_deferred_snapshot(
|
|
6923
7077
|
args,
|
|
6924
7078
|
pinned_now=pinned_now,
|
|
6925
7079
|
exc=exc,
|
|
6926
7080
|
)
|
|
6927
7081
|
except tui._StatsSnapshotCorruption as fault:
|
|
6928
7082
|
# The once-builder's finally has closed the cheap-seed stats handle.
|
|
6929
|
-
|
|
7083
|
+
#
|
|
7084
|
+
# #496 S3 §6: this call is made from INSIDE an `except` handler, so a
|
|
7085
|
+
# `BaseException` raised by the heal would escape past the sibling
|
|
7086
|
+
# `except` above and fail dashboard startup instead of hydrating it.
|
|
7087
|
+
# The shared deferral base is therefore caught AT the heal-call
|
|
7088
|
+
# boundary, and the correctly typed degraded frame is built directly.
|
|
7089
|
+
try:
|
|
7090
|
+
tui._tui_heal_post_query_stats(fault.cause)
|
|
7091
|
+
except c.StatsRebuildDeferred as deferred:
|
|
7092
|
+
return _dashboard_stats_deferred_snapshot(
|
|
7093
|
+
args,
|
|
7094
|
+
pinned_now=pinned_now,
|
|
7095
|
+
exc=deferred,
|
|
7096
|
+
)
|
|
6930
7097
|
return _dashboard_initial_snapshot_once(
|
|
6931
7098
|
args,
|
|
6932
7099
|
pinned_now=pinned_now,
|
|
@@ -6935,8 +7102,13 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
|
|
|
6935
7102
|
)
|
|
6936
7103
|
|
|
6937
7104
|
|
|
6938
|
-
def
|
|
6939
|
-
"""Bind promptly with a typed degraded frame while
|
|
7105
|
+
def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
|
|
7106
|
+
"""Bind promptly with a typed degraded frame while a rebuild runs detached.
|
|
7107
|
+
|
|
7108
|
+
Serves both deferral classes and keeps them distinct: a wrong EPOCH is a
|
|
7109
|
+
readable index, while a deferred corruption heal is an index that could not
|
|
7110
|
+
be read, and the attribution must say which (#496 S3 §6).
|
|
7111
|
+
"""
|
|
6940
7112
|
|
|
6941
7113
|
import time as _time
|
|
6942
7114
|
|
|
@@ -6970,7 +7142,7 @@ def _dashboard_stats_epoch_deferred_snapshot(args, *, pinned_now, exc):
|
|
|
6970
7142
|
tui.SyncFailureAttribution(
|
|
6971
7143
|
leg="stats-open",
|
|
6972
7144
|
database="stats",
|
|
6973
|
-
corruption=
|
|
7145
|
+
corruption=isinstance(exc, c.StatsHealDeferred),
|
|
6974
7146
|
),
|
|
6975
7147
|
),
|
|
6976
7148
|
"doctor_payload": doctor_payload,
|
|
@@ -7266,6 +7438,10 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
7266
7438
|
stored = "loopback"
|
|
7267
7439
|
resolved_bind_stored = stored
|
|
7268
7440
|
args.host = _resolve_dashboard_bind_for_runtime(resolved_bind_stored)
|
|
7441
|
+
lan_auth_enabled = bool(
|
|
7442
|
+
_config_known_value(config, "dashboard.lan_auth")
|
|
7443
|
+
)
|
|
7444
|
+
api_token = _dashboard_lan_auth_token(args.host, lan_auth_enabled)
|
|
7269
7445
|
|
|
7270
7446
|
# F3: capture the canonical tz token from `--tz` (NOT the ZoneInfo
|
|
7271
7447
|
# — the override needs to flow through `load_config()`-style readers
|
|
@@ -7365,6 +7541,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
7365
7541
|
# in the doctor SSE block + /api/doctor reflects the actual --host
|
|
7366
7542
|
# the process is serving, not just the config-only view the CLI sees.
|
|
7367
7543
|
DashboardHTTPHandler.cctally_host = args.host
|
|
7544
|
+
DashboardHTTPHandler.cctally_api_token = api_token
|
|
7368
7545
|
# Conversation viewer (Plan 2, spec §5): the resolved
|
|
7369
7546
|
# `dashboard.expose_transcripts` opt-in. Read off the already-loaded
|
|
7370
7547
|
# `config` the same way `dashboard.bind` is resolved above (the
|
|
@@ -7490,20 +7667,40 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
7490
7667
|
is_loopback = bind_host in ("127.0.0.1", "localhost", "::1")
|
|
7491
7668
|
|
|
7492
7669
|
if is_all_interfaces:
|
|
7493
|
-
local_url =
|
|
7670
|
+
local_url = _dashboard_auth_url(
|
|
7671
|
+
_format_url("localhost", bind_port), api_token
|
|
7672
|
+
)
|
|
7494
7673
|
lan_ip = _discover_lan_ip()
|
|
7495
7674
|
print("dashboard: serving on all interfaces:", flush=True)
|
|
7496
7675
|
print(f" - {local_url} (this machine)", flush=True)
|
|
7497
7676
|
if lan_ip:
|
|
7498
|
-
lan_url =
|
|
7677
|
+
lan_url = _dashboard_auth_url(
|
|
7678
|
+
_format_url(lan_ip, bind_port), api_token
|
|
7679
|
+
)
|
|
7499
7680
|
print(f" - {lan_url} (LAN)", flush=True)
|
|
7681
|
+
if api_token is not None:
|
|
7682
|
+
print(f"dashboard: LAN access token: {api_token}", flush=True)
|
|
7683
|
+
elif not lan_auth_enabled:
|
|
7684
|
+
print(
|
|
7685
|
+
"warning: LAN authentication disabled by "
|
|
7686
|
+
"dashboard.lan_auth=false; restart after enabling it",
|
|
7687
|
+
flush=True,
|
|
7688
|
+
)
|
|
7500
7689
|
print("Ctrl-C to stop", flush=True)
|
|
7501
7690
|
elif is_loopback:
|
|
7502
7691
|
url = _format_url("localhost", bind_port)
|
|
7503
7692
|
print(f"dashboard: serving {url} — Ctrl-C to stop", flush=True)
|
|
7504
7693
|
else:
|
|
7505
|
-
url = _format_url(bind_host, bind_port)
|
|
7694
|
+
url = _dashboard_auth_url(_format_url(bind_host, bind_port), api_token)
|
|
7506
7695
|
print(f"dashboard: serving {url} — Ctrl-C to stop", flush=True)
|
|
7696
|
+
if api_token is not None:
|
|
7697
|
+
print(f"dashboard: LAN access token: {api_token}", flush=True)
|
|
7698
|
+
elif not lan_auth_enabled:
|
|
7699
|
+
print(
|
|
7700
|
+
"warning: LAN authentication disabled by "
|
|
7701
|
+
"dashboard.lan_auth=false; restart after enabling it",
|
|
7702
|
+
flush=True,
|
|
7703
|
+
)
|
|
7507
7704
|
|
|
7508
7705
|
http_thread = threading.Thread(target=srv.serve_forever, daemon=True,
|
|
7509
7706
|
name="dashboard-http")
|
|
@@ -7528,6 +7725,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
7528
7725
|
browser_url = _format_url("localhost", bind_port)
|
|
7529
7726
|
else:
|
|
7530
7727
|
browser_url = _format_url(bind_host, bind_port)
|
|
7728
|
+
browser_url = _dashboard_auth_url(browser_url, api_token)
|
|
7531
7729
|
try:
|
|
7532
7730
|
_wb.open(browser_url)
|
|
7533
7731
|
except Exception as exc:
|