cctally 1.28.0 → 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 +18 -0
- package/bin/_cctally_cache.py +111 -59
- package/bin/_cctally_core.py +22 -49
- package/bin/_cctally_dashboard.py +239 -152
- package/bin/_cctally_db.py +193 -31
- package/bin/_cctally_milestones.py +126 -166
- package/bin/_cctally_record.py +161 -192
- package/bin/_lib_alert_axes.py +7 -4
- package/bin/_lib_conversation.py +21 -6
- package/bin/_lib_conversation_query.py +145 -49
- package/bin/_lib_jsonl.py +69 -50
- package/bin/cctally +5 -5
- package/dashboard/static/assets/index-BGaWg6ys.js +47 -0
- package/dashboard/static/assets/{index-Bj5ckRUE.css → index-BqQ5xdX0.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-Dw4G5FD9.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``,
|
|
@@ -4184,58 +4189,85 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
4184
4189
|
return out
|
|
4185
4190
|
|
|
4186
4191
|
|
|
4187
|
-
def
|
|
4188
|
-
#
|
|
4189
|
-
#
|
|
4190
|
-
#
|
|
4191
|
-
#
|
|
4192
|
-
# ``
|
|
4193
|
-
#
|
|
4194
|
-
#
|
|
4195
|
-
# naturally gets fresh rows under
|
|
4196
|
-
# ``UNIQUE(week_start_at, period, threshold)``.
|
|
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.
|
|
4197
4200
|
#
|
|
4198
|
-
#
|
|
4199
|
-
#
|
|
4200
|
-
#
|
|
4201
|
-
#
|
|
4202
|
-
#
|
|
4203
|
-
#
|
|
4204
|
-
#
|
|
4205
|
-
#
|
|
4206
|
-
#
|
|
4207
|
-
#
|
|
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"
|
|
4208
4228
|
rows = conn.execute(
|
|
4209
4229
|
f"""
|
|
4210
|
-
SELECT
|
|
4211
|
-
COALESCE(period,
|
|
4230
|
+
SELECT period_start_at,
|
|
4231
|
+
COALESCE(period, ?) AS period,
|
|
4212
4232
|
threshold, crossed_at_utc, alerted_at,
|
|
4213
4233
|
budget_usd, spent_usd, consumption_pct
|
|
4214
4234
|
FROM {descriptor.milestone_table}
|
|
4215
|
-
WHERE alerted_at IS NOT NULL
|
|
4235
|
+
WHERE vendor = ? AND alerted_at IS NOT NULL
|
|
4216
4236
|
ORDER BY alerted_at DESC
|
|
4217
4237
|
LIMIT ?
|
|
4218
4238
|
""",
|
|
4219
|
-
(limit
|
|
4239
|
+
(default_noun, vendor, limit),
|
|
4220
4240
|
).fetchall()
|
|
4221
4241
|
out: list[dict] = []
|
|
4222
4242
|
for r in rows:
|
|
4223
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
|
+
}
|
|
4224
4261
|
out.append({
|
|
4225
|
-
"id":
|
|
4262
|
+
"id": (
|
|
4263
|
+
f"{descriptor.id}:{r['period_start_at']}:{r['period']}:{threshold}"
|
|
4264
|
+
),
|
|
4226
4265
|
"axis": descriptor.id,
|
|
4227
4266
|
"threshold": threshold,
|
|
4228
4267
|
"severity": severity_for(threshold),
|
|
4229
4268
|
"crossed_at": r["crossed_at_utc"],
|
|
4230
4269
|
"alerted_at": r["alerted_at"],
|
|
4231
|
-
"context":
|
|
4232
|
-
"week_start_at": r["week_start_at"],
|
|
4233
|
-
"period": r["period"],
|
|
4234
|
-
"period_start_at": r["week_start_at"],
|
|
4235
|
-
"budget_usd": float(r["budget_usd"]),
|
|
4236
|
-
"spent_usd": float(r["spent_usd"]),
|
|
4237
|
-
"consumption_pct": float(r["consumption_pct"]),
|
|
4238
|
-
},
|
|
4270
|
+
"context": ctx,
|
|
4239
4271
|
})
|
|
4240
4272
|
return out
|
|
4241
4273
|
|
|
@@ -4354,75 +4386,19 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
|
|
|
4354
4386
|
return out
|
|
4355
4387
|
|
|
4356
4388
|
|
|
4357
|
-
def _envelope_rows_codex_budget(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
4358
|
-
# Sixth axis (calendar-period-codex-budgets, spec §6): per-vendor Codex
|
|
4359
|
-
# equiv-actual-$ budget threshold crossings over a CALENDAR period
|
|
4360
|
-
# (calendar-week / calendar-month — Codex has no Anthropic subscription
|
|
4361
|
-
# week). Keyed on ``period_start_at`` (the resolved period-window start
|
|
4362
|
-
# instant, stored as the ``isoformat(timespec="seconds")`` ``+00:00`` offset
|
|
4363
|
-
# form, NOT a ``Z`` suffix) + the integer threshold; the envelope id mirrors
|
|
4364
|
-
# the dispatch payload's ``codex_budget:<period_start_at>:<threshold>`` shape
|
|
4365
|
-
# (``_build_alert_payload_codex_budget``). NO ``reset_event_id`` segment —
|
|
4366
|
-
# rolling to the next period yields a fresh ``period_start_at`` so the new
|
|
4367
|
-
# window naturally gets fresh rows under ``UNIQUE(period_start_at,
|
|
4368
|
-
# threshold)``. ``budget_usd`` / ``spent_usd`` / ``consumption_pct`` are
|
|
4369
|
-
# rendered FROM THE ROW (snapshotted at crossing), never live config that may
|
|
4370
|
-
# have changed since (the Codex P0-4 lesson). The ``period`` discriminator is
|
|
4371
|
-
# the write-once ``period`` discriminator (#137) is read FROM THE ROW
|
|
4372
|
-
# (snapshotted at crossing) — mirroring how the dispatched payload sourced it
|
|
4373
|
-
# from config-at-fire-time — never live ``budget.codex.period`` that may have
|
|
4374
|
-
# changed since (the Codex P0-4 lesson; Symptom 1 fix). ``COALESCE(period,
|
|
4375
|
-
# 'calendar-month')`` renders a pre-011 NULL-sentinel row with the
|
|
4376
|
-
# vendor-default civil-period noun (Codex has no subscription week) and keeps
|
|
4377
|
-
# the ``id`` non-``None``. The id gains the ``period`` segment so a
|
|
4378
|
-
# calendar-week ↔ calendar-month coinciding-instant collision (now distinct
|
|
4379
|
-
# coexisting rows under ``UNIQUE(period_start_at, period, threshold)``) gets
|
|
4380
|
-
# distinct React keys.
|
|
4381
|
-
rows = conn.execute(
|
|
4382
|
-
f"""
|
|
4383
|
-
SELECT period_start_at,
|
|
4384
|
-
COALESCE(period, 'calendar-month') AS period,
|
|
4385
|
-
threshold, crossed_at_utc, alerted_at,
|
|
4386
|
-
budget_usd, spent_usd, consumption_pct
|
|
4387
|
-
FROM {descriptor.milestone_table}
|
|
4388
|
-
WHERE alerted_at IS NOT NULL
|
|
4389
|
-
ORDER BY alerted_at DESC
|
|
4390
|
-
LIMIT ?
|
|
4391
|
-
""",
|
|
4392
|
-
(limit,),
|
|
4393
|
-
).fetchall()
|
|
4394
|
-
out: list[dict] = []
|
|
4395
|
-
for r in rows:
|
|
4396
|
-
threshold = int(r["threshold"])
|
|
4397
|
-
out.append({
|
|
4398
|
-
"id": (
|
|
4399
|
-
f"codex_budget:{r['period_start_at']}:{r['period']}:{threshold}"
|
|
4400
|
-
),
|
|
4401
|
-
"axis": descriptor.id,
|
|
4402
|
-
"threshold": threshold,
|
|
4403
|
-
"severity": severity_for(threshold),
|
|
4404
|
-
"crossed_at": r["crossed_at_utc"],
|
|
4405
|
-
"alerted_at": r["alerted_at"],
|
|
4406
|
-
"context": {
|
|
4407
|
-
"period": r["period"],
|
|
4408
|
-
"period_start_at": r["period_start_at"],
|
|
4409
|
-
"budget_usd": float(r["budget_usd"]),
|
|
4410
|
-
"spent_usd": float(r["spent_usd"]),
|
|
4411
|
-
"consumption_pct": float(r["consumption_pct"]),
|
|
4412
|
-
},
|
|
4413
|
-
})
|
|
4414
|
-
return out
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
4389
|
# Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
|
|
4418
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.
|
|
4419
4395
|
_ENVELOPE_AXIS_MAPPERS = {
|
|
4420
4396
|
"weekly": _envelope_rows_weekly,
|
|
4421
4397
|
"five_hour": _envelope_rows_five_hour,
|
|
4422
|
-
"budget":
|
|
4398
|
+
"budget": _envelope_rows_budget_family,
|
|
4423
4399
|
"projected": _envelope_rows_projected,
|
|
4424
4400
|
"project_budget": _envelope_rows_project_budget,
|
|
4425
|
-
"codex_budget":
|
|
4401
|
+
"codex_budget": _envelope_rows_budget_family,
|
|
4426
4402
|
}
|
|
4427
4403
|
|
|
4428
4404
|
|
|
@@ -4433,8 +4409,9 @@ def _build_alerts_envelope_array(
|
|
|
4433
4409
|
"""Return the ``alerts`` array for the SSE snapshot envelope.
|
|
4434
4410
|
|
|
4435
4411
|
Union of ``percent_milestones``, ``five_hour_milestones``,
|
|
4436
|
-
``budget_milestones
|
|
4437
|
-
``
|
|
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
|
|
4438
4415
|
``alerted_at IS NOT NULL``, ordered newest-first by ``alerted_at``, capped at
|
|
4439
4416
|
``limit`` (default 100). Single source of truth for both the dashboard panel
|
|
4440
4417
|
(slices to 10 client-side) and the modal (renders all 100). Forward-only
|
|
@@ -5228,6 +5205,19 @@ def _qs_int(q: dict, key: str, default: int) -> int:
|
|
|
5228
5205
|
return default
|
|
5229
5206
|
|
|
5230
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
|
+
|
|
5231
5221
|
class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
5232
5222
|
"""Routes:
|
|
5233
5223
|
GET / → dashboard.html
|
|
@@ -5523,9 +5513,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5523
5513
|
per-request Host allowlist (`host_allowed_for_transcripts`,
|
|
5524
5514
|
anti-DNS-rebinding). Spec §5.
|
|
5525
5515
|
|
|
5526
|
-
|
|
5527
|
-
`transcriptsEnabled` client signal
|
|
5528
|
-
|
|
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
|
|
5529
5520
|
one-line drift can never re-introduce the enabled-then-403 desync.
|
|
5530
5521
|
"""
|
|
5531
5522
|
ta = self._transcript_gate()
|
|
@@ -6059,7 +6050,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6059
6050
|
touched = (
|
|
6060
6051
|
set(budget_in.keys()) if isinstance(budget_in, dict) else set()
|
|
6061
6052
|
)
|
|
6062
|
-
|
|
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"}:
|
|
6063
6059
|
_cctally()._reconcile_budget_on_config_write(validated_budget)
|
|
6064
6060
|
if touched & {"project_alerts_enabled", "alert_thresholds"}:
|
|
6065
6061
|
_cctally()._reconcile_project_budget_milestones_on_write(
|
|
@@ -7247,6 +7243,34 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7247
7243
|
"""Lazy-load the pure conversation query kernel (Plan 2, §3)."""
|
|
7248
7244
|
return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
|
|
7249
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
|
+
|
|
7250
7274
|
def _handle_get_conversations(self) -> None:
|
|
7251
7275
|
"""``GET /api/conversations`` — the browse rail (spec §3.1).
|
|
7252
7276
|
|
|
@@ -7258,23 +7282,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7258
7282
|
return
|
|
7259
7283
|
import urllib.parse as _u
|
|
7260
7284
|
q = _u.parse_qs(self.path.partition("?")[2])
|
|
7261
|
-
sort = (q
|
|
7285
|
+
sort = _qs_str(q, "sort", "recent")
|
|
7262
7286
|
limit = _qs_int(q, "limit", 50)
|
|
7263
7287
|
offset = _qs_int(q, "offset", 0)
|
|
7264
|
-
|
|
7265
|
-
conn
|
|
7266
|
-
|
|
7267
|
-
|
|
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:
|
|
7268
7293
|
return
|
|
7269
|
-
try:
|
|
7270
|
-
body = self._conversation_query().list_conversations(
|
|
7271
|
-
conn, sort=sort, limit=limit, offset=offset)
|
|
7272
|
-
except Exception as exc: # noqa: BLE001
|
|
7273
|
-
self.log_error("/api/conversations failed: %r", exc)
|
|
7274
|
-
self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
|
|
7275
|
-
return
|
|
7276
|
-
finally:
|
|
7277
|
-
conn.close()
|
|
7278
7294
|
self._respond_json(200, body)
|
|
7279
7295
|
|
|
7280
7296
|
def _handle_get_conversation_detail(self, path: str) -> None:
|
|
@@ -7296,22 +7312,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7296
7312
|
self.send_error(404, "conversation not found")
|
|
7297
7313
|
return
|
|
7298
7314
|
q = _u.parse_qs(query_str)
|
|
7299
|
-
after = (q
|
|
7315
|
+
after = _qs_str(q, "after", None)
|
|
7300
7316
|
limit = _qs_int(q, "limit", 500)
|
|
7301
|
-
|
|
7302
|
-
conn
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7306
|
-
try:
|
|
7307
|
-
body = self._conversation_query().get_conversation(
|
|
7308
|
-
conn, session_id, after=after, limit=limit)
|
|
7309
|
-
except Exception as exc: # noqa: BLE001
|
|
7310
|
-
self.log_error("/api/conversation failed: %r", exc)
|
|
7311
|
-
self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
|
|
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:
|
|
7312
7322
|
return
|
|
7313
|
-
finally:
|
|
7314
|
-
conn.close()
|
|
7315
7323
|
if body is None:
|
|
7316
7324
|
self.send_error(404, "conversation not found")
|
|
7317
7325
|
return
|
|
@@ -7325,23 +7333,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7325
7333
|
return
|
|
7326
7334
|
import urllib.parse as _u
|
|
7327
7335
|
q = _u.parse_qs(self.path.partition("?")[2])
|
|
7328
|
-
query = (q
|
|
7336
|
+
query = _qs_str(q, "q", "")
|
|
7329
7337
|
limit = _qs_int(q, "limit", 50)
|
|
7330
7338
|
offset = _qs_int(q, "offset", 0)
|
|
7331
|
-
|
|
7332
|
-
conn
|
|
7333
|
-
|
|
7334
|
-
|
|
7335
|
-
|
|
7336
|
-
try:
|
|
7337
|
-
body = self._conversation_query().search_conversations(
|
|
7338
|
-
conn, query, limit=limit, offset=offset)
|
|
7339
|
-
except Exception as exc: # noqa: BLE001
|
|
7340
|
-
self.log_error("/api/conversation/search failed: %r", exc)
|
|
7341
|
-
self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
|
|
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:
|
|
7342
7344
|
return
|
|
7343
|
-
finally:
|
|
7344
|
-
conn.close()
|
|
7345
7345
|
self._respond_json(200, body)
|
|
7346
7346
|
|
|
7347
7347
|
def _handle_get_project_detail(self) -> None:
|
|
@@ -7516,6 +7516,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7516
7516
|
except OauthUsageConfigError:
|
|
7517
7517
|
cfg_oauth = dict(sys.modules["cctally"]._OAUTH_USAGE_DEFAULTS)
|
|
7518
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
|
+
|
|
7519
7529
|
q = self.hub.subscribe()
|
|
7520
7530
|
try:
|
|
7521
7531
|
while True:
|
|
@@ -7535,6 +7545,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7535
7545
|
display_tz_pref_override=type(self).display_tz_pref_override,
|
|
7536
7546
|
runtime_bind=type(self).cctally_host,
|
|
7537
7547
|
)
|
|
7548
|
+
env["transcriptsEnabled"] = transcripts_enabled
|
|
7538
7549
|
msg = (
|
|
7539
7550
|
"event: update\n"
|
|
7540
7551
|
+ "data: " + json.dumps(env, ensure_ascii=False) + "\n\n"
|
|
@@ -7742,6 +7753,82 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7742
7753
|
# === cmd_dashboard (dashboard subcommand entry point) =====================
|
|
7743
7754
|
# Pre-extract location: bin/cctally L21478.
|
|
7744
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
|
+
|
|
7745
7832
|
def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
7746
7833
|
"""Launch the live web dashboard."""
|
|
7747
7834
|
import signal as _signal
|
|
@@ -7981,14 +8068,14 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
7981
8068
|
file=sys.stderr,
|
|
7982
8069
|
)
|
|
7983
8070
|
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
|
|
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.
|
|
7990
8077
|
try:
|
|
7991
|
-
|
|
8078
|
+
_dashboard_wait_for_signal((_signal.SIGINT, _signal.SIGTERM))
|
|
7992
8079
|
finally:
|
|
7993
8080
|
if sync_thread is not None:
|
|
7994
8081
|
sync_thread.stop()
|