cctally 1.27.1 → 1.29.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 +37 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_cache.py +355 -31
- package/bin/_cctally_config.py +153 -11
- package/bin/_cctally_core.py +204 -42
- package/bin/_cctally_dashboard.py +510 -61
- package/bin/_cctally_db.py +756 -163
- package/bin/_cctally_doctor.py +11 -0
- package/bin/_cctally_forecast.py +700 -57
- package/bin/_cctally_milestones.py +252 -47
- package/bin/_cctally_parser.py +44 -4
- package/bin/_cctally_record.py +380 -133
- package/bin/_cctally_weekrefs.py +30 -6
- package/bin/_lib_alert_axes.py +12 -2
- package/bin/_lib_alerts_payload.py +95 -3
- package/bin/_lib_budget.py +48 -0
- package/bin/_lib_conversation.py +177 -0
- package/bin/_lib_conversation_query.py +620 -0
- package/bin/_lib_doctor.py +60 -1
- package/bin/_lib_jsonl.py +69 -50
- package/bin/_lib_transcript_access.py +80 -0
- package/bin/cctally +29 -2
- package/dashboard/static/assets/index-BGaWg6ys.js +47 -0
- package/dashboard/static/assets/{index-D34qf0LE.css → index-BqQ5xdX0.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-C2F1_Mxt.js +0 -18
|
@@ -12,8 +12,13 @@ exposure:
|
|
|
12
12
|
``--host`` / ``dashboard.bind`` config / ``--tz`` overrides; binds
|
|
13
13
|
the ``ThreadingHTTPServer``; spins up the snapshot sync thread, the
|
|
14
14
|
update-check thread, and the SSE hub; opens a browser when
|
|
15
|
-
``--open`` is set;
|
|
16
|
-
|
|
15
|
+
``--open`` is set; blocks on ``_dashboard_wait_for_signal`` for
|
|
16
|
+
``SIGINT`` / ``SIGTERM`` (lost-wakeup-proof per #154) then runs
|
|
17
|
+
clean shutdown.
|
|
18
|
+
- ``_dashboard_wait_for_signal`` — main-thread shutdown wait built on
|
|
19
|
+
``signal.set_wakeup_fd`` + ``select`` so a single SIGINT/SIGTERM
|
|
20
|
+
always tears the server down, immune to the ``threading.Event.wait()``
|
|
21
|
+
lost-wakeup race (#154).
|
|
17
22
|
- ``DashboardHTTPHandler`` — the stdlib ``BaseHTTPRequestHandler``
|
|
18
23
|
subclass that serves the static React bundle plus the entire
|
|
19
24
|
``/api/*`` surface (``data``, ``events``, ``sync``, ``refresh``,
|
|
@@ -286,7 +291,7 @@ from _lib_subscription_weeks import _compute_subscription_weeks
|
|
|
286
291
|
from _lib_blocks import _group_entries_into_blocks
|
|
287
292
|
from _cctally_config import save_config, _load_config_unlocked
|
|
288
293
|
from _cctally_db import _render_migration_error_banner
|
|
289
|
-
from _cctally_cache import get_entries
|
|
294
|
+
from _cctally_cache import get_entries, open_cache_db
|
|
290
295
|
|
|
291
296
|
|
|
292
297
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
@@ -347,6 +352,12 @@ def _build_alert_payload_project_budget(*args, **kwargs):
|
|
|
347
352
|
)
|
|
348
353
|
|
|
349
354
|
|
|
355
|
+
def _build_alert_payload_codex_budget(*args, **kwargs):
|
|
356
|
+
return sys.modules["cctally"]._build_alert_payload_codex_budget(
|
|
357
|
+
*args, **kwargs
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
350
361
|
def _dispatch_alert_notification(*args, **kwargs):
|
|
351
362
|
return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
|
|
352
363
|
|
|
@@ -4178,41 +4189,85 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
4178
4189
|
return out
|
|
4179
4190
|
|
|
4180
4191
|
|
|
4181
|
-
def
|
|
4182
|
-
#
|
|
4183
|
-
#
|
|
4184
|
-
#
|
|
4185
|
-
# ``
|
|
4186
|
-
# (``
|
|
4187
|
-
#
|
|
4188
|
-
#
|
|
4192
|
+
def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
4193
|
+
# Unified vendor-tagged budget axis (#143). ONE mapper backs BOTH the
|
|
4194
|
+
# ``budget`` (``vendor='claude'``, issue #19) and ``codex_budget``
|
|
4195
|
+
# (``vendor='codex'``, calendar-period-codex-budgets spec §6) axes —
|
|
4196
|
+
# ``descriptor.vendor`` drives the ``WHERE vendor=?`` row filter + the
|
|
4197
|
+
# ``COALESCE(period, <vendor-default-noun>)`` default, ``descriptor.id`` the
|
|
4198
|
+
# envelope id prefix, ``descriptor.milestone_table`` (now ``budget_milestones``
|
|
4199
|
+
# for both) the source table.
|
|
4200
|
+
#
|
|
4201
|
+
# Budget alerts are keyed by ``period_start_at`` (the resolved period-window
|
|
4202
|
+
# start instant — a subscription-week start for claude OR a calendar
|
|
4203
|
+
# period-start for codex) + the write-once ``period`` discriminator (#137) +
|
|
4204
|
+
# the integer threshold. No ``reset_event_id`` segment: a mid-week reset
|
|
4205
|
+
# (claude) or a period rollover (codex) re-anchors ``period_start_at`` so the
|
|
4206
|
+
# new window naturally gets fresh rows under
|
|
4207
|
+
# ``UNIQUE(vendor, period_start_at, period, threshold)``.
|
|
4208
|
+
#
|
|
4209
|
+
# All numbers + the ``period`` noun are read FROM THE ROW (snapshotted at
|
|
4210
|
+
# crossing), never live config that may have changed since (the Codex P0-4
|
|
4211
|
+
# lesson; Symptom 1 fix) — a user who fires alerts then switches
|
|
4212
|
+
# ``budget.period`` keeps the historical row's noun. ``COALESCE(period, …)``
|
|
4213
|
+
# renders a pre-011 NULL-sentinel row with the vendor-default noun and keeps
|
|
4214
|
+
# the ``id`` non-``None``; the id's ``period`` segment gives a calendar-week ↔
|
|
4215
|
+
# calendar-month coinciding-instant collision (now distinct coexisting rows)
|
|
4216
|
+
# distinct React keys.
|
|
4217
|
+
#
|
|
4218
|
+
# Byte-stable identity: the envelope id ==
|
|
4219
|
+
# ``f"{descriptor.id}:{period_start_at}:{period}:{threshold}"`` matches the
|
|
4220
|
+
# pre-#143 ``budget:…`` / ``codex_budget:…`` strings exactly (same instant
|
|
4221
|
+
# value, renamed key column). The per-vendor context dict KEY ORDER matches
|
|
4222
|
+
# the pre-#143 envelopes verbatim: the claude/``budget`` axis still emits BOTH
|
|
4223
|
+
# the legacy ``week_start_at`` AND ``period_start_at`` (same instant value) so
|
|
4224
|
+
# no existing TS consumer of ``context.week_start_at`` breaks; the
|
|
4225
|
+
# codex/``codex_budget`` axis emits ``period_start_at`` only.
|
|
4226
|
+
vendor = descriptor.vendor
|
|
4227
|
+
default_noun = "subscription-week" if vendor == "claude" else "calendar-month"
|
|
4189
4228
|
rows = conn.execute(
|
|
4190
4229
|
f"""
|
|
4191
|
-
SELECT
|
|
4230
|
+
SELECT period_start_at,
|
|
4231
|
+
COALESCE(period, ?) AS period,
|
|
4232
|
+
threshold, crossed_at_utc, alerted_at,
|
|
4192
4233
|
budget_usd, spent_usd, consumption_pct
|
|
4193
4234
|
FROM {descriptor.milestone_table}
|
|
4194
|
-
WHERE alerted_at IS NOT NULL
|
|
4235
|
+
WHERE vendor = ? AND alerted_at IS NOT NULL
|
|
4195
4236
|
ORDER BY alerted_at DESC
|
|
4196
4237
|
LIMIT ?
|
|
4197
4238
|
""",
|
|
4198
|
-
(limit
|
|
4239
|
+
(default_noun, vendor, limit),
|
|
4199
4240
|
).fetchall()
|
|
4200
4241
|
out: list[dict] = []
|
|
4201
4242
|
for r in rows:
|
|
4202
4243
|
threshold = int(r["threshold"])
|
|
4244
|
+
if vendor == "claude":
|
|
4245
|
+
ctx = { # key order byte-stable with the pre-#143 budget envelope
|
|
4246
|
+
"week_start_at": r["period_start_at"],
|
|
4247
|
+
"period": r["period"],
|
|
4248
|
+
"period_start_at": r["period_start_at"],
|
|
4249
|
+
"budget_usd": float(r["budget_usd"]),
|
|
4250
|
+
"spent_usd": float(r["spent_usd"]),
|
|
4251
|
+
"consumption_pct": float(r["consumption_pct"]),
|
|
4252
|
+
}
|
|
4253
|
+
else:
|
|
4254
|
+
ctx = { # key order byte-stable with the pre-#143 codex_budget envelope
|
|
4255
|
+
"period": r["period"],
|
|
4256
|
+
"period_start_at": r["period_start_at"],
|
|
4257
|
+
"budget_usd": float(r["budget_usd"]),
|
|
4258
|
+
"spent_usd": float(r["spent_usd"]),
|
|
4259
|
+
"consumption_pct": float(r["consumption_pct"]),
|
|
4260
|
+
}
|
|
4203
4261
|
out.append({
|
|
4204
|
-
"id":
|
|
4262
|
+
"id": (
|
|
4263
|
+
f"{descriptor.id}:{r['period_start_at']}:{r['period']}:{threshold}"
|
|
4264
|
+
),
|
|
4205
4265
|
"axis": descriptor.id,
|
|
4206
4266
|
"threshold": threshold,
|
|
4207
4267
|
"severity": severity_for(threshold),
|
|
4208
4268
|
"crossed_at": r["crossed_at_utc"],
|
|
4209
4269
|
"alerted_at": r["alerted_at"],
|
|
4210
|
-
"context":
|
|
4211
|
-
"week_start_at": r["week_start_at"],
|
|
4212
|
-
"budget_usd": float(r["budget_usd"]),
|
|
4213
|
-
"spent_usd": float(r["spent_usd"]),
|
|
4214
|
-
"consumption_pct": float(r["consumption_pct"]),
|
|
4215
|
-
},
|
|
4270
|
+
"context": ctx,
|
|
4216
4271
|
})
|
|
4217
4272
|
return out
|
|
4218
4273
|
|
|
@@ -4221,16 +4276,23 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
4221
4276
|
# Fourth axis (issue #121): projected-pace threshold crossings. Like
|
|
4222
4277
|
# budget, projected alerts re-anchor ``week_start_at`` on a mid-week
|
|
4223
4278
|
# reset, so there is NO ``reset_event_id`` segment — the new window gets
|
|
4224
|
-
# fresh rows under ``UNIQUE(week_start_at, metric, threshold)``. The
|
|
4225
|
-
# ``metric`` discriminator (``weekly_pct`` | ``budget_usd``
|
|
4226
|
-
# frontend's metric-aware context renderer;
|
|
4227
|
-
# ``projected_value`` are rendered FROM THE ROW (the values
|
|
4228
|
-
# crossing), never live config that may have changed since
|
|
4229
|
-
# The envelope id mirrors the dispatch payload's
|
|
4230
|
-
# ``projected:<week_start_at>:<metric>:<threshold>`` shape.
|
|
4279
|
+
# fresh rows under ``UNIQUE(week_start_at, period, metric, threshold)``. The
|
|
4280
|
+
# ``metric`` discriminator (``weekly_pct`` | ``budget_usd`` |
|
|
4281
|
+
# ``codex_budget_usd``) drives the frontend's metric-aware context renderer;
|
|
4282
|
+
# ``denominator`` + ``projected_value`` are rendered FROM THE ROW (the values
|
|
4283
|
+
# snapshotted at crossing), never live config that may have changed since
|
|
4284
|
+
# (Codex P0-4). The envelope id mirrors the dispatch payload's
|
|
4285
|
+
# ``projected:<week_start_at>:<period>:<metric>:<threshold>`` shape. The
|
|
4286
|
+
# write-once ``period`` discriminator (#137) carries no symptom-1 label here
|
|
4287
|
+
# — projected's ``context`` is metric-driven, never a live-config period
|
|
4288
|
+
# noun — so ``COALESCE(period, 'subscription-week')`` is purely for a stable
|
|
4289
|
+
# non-``None`` id segment on a pre-011 NULL-sentinel row (the calendar-week ↔
|
|
4290
|
+
# calendar-month within-metric collision otherwise shares a React key).
|
|
4231
4291
|
rows = conn.execute(
|
|
4232
4292
|
f"""
|
|
4233
|
-
SELECT week_start_at,
|
|
4293
|
+
SELECT week_start_at,
|
|
4294
|
+
COALESCE(period, 'subscription-week') AS period,
|
|
4295
|
+
metric, threshold, projected_value,
|
|
4234
4296
|
denominator, crossed_at_utc, alerted_at
|
|
4235
4297
|
FROM {descriptor.milestone_table}
|
|
4236
4298
|
WHERE alerted_at IS NOT NULL
|
|
@@ -4244,7 +4306,10 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
4244
4306
|
threshold = int(r["threshold"])
|
|
4245
4307
|
metric = str(r["metric"])
|
|
4246
4308
|
out.append({
|
|
4247
|
-
"id":
|
|
4309
|
+
"id": (
|
|
4310
|
+
f"projected:{r['week_start_at']}:{r['period']}"
|
|
4311
|
+
f":{metric}:{threshold}"
|
|
4312
|
+
),
|
|
4248
4313
|
"axis": descriptor.id,
|
|
4249
4314
|
"metric": metric,
|
|
4250
4315
|
"threshold": threshold,
|
|
@@ -4323,12 +4388,17 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
|
|
|
4323
4388
|
|
|
4324
4389
|
# Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
|
|
4325
4390
|
# in what order; this table supplies the bespoke heterogeneous row-mapper.
|
|
4391
|
+
# ``budget`` (vendor='claude') and ``codex_budget`` (vendor='codex') share the
|
|
4392
|
+
# one ``_envelope_rows_budget_family`` mapper (#143) — it reads
|
|
4393
|
+
# ``descriptor.vendor`` for the row filter + default noun and ``descriptor.id``
|
|
4394
|
+
# for the envelope id prefix, so the two axes stay distinct on the wire.
|
|
4326
4395
|
_ENVELOPE_AXIS_MAPPERS = {
|
|
4327
4396
|
"weekly": _envelope_rows_weekly,
|
|
4328
4397
|
"five_hour": _envelope_rows_five_hour,
|
|
4329
|
-
"budget":
|
|
4398
|
+
"budget": _envelope_rows_budget_family,
|
|
4330
4399
|
"projected": _envelope_rows_projected,
|
|
4331
4400
|
"project_budget": _envelope_rows_project_budget,
|
|
4401
|
+
"codex_budget": _envelope_rows_budget_family,
|
|
4332
4402
|
}
|
|
4333
4403
|
|
|
4334
4404
|
|
|
@@ -4339,20 +4409,25 @@ def _build_alerts_envelope_array(
|
|
|
4339
4409
|
"""Return the ``alerts`` array for the SSE snapshot envelope.
|
|
4340
4410
|
|
|
4341
4411
|
Union of ``percent_milestones``, ``five_hour_milestones``,
|
|
4342
|
-
``budget_milestones
|
|
4343
|
-
``
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4412
|
+
``budget_milestones`` (vendor-tagged — backs BOTH the ``budget`` and
|
|
4413
|
+
``codex_budget`` axes since #143), ``projected_milestones``, and
|
|
4414
|
+
``project_budget_milestones`` rows with
|
|
4415
|
+
``alerted_at IS NOT NULL``, ordered newest-first by ``alerted_at``, capped at
|
|
4416
|
+
``limit`` (default 100). Single source of truth for both the dashboard panel
|
|
4417
|
+
(slices to 10 client-side) and the modal (renders all 100). Forward-only
|
|
4418
|
+
semantics: only rows the alert-dispatch path stamped get included; pre-deploy
|
|
4419
|
+
crossings stay NULL and are intentionally invisible (spec §4.3).
|
|
4420
|
+
|
|
4421
|
+
All six axes share the same envelope schema; the ``axis`` field
|
|
4351
4422
|
(``weekly`` / ``five_hour`` / ``budget`` / ``projected`` /
|
|
4352
|
-
``project_budget``) discriminates. The ``projected`` axis
|
|
4353
|
-
carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``
|
|
4354
|
-
frontend can pick its metric-aware context
|
|
4355
|
-
|
|
4423
|
+
``project_budget`` / ``codex_budget``) discriminates. The ``projected`` axis
|
|
4424
|
+
additionally carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``
|
|
4425
|
+
| ``codex_budget_usd``) so the frontend can pick its metric-aware context
|
|
4426
|
+
renderer; ``project_budget``
|
|
4427
|
+
carries the project basename + ``$spent of $budget`` in its context;
|
|
4428
|
+
``budget`` + ``codex_budget`` carry a ``period`` discriminator
|
|
4429
|
+
(subscription-week / calendar-week / calendar-month) so the frontend renders
|
|
4430
|
+
a period-aware "Month" / "Calendar week" / "Week" label.
|
|
4356
4431
|
|
|
4357
4432
|
Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
|
|
4358
4433
|
up to ``limit``) and the union is re-sorted + sliced — important for
|
|
@@ -4792,6 +4867,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4792
4867
|
# getter's ``project_alerts_enabled`` (default False) — the frontend
|
|
4793
4868
|
# SettingsOverlay seeds a single on/off toggle from it.
|
|
4794
4869
|
"project_alerts_enabled": bool(_budget_cfg.get("project_alerts_enabled")),
|
|
4870
|
+
# Codex budget toggle mirrors (#134). The frontend SettingsOverlay
|
|
4871
|
+
# seeds two toggles (alerts + projected) + a disabled-with-hint empty
|
|
4872
|
+
# state from these three flags. ``_budget_cfg["codex"]`` is ``None`` by
|
|
4873
|
+
# default (no Codex budget) → all three default false/absent safely;
|
|
4874
|
+
# the ``_BudgetConfigError`` fallback dict above lacks a ``codex`` key,
|
|
4875
|
+
# so ``.get("codex")`` → ``None`` is likewise safe.
|
|
4876
|
+
"codex_budget_configured": _budget_cfg.get("codex") is not None,
|
|
4877
|
+
"codex_budget_alerts_enabled": bool((_budget_cfg.get("codex") or {}).get("alerts_enabled")),
|
|
4878
|
+
"codex_projected_enabled": bool((_budget_cfg.get("codex") or {}).get("projected_enabled")),
|
|
4795
4879
|
# Alert-dispatch notifier mirror (Phase B). `notifier` is the
|
|
4796
4880
|
# validated backend selector ("auto"/"command"/etc.). The raw
|
|
4797
4881
|
# `command_template` is NEVER mirrored — it routinely holds secrets
|
|
@@ -5106,6 +5190,34 @@ _DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS = 2.0
|
|
|
5106
5190
|
# Pre-extract location: bin/cctally L17694.
|
|
5107
5191
|
|
|
5108
5192
|
|
|
5193
|
+
def _qs_int(q: dict, key: str, default: int) -> int:
|
|
5194
|
+
"""Parse a single query-string int with a fallback.
|
|
5195
|
+
|
|
5196
|
+
``q`` is a ``urllib.parse.parse_qs`` mapping (list-valued). A missing key,
|
|
5197
|
+
an empty value, or a non-integer spelling all fall back to ``default`` —
|
|
5198
|
+
the kernels clamp bounds, so this only needs to be permissive, not strict.
|
|
5199
|
+
"""
|
|
5200
|
+
vals = q.get(key, [None])
|
|
5201
|
+
raw = vals[0] if vals else None
|
|
5202
|
+
try:
|
|
5203
|
+
return int(raw) if raw is not None else default
|
|
5204
|
+
except (TypeError, ValueError):
|
|
5205
|
+
return default
|
|
5206
|
+
|
|
5207
|
+
|
|
5208
|
+
def _qs_str(q: dict, key: str, default: str | None) -> str | None:
|
|
5209
|
+
"""Parse a single query-string value with a fallback.
|
|
5210
|
+
|
|
5211
|
+
``q`` is a ``urllib.parse.parse_qs`` mapping (list-valued). A missing key
|
|
5212
|
+
or an empty list falls back to ``default``. The string sibling of
|
|
5213
|
+
``_qs_int`` — collapses the ``(q.get(key, [default]) or [default])[0]``
|
|
5214
|
+
idiom the conversation handlers hand-respelled (a ``None`` default is fine,
|
|
5215
|
+
so the reader's ``after`` cursor routes through it too).
|
|
5216
|
+
"""
|
|
5217
|
+
vals = q.get(key, [default])
|
|
5218
|
+
return vals[0] if vals else default
|
|
5219
|
+
|
|
5220
|
+
|
|
5109
5221
|
class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
5110
5222
|
"""Routes:
|
|
5111
5223
|
GET / → dashboard.html
|
|
@@ -5146,6 +5258,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5146
5258
|
# path leaves this None and only sees the `config.json` view, which
|
|
5147
5259
|
# is the Codex H4 finding. Set by cmd_dashboard before serve_forever.
|
|
5148
5260
|
cctally_host: "str | None" = None
|
|
5261
|
+
# Conversation viewer (Plan 2, spec §5): the resolved
|
|
5262
|
+
# `dashboard.expose_transcripts` opt-in. False = transcript endpoints
|
|
5263
|
+
# are served only over loopback; True = LAN devices reach them at the
|
|
5264
|
+
# bind's IP literal (anti-DNS-rebinding still rejects hostnames). Set by
|
|
5265
|
+
# cmd_dashboard before serve_forever; the per-request gate
|
|
5266
|
+
# (`_require_transcripts_allowed`) ANDs this with the request Host.
|
|
5267
|
+
cctally_expose_transcripts: bool = False
|
|
5149
5268
|
|
|
5150
5269
|
# Silence the default per-request access log — noisy in the parent
|
|
5151
5270
|
# terminal, and we pipe it through our own logger in cmd_dashboard.
|
|
@@ -5218,6 +5337,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5218
5337
|
self._handle_share_history_get()
|
|
5219
5338
|
elif path == "/api/doctor":
|
|
5220
5339
|
self._handle_get_doctor()
|
|
5340
|
+
elif path == "/api/conversations":
|
|
5341
|
+
self._handle_get_conversations()
|
|
5342
|
+
elif path == "/api/conversation/search":
|
|
5343
|
+
self._handle_get_conversation_search()
|
|
5344
|
+
elif path.startswith("/api/conversation/"):
|
|
5345
|
+
self._handle_get_conversation_detail(path)
|
|
5221
5346
|
else:
|
|
5222
5347
|
self.send_error(404, "not found")
|
|
5223
5348
|
|
|
@@ -5377,6 +5502,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5377
5502
|
else:
|
|
5378
5503
|
self._respond_json(403, {"error": reason})
|
|
5379
5504
|
|
|
5505
|
+
@staticmethod
|
|
5506
|
+
def _transcript_gate():
|
|
5507
|
+
"""Lazy-load the pure transcript-access gate kernel (Plan 2, §5)."""
|
|
5508
|
+
return sys.modules["cctally"]._load_sibling("_lib_transcript_access")
|
|
5509
|
+
|
|
5510
|
+
def _transcripts_visible_to_request(self) -> bool:
|
|
5511
|
+
"""Single source of truth for "may transcripts be served to THIS
|
|
5512
|
+
request?" Composes the bind gate (`transcripts_allowed`) with the
|
|
5513
|
+
per-request Host allowlist (`host_allowed_for_transcripts`,
|
|
5514
|
+
anti-DNS-rebinding). Spec §5.
|
|
5515
|
+
|
|
5516
|
+
`_require_transcripts_allowed` (the GET-route 403 gate) and the
|
|
5517
|
+
`transcriptsEnabled` client signal on BOTH the `/api/data` route
|
|
5518
|
+
(`_serve_api_data`) and the SSE stream (`_serve_api_events`) route
|
|
5519
|
+
through this predicate so they are contractually identical — a future
|
|
5520
|
+
one-line drift can never re-introduce the enabled-then-403 desync.
|
|
5521
|
+
"""
|
|
5522
|
+
ta = self._transcript_gate()
|
|
5523
|
+
expose = bool(type(self).cctally_expose_transcripts)
|
|
5524
|
+
return (ta.transcripts_allowed(type(self).cctally_host, expose)
|
|
5525
|
+
and ta.host_allowed_for_transcripts(
|
|
5526
|
+
self.headers.get("Host", ""), expose))
|
|
5527
|
+
|
|
5528
|
+
def _require_transcripts_allowed(self) -> bool:
|
|
5529
|
+
"""True if transcripts may be served to THIS request; else emit 403 and
|
|
5530
|
+
return False. Spec §5.
|
|
5531
|
+
"""
|
|
5532
|
+
if not self._transcripts_visible_to_request():
|
|
5533
|
+
self._respond_403("transcripts not exposed")
|
|
5534
|
+
return False
|
|
5535
|
+
return True
|
|
5536
|
+
|
|
5380
5537
|
def _handle_post_settings(self) -> None:
|
|
5381
5538
|
"""Persist a settings update and trigger an immediate SSE broadcast.
|
|
5382
5539
|
|
|
@@ -5384,8 +5541,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5384
5541
|
"update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}},
|
|
5385
5542
|
"cache_report"?: {"anomaly_threshold_pp"?: int},
|
|
5386
5543
|
"budget"?: {"weekly_usd"?: number|null, "alerts_enabled"?: bool,
|
|
5387
|
-
"alert_thresholds"?: int[], "projected_enabled"?: bool
|
|
5388
|
-
|
|
5544
|
+
"alert_thresholds"?: int[], "projected_enabled"?: bool,
|
|
5545
|
+
"codex"?: {"alerts_enabled"?: bool, "projected_enabled"?: bool}}}``
|
|
5546
|
+
— every top-level key is optional; any subset may be sent together
|
|
5389
5547
|
(combined save). Unknown top-level keys are rejected with 400.
|
|
5390
5548
|
|
|
5391
5549
|
Per-block validation:
|
|
@@ -5412,6 +5570,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5412
5570
|
block and validated via ``_get_budget_config(merged)`` (issue
|
|
5413
5571
|
#19, projected toggle #121); ``_BudgetConfigError`` → 400.
|
|
5414
5572
|
Budget is its OWN config block, distinct from ``alerts``.
|
|
5573
|
+
``budget.codex`` (#134) is a nested partial-merge: only the two
|
|
5574
|
+
toggles (``alerts_enabled`` / ``projected_enabled``) are
|
|
5575
|
+
dashboard-writable — ``amount_usd`` / ``period`` /
|
|
5576
|
+
``alert_thresholds`` stay CLI-only and are preserved from the
|
|
5577
|
+
persisted block. A non-dict ``budget.codex`` → 400; toggling
|
|
5578
|
+
``budget.codex.*`` when no Codex budget is configured → 400
|
|
5579
|
+
(fail-closed; amounts are never invented from the dashboard).
|
|
5415
5580
|
|
|
5416
5581
|
Atomic merged write: if all touched blocks validate, the merged
|
|
5417
5582
|
config is persisted in a single ``save_config`` call inside the
|
|
@@ -5755,6 +5920,36 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5755
5920
|
):
|
|
5756
5921
|
if leaf in budget_in:
|
|
5757
5922
|
merged_budget[leaf] = budget_in[leaf]
|
|
5923
|
+
# Nested partial-merge for the Codex sub-block (#134). Mirrors
|
|
5924
|
+
# the ``update.check`` nested-dict merge below: only the two
|
|
5925
|
+
# alert toggles (``alerts_enabled`` / ``projected_enabled``) are
|
|
5926
|
+
# dashboard-writable — ``amount_usd`` / ``period`` /
|
|
5927
|
+
# ``alert_thresholds`` stay CLI-only (ignored if sent), and the
|
|
5928
|
+
# merge preserves them from the persisted block rather than
|
|
5929
|
+
# replacing the whole ``codex`` dict (the clobber regression).
|
|
5930
|
+
if "codex" in budget_in:
|
|
5931
|
+
incoming_codex = budget_in["codex"]
|
|
5932
|
+
if not isinstance(incoming_codex, dict):
|
|
5933
|
+
self._respond_json(
|
|
5934
|
+
400, {"error": "budget.codex must be an object"}
|
|
5935
|
+
)
|
|
5936
|
+
return
|
|
5937
|
+
existing_codex = merged_budget.get("codex")
|
|
5938
|
+
if existing_codex is None:
|
|
5939
|
+
# Fail closed: the dashboard only TOGGLES an existing
|
|
5940
|
+
# Codex budget — amounts are CLI-only — so it must never
|
|
5941
|
+
# invent one. The frontend disables the toggle, this
|
|
5942
|
+
# backstops a direct POST.
|
|
5943
|
+
self._respond_json(400, {"error": (
|
|
5944
|
+
"no Codex budget configured — set one via the CLI "
|
|
5945
|
+
"first (cctally budget set <amount> --vendor codex)"
|
|
5946
|
+
)})
|
|
5947
|
+
return
|
|
5948
|
+
merged_codex = dict(existing_codex)
|
|
5949
|
+
for sub in ("alerts_enabled", "projected_enabled"):
|
|
5950
|
+
if sub in incoming_codex:
|
|
5951
|
+
merged_codex[sub] = bool(incoming_codex[sub])
|
|
5952
|
+
merged_budget["codex"] = merged_codex
|
|
5758
5953
|
merged["budget"] = merged_budget
|
|
5759
5954
|
# Final validation against the merged block.
|
|
5760
5955
|
# _BudgetConfigError → 400 (no partial write — save_config
|
|
@@ -5855,12 +6050,32 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5855
6050
|
touched = (
|
|
5856
6051
|
set(budget_in.keys()) if isinstance(budget_in, dict) else set()
|
|
5857
6052
|
)
|
|
5858
|
-
|
|
6053
|
+
# ``period`` included (#143 §5.4 fold-in): switching budget.period
|
|
6054
|
+
# via the dashboard while already over a threshold must reconcile
|
|
6055
|
+
# forward-only, exactly like the CLI `config set budget.period` path
|
|
6056
|
+
# (`_cctally_config.py`) — else the next record-usage tick would
|
|
6057
|
+
# instant-popup retroactive alerts under the new period window.
|
|
6058
|
+
if touched & {"weekly_usd", "alerts_enabled", "alert_thresholds", "period"}:
|
|
5859
6059
|
_cctally()._reconcile_budget_on_config_write(validated_budget)
|
|
5860
6060
|
if touched & {"project_alerts_enabled", "alert_thresholds"}:
|
|
5861
6061
|
_cctally()._reconcile_project_budget_milestones_on_write(
|
|
5862
6062
|
validated_budget
|
|
5863
6063
|
)
|
|
6064
|
+
# Codex actual-spend reconcile (#134). Key it on the ``alerts_enabled``
|
|
6065
|
+
# SUB-leaf specifically — NOT ``"codex" in touched``. The helper
|
|
6066
|
+
# (`_reconcile_codex_budget_on_config_write`) is itself gated on
|
|
6067
|
+
# alerts_enabled && amount && thresholds, so a coarse "codex touched"
|
|
6068
|
+
# check would run it whenever alerts is already True — meaning a
|
|
6069
|
+
# ``projected_enabled``-only toggle would latch (silently suppress) a
|
|
6070
|
+
# still-unfired actual-spend crossing. Keying on the sub-leaf means
|
|
6071
|
+
# flipping alerts ON latches already-crossed thresholds (intended),
|
|
6072
|
+
# while toggling projected reconciles nothing (projected stays
|
|
6073
|
+
# live-pace, Q4).
|
|
6074
|
+
codex_in = budget_in.get("codex")
|
|
6075
|
+
if isinstance(codex_in, dict) and "alerts_enabled" in codex_in:
|
|
6076
|
+
_cctally()._reconcile_codex_budget_on_config_write(
|
|
6077
|
+
validated_budget
|
|
6078
|
+
)
|
|
5864
6079
|
if update_check_validated is not None:
|
|
5865
6080
|
# Echo the full merged check block (cooked defaults included)
|
|
5866
6081
|
# so the SettingsOverlay can repaint without a follow-up GET.
|
|
@@ -5959,25 +6174,30 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5959
6174
|
axis = body.get("axis", "weekly")
|
|
5960
6175
|
if axis not in (
|
|
5961
6176
|
"weekly", "five_hour", "budget", "projected", "project_budget",
|
|
6177
|
+
"codex_budget",
|
|
5962
6178
|
):
|
|
5963
6179
|
self._respond_json(
|
|
5964
6180
|
400,
|
|
5965
6181
|
{"error": (
|
|
5966
6182
|
"axis must be 'weekly', 'five_hour', 'budget', "
|
|
5967
|
-
|
|
6183
|
+
"'projected', 'project_budget' or 'codex_budget', "
|
|
6184
|
+
f"got {axis!r}"
|
|
5968
6185
|
)},
|
|
5969
6186
|
)
|
|
5970
6187
|
return
|
|
5971
6188
|
# ``metric`` discriminates the projected axis (weekly_pct vs
|
|
5972
|
-
# budget_usd); the other axes ignore it. Validate
|
|
5973
|
-
# matters so a stray metric on a weekly/budget test isn't
|
|
6189
|
+
# budget_usd vs codex_budget_usd); the other axes ignore it. Validate
|
|
6190
|
+
# only when it matters so a stray metric on a weekly/budget test isn't
|
|
6191
|
+
# a 400.
|
|
5974
6192
|
metric = body.get("metric", "weekly_pct")
|
|
5975
|
-
if axis == "projected" and metric not in (
|
|
6193
|
+
if axis == "projected" and metric not in (
|
|
6194
|
+
"weekly_pct", "budget_usd", "codex_budget_usd",
|
|
6195
|
+
):
|
|
5976
6196
|
self._respond_json(
|
|
5977
6197
|
400,
|
|
5978
6198
|
{"error": (
|
|
5979
|
-
"metric must be 'weekly_pct'
|
|
5980
|
-
f"got {metric!r}"
|
|
6199
|
+
"metric must be 'weekly_pct', 'budget_usd' or "
|
|
6200
|
+
f"'codex_budget_usd', got {metric!r}"
|
|
5981
6201
|
)},
|
|
5982
6202
|
)
|
|
5983
6203
|
return
|
|
@@ -6031,6 +6251,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6031
6251
|
spent_usd=26.0,
|
|
6032
6252
|
consumption_pct=104.0,
|
|
6033
6253
|
)
|
|
6254
|
+
elif axis == "codex_budget":
|
|
6255
|
+
# Synthetic Codex budget payload — mirrors the CLI
|
|
6256
|
+
# ``alerts test --axis codex-budget`` branch (NO DB writes,
|
|
6257
|
+
# test/real divergence contract, NO real budget.codex entry
|
|
6258
|
+
# required). A $200 calendar-month budget reads plausibly; spent
|
|
6259
|
+
# scaled to the threshold so the body line reads as the
|
|
6260
|
+
# at-crossing snapshot the dashboard would render (R4).
|
|
6261
|
+
payload = _build_alert_payload_codex_budget(
|
|
6262
|
+
threshold=threshold,
|
|
6263
|
+
crossed_at_utc=now_utc_iso(),
|
|
6264
|
+
period_start_at=dt.date.today().replace(day=1).isoformat(),
|
|
6265
|
+
period="calendar-month",
|
|
6266
|
+
budget_usd=200.0,
|
|
6267
|
+
spent_usd=200.0 * threshold / 100.0,
|
|
6268
|
+
consumption_pct=float(threshold),
|
|
6269
|
+
)
|
|
6034
6270
|
elif axis == "projected":
|
|
6035
6271
|
# Synthetic projected-pace payload — mirrors the CLI
|
|
6036
6272
|
# cmd_alerts_test projected branch (NO DB writes, test/real
|
|
@@ -6039,10 +6275,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6039
6275
|
# value (so the body reads plausibly, e.g. weekly 100% → "~100% of
|
|
6040
6276
|
# cap", budget 100% → "$300 of $300"). denominator is the
|
|
6041
6277
|
# at-crossing target the row would carry (Codex P0-4): 100.0 for
|
|
6042
|
-
# weekly_pct, $300 for budget_usd
|
|
6278
|
+
# weekly_pct, $300 for budget_usd, $200 for codex_budget_usd
|
|
6279
|
+
# (matching the codex_budget axis test-alert budget).
|
|
6043
6280
|
if metric == "budget_usd":
|
|
6044
6281
|
denominator = 300.0
|
|
6045
6282
|
projected_value = 300.0 * threshold / 100.0
|
|
6283
|
+
elif metric == "codex_budget_usd":
|
|
6284
|
+
denominator = 200.0
|
|
6285
|
+
projected_value = 200.0 * threshold / 100.0
|
|
6046
6286
|
else: # weekly_pct
|
|
6047
6287
|
denominator = 100.0
|
|
6048
6288
|
projected_value = float(threshold)
|
|
@@ -6901,6 +7141,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6901
7141
|
display_tz_pref_override=type(self).display_tz_pref_override,
|
|
6902
7142
|
runtime_bind=type(self).cctally_host,
|
|
6903
7143
|
)
|
|
7144
|
+
# Conversation viewer (Plan 2, spec §5): inject the client signal
|
|
7145
|
+
# PER-REQUEST and Host-aware — NOT inside snapshot_to_envelope (the
|
|
7146
|
+
# request-independent SSE snapshot has no Host header). Routes through
|
|
7147
|
+
# the SAME predicate as the transcript GET-route gate so a LAN-hostname
|
|
7148
|
+
# request that the transcript GETs would 403 shows
|
|
7149
|
+
# transcriptsEnabled=false, never enabled-then-403 (the pass-2 P2
|
|
7150
|
+
# finding) — one predicate, two consumers, desync impossible.
|
|
7151
|
+
env["transcriptsEnabled"] = self._transcripts_visible_to_request()
|
|
6904
7152
|
body = json.dumps(env, ensure_ascii=False).encode("utf-8")
|
|
6905
7153
|
self.send_response(200)
|
|
6906
7154
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
@@ -6990,6 +7238,112 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6990
7238
|
self.end_headers()
|
|
6991
7239
|
self.wfile.write(body)
|
|
6992
7240
|
|
|
7241
|
+
@staticmethod
|
|
7242
|
+
def _conversation_query():
|
|
7243
|
+
"""Lazy-load the pure conversation query kernel (Plan 2, §3)."""
|
|
7244
|
+
return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
|
|
7245
|
+
|
|
7246
|
+
def _run_conversation_query(self, kernel_call, log_label):
|
|
7247
|
+
"""Open cache.db, run ``kernel_call(conn)``, close — with the uniform
|
|
7248
|
+
500 envelopes the three conversation routes share (#151).
|
|
7249
|
+
|
|
7250
|
+
Collapses the triplicated open-cache → try/except/finally → 500
|
|
7251
|
+
scaffold to one site. Returns ``(ok, body)``: ``ok=False`` means a 500
|
|
7252
|
+
has ALREADY been sent and the caller must just ``return``; ``ok=True``
|
|
7253
|
+
carries the kernel result (which may itself be ``None`` — the reader's
|
|
7254
|
+
404 sentinel — so the explicit flag, not ``body is None``, signals
|
|
7255
|
+
failure). An ``open_cache_db`` failure is a ``cache unavailable:`` 500;
|
|
7256
|
+
a kernel exception is logged as ``<log_label> failed: %r`` and returned
|
|
7257
|
+
as a ``{type}: {msg}`` 500 — byte-identical to the inlined handlers.
|
|
7258
|
+
"""
|
|
7259
|
+
try:
|
|
7260
|
+
conn = open_cache_db()
|
|
7261
|
+
except (sqlite3.DatabaseError, OSError) as exc:
|
|
7262
|
+
self._respond_json(500, {"error": f"cache unavailable: {exc}"})
|
|
7263
|
+
return False, None
|
|
7264
|
+
try:
|
|
7265
|
+
body = kernel_call(conn)
|
|
7266
|
+
except Exception as exc: # noqa: BLE001
|
|
7267
|
+
self.log_error("%s failed: %r", log_label, exc)
|
|
7268
|
+
self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
|
|
7269
|
+
return False, None
|
|
7270
|
+
finally:
|
|
7271
|
+
conn.close()
|
|
7272
|
+
return True, body
|
|
7273
|
+
|
|
7274
|
+
def _handle_get_conversations(self) -> None:
|
|
7275
|
+
"""``GET /api/conversations`` — the browse rail (spec §3.1).
|
|
7276
|
+
|
|
7277
|
+
Gated first (loopback / Host allowlist). ``sort``/``limit``/``offset``
|
|
7278
|
+
are read from the query string; the kernel clamps bounds. Cache-open
|
|
7279
|
+
failures are 500s, never 5xx-with-stacktrace.
|
|
7280
|
+
"""
|
|
7281
|
+
if not self._require_transcripts_allowed():
|
|
7282
|
+
return
|
|
7283
|
+
import urllib.parse as _u
|
|
7284
|
+
q = _u.parse_qs(self.path.partition("?")[2])
|
|
7285
|
+
sort = _qs_str(q, "sort", "recent")
|
|
7286
|
+
limit = _qs_int(q, "limit", 50)
|
|
7287
|
+
offset = _qs_int(q, "offset", 0)
|
|
7288
|
+
ok, body = self._run_conversation_query(
|
|
7289
|
+
lambda conn: self._conversation_query().list_conversations(
|
|
7290
|
+
conn, sort=sort, limit=limit, offset=offset),
|
|
7291
|
+
"/api/conversations")
|
|
7292
|
+
if not ok:
|
|
7293
|
+
return
|
|
7294
|
+
self._respond_json(200, body)
|
|
7295
|
+
|
|
7296
|
+
def _handle_get_conversation_detail(self, path: str) -> None:
|
|
7297
|
+
"""``GET /api/conversation/<session-id>`` — the reader (spec §3.2).
|
|
7298
|
+
|
|
7299
|
+
The id is percent-decoded so clients that encode reserved chars
|
|
7300
|
+
round-trip. Unknown id → 404. ``after``/``limit`` page the items.
|
|
7301
|
+
"""
|
|
7302
|
+
if not self._require_transcripts_allowed():
|
|
7303
|
+
return
|
|
7304
|
+
import urllib.parse as _u
|
|
7305
|
+
# ``path`` is already query-stripped by ``do_GET`` (``self.path.split("?")``),
|
|
7306
|
+
# so the cursor params (?after=/?limit=) live ONLY on the raw ``self.path``.
|
|
7307
|
+
# Sibling handlers read ``self.path`` directly — the detail route must too,
|
|
7308
|
+
# or every request re-serves the head and pagination is dead.
|
|
7309
|
+
query_str = self.path.partition("?")[2]
|
|
7310
|
+
session_id = _u.unquote(path[len("/api/conversation/"):])
|
|
7311
|
+
if not session_id:
|
|
7312
|
+
self.send_error(404, "conversation not found")
|
|
7313
|
+
return
|
|
7314
|
+
q = _u.parse_qs(query_str)
|
|
7315
|
+
after = _qs_str(q, "after", None)
|
|
7316
|
+
limit = _qs_int(q, "limit", 500)
|
|
7317
|
+
ok, body = self._run_conversation_query(
|
|
7318
|
+
lambda conn: self._conversation_query().get_conversation(
|
|
7319
|
+
conn, session_id, after=after, limit=limit),
|
|
7320
|
+
"/api/conversation")
|
|
7321
|
+
if not ok:
|
|
7322
|
+
return
|
|
7323
|
+
if body is None:
|
|
7324
|
+
self.send_error(404, "conversation not found")
|
|
7325
|
+
return
|
|
7326
|
+
self._respond_json(200, body)
|
|
7327
|
+
|
|
7328
|
+
def _handle_get_conversation_search(self) -> None:
|
|
7329
|
+
"""``GET /api/conversation/search?q=...`` — cross-session FTS/LIKE
|
|
7330
|
+
search (spec §3.3). Matched BEFORE the ``<id>`` reader in ``do_GET``.
|
|
7331
|
+
"""
|
|
7332
|
+
if not self._require_transcripts_allowed():
|
|
7333
|
+
return
|
|
7334
|
+
import urllib.parse as _u
|
|
7335
|
+
q = _u.parse_qs(self.path.partition("?")[2])
|
|
7336
|
+
query = _qs_str(q, "q", "")
|
|
7337
|
+
limit = _qs_int(q, "limit", 50)
|
|
7338
|
+
offset = _qs_int(q, "offset", 0)
|
|
7339
|
+
ok, body = self._run_conversation_query(
|
|
7340
|
+
lambda conn: self._conversation_query().search_conversations(
|
|
7341
|
+
conn, query, limit=limit, offset=offset),
|
|
7342
|
+
"/api/conversation/search")
|
|
7343
|
+
if not ok:
|
|
7344
|
+
return
|
|
7345
|
+
self._respond_json(200, body)
|
|
7346
|
+
|
|
6993
7347
|
def _handle_get_project_detail(self) -> None:
|
|
6994
7348
|
"""Return ProjectDetail JSON for ``GET /api/project/<key>?weeks=N``
|
|
6995
7349
|
(spec §5.3 / §6.5).
|
|
@@ -7162,6 +7516,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7162
7516
|
except OauthUsageConfigError:
|
|
7163
7517
|
cfg_oauth = dict(sys.modules["cctally"]._OAUTH_USAGE_DEFAULTS)
|
|
7164
7518
|
|
|
7519
|
+
# Conversation viewer (Plan 2, spec §5): resolve the transcript gate
|
|
7520
|
+
# ONCE per SSE connection. `_transcripts_visible_to_request()` reads
|
|
7521
|
+
# this client's `Host` header + the class-level expose flag — both
|
|
7522
|
+
# constant for the connection's lifetime, so no per-tick FS/header
|
|
7523
|
+
# reads. The SSE `update` envelope MUST carry `transcriptsEnabled`:
|
|
7524
|
+
# the client replaces the whole snapshot on every tick, so without it
|
|
7525
|
+
# the steady-state UI loses the gate (the ViewSwitcher disappears
|
|
7526
|
+
# ~15s after bootstrap). Mirrors `/api/data`'s per-request injection.
|
|
7527
|
+
transcripts_enabled = self._transcripts_visible_to_request()
|
|
7528
|
+
|
|
7165
7529
|
q = self.hub.subscribe()
|
|
7166
7530
|
try:
|
|
7167
7531
|
while True:
|
|
@@ -7181,6 +7545,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7181
7545
|
display_tz_pref_override=type(self).display_tz_pref_override,
|
|
7182
7546
|
runtime_bind=type(self).cctally_host,
|
|
7183
7547
|
)
|
|
7548
|
+
env["transcriptsEnabled"] = transcripts_enabled
|
|
7184
7549
|
msg = (
|
|
7185
7550
|
"event: update\n"
|
|
7186
7551
|
+ "data: " + json.dumps(env, ensure_ascii=False) + "\n\n"
|
|
@@ -7388,6 +7753,82 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7388
7753
|
# === cmd_dashboard (dashboard subcommand entry point) =====================
|
|
7389
7754
|
# Pre-extract location: bin/cctally L21478.
|
|
7390
7755
|
|
|
7756
|
+
def _dashboard_wait_for_signal(
|
|
7757
|
+
signals,
|
|
7758
|
+
*,
|
|
7759
|
+
on_signal=None,
|
|
7760
|
+
timeout=None,
|
|
7761
|
+
):
|
|
7762
|
+
"""Block the calling (main) thread until one of ``signals`` is delivered.
|
|
7763
|
+
|
|
7764
|
+
The wait is driven by a self-pipe wakeup fd (``signal.set_wakeup_fd``)
|
|
7765
|
+
rather than a ``threading.Event``: CPython's C-level signal trampoline
|
|
7766
|
+
writes the signum to the pipe on EVERY delivery, *before* (and
|
|
7767
|
+
independent of) the Python-level handler running, so the ``select``
|
|
7768
|
+
below unblocks on the very first signal. This eliminates the lost-wakeup
|
|
7769
|
+
that races ``threading.Event.wait()`` — a single SIGTERM that arrives as
|
|
7770
|
+
the main thread enters the wait is dropped ~0.04-0.07% of the time,
|
|
7771
|
+
never waking an Event-based loop, and recovery needs a *second* signal
|
|
7772
|
+
(issue #154; surfaced during #153 triage). A timed-poll
|
|
7773
|
+
(``while not stop.wait(0.5)``) does NOT fix it: on the miss the flag is
|
|
7774
|
+
never set, so it polls forever — the pipe buffer is the fix, not the
|
|
7775
|
+
timeout.
|
|
7776
|
+
|
|
7777
|
+
``on_signal`` (optional) is invoked from the Python-level handler on each
|
|
7778
|
+
delivery — a belt-and-suspenders secondary signal for callers that still
|
|
7779
|
+
want one; the wakeup does NOT depend on it running. ``timeout`` is
|
|
7780
|
+
``None`` (block forever) in production; tests pass a finite bound so a
|
|
7781
|
+
regressed mechanism fails loudly instead of hanging.
|
|
7782
|
+
|
|
7783
|
+
Returns ``True`` if woken by a signal, ``False`` if ``timeout`` elapsed.
|
|
7784
|
+
MUST be called from the main thread (``set_wakeup_fd`` / ``signal.signal``
|
|
7785
|
+
both require it). Restores the prior signal dispositions and wakeup fd on
|
|
7786
|
+
return, so it is safe to call repeatedly and inside a test process.
|
|
7787
|
+
"""
|
|
7788
|
+
import os
|
|
7789
|
+
import selectors
|
|
7790
|
+
import signal
|
|
7791
|
+
|
|
7792
|
+
prev_handlers = {sig: signal.getsignal(sig) for sig in signals}
|
|
7793
|
+
read_fd, write_fd = os.pipe()
|
|
7794
|
+
os.set_blocking(read_fd, False)
|
|
7795
|
+
os.set_blocking(write_fd, False)
|
|
7796
|
+
# Arm the wakeup fd BEFORE installing the Python handlers: by the time a
|
|
7797
|
+
# handler can fire, the C trampoline already has a pipe to write to, so a
|
|
7798
|
+
# signal racing the setup can't slip through a gap and be lost. (A signal
|
|
7799
|
+
# before this point still hits the prior disposition — pre-existing, not
|
|
7800
|
+
# our shutdown wait.)
|
|
7801
|
+
prev_wakeup_fd = signal.set_wakeup_fd(write_fd)
|
|
7802
|
+
|
|
7803
|
+
def _handler(signum, frame):
|
|
7804
|
+
if on_signal is not None:
|
|
7805
|
+
on_signal()
|
|
7806
|
+
|
|
7807
|
+
sel = selectors.DefaultSelector()
|
|
7808
|
+
sel.register(read_fd, selectors.EVENT_READ)
|
|
7809
|
+
try:
|
|
7810
|
+
for sig in signals:
|
|
7811
|
+
signal.signal(sig, _handler)
|
|
7812
|
+
woke = bool(sel.select(timeout))
|
|
7813
|
+
if woke:
|
|
7814
|
+
# Drain the wakeup byte(s); the pipe is non-blocking so an empty
|
|
7815
|
+
# read raises BlockingIOError rather than blocking.
|
|
7816
|
+
try:
|
|
7817
|
+
os.read(read_fd, 4096)
|
|
7818
|
+
except BlockingIOError:
|
|
7819
|
+
pass
|
|
7820
|
+
return woke
|
|
7821
|
+
finally:
|
|
7822
|
+
# Order matters: restore the wakeup fd before closing write_fd so the
|
|
7823
|
+
# signal machinery never points at a closed descriptor.
|
|
7824
|
+
signal.set_wakeup_fd(prev_wakeup_fd)
|
|
7825
|
+
for sig, handler in prev_handlers.items():
|
|
7826
|
+
signal.signal(sig, handler)
|
|
7827
|
+
sel.close()
|
|
7828
|
+
os.close(read_fd)
|
|
7829
|
+
os.close(write_fd)
|
|
7830
|
+
|
|
7831
|
+
|
|
7391
7832
|
def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
7392
7833
|
"""Launch the live web dashboard."""
|
|
7393
7834
|
import signal as _signal
|
|
@@ -7515,6 +7956,14 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
7515
7956
|
# in the doctor SSE block + /api/doctor reflects the actual --host
|
|
7516
7957
|
# the process is serving, not just the config-only view the CLI sees.
|
|
7517
7958
|
DashboardHTTPHandler.cctally_host = args.host
|
|
7959
|
+
# Conversation viewer (Plan 2, spec §5): the resolved
|
|
7960
|
+
# `dashboard.expose_transcripts` opt-in. Read off the already-loaded
|
|
7961
|
+
# `config` the same way `dashboard.bind` is resolved above (the
|
|
7962
|
+
# `_config_known_value` shim surfaces the boolean default of False for
|
|
7963
|
+
# an absent or hand-edited-junk value).
|
|
7964
|
+
DashboardHTTPHandler.cctally_expose_transcripts = bool(
|
|
7965
|
+
_config_known_value(config, "dashboard.expose_transcripts")
|
|
7966
|
+
)
|
|
7518
7967
|
DashboardHTTPHandler.run_sync_now = staticmethod(
|
|
7519
7968
|
lambda: _run_sync_now(skip_sync=args.no_sync)
|
|
7520
7969
|
)
|
|
@@ -7619,14 +8068,14 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
7619
8068
|
file=sys.stderr,
|
|
7620
8069
|
)
|
|
7621
8070
|
|
|
7622
|
-
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
7626
|
-
|
|
7627
|
-
|
|
8071
|
+
# Block the main thread until SIGINT/SIGTERM. The wait is driven by a
|
|
8072
|
+
# self-pipe wakeup fd (signal.set_wakeup_fd) rather than a
|
|
8073
|
+
# threading.Event, so a single signal unblocks it unconditionally — the
|
|
8074
|
+
# C-level signal trampoline writes to the pipe before (and independent
|
|
8075
|
+
# of) any Python-level handler running, so the wakeup can't be lost to
|
|
8076
|
+
# the Event.wait() entry race (#154). timeout=None → block forever.
|
|
7628
8077
|
try:
|
|
7629
|
-
|
|
8078
|
+
_dashboard_wait_for_signal((_signal.SIGINT, _signal.SIGTERM))
|
|
7630
8079
|
finally:
|
|
7631
8080
|
if sync_thread is not None:
|
|
7632
8081
|
sync_thread.stop()
|