cctally 1.28.0 → 1.30.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/bin/_cctally_cache.py +147 -59
  3. package/bin/_cctally_core.py +22 -49
  4. package/bin/_cctally_dashboard.py +239 -152
  5. package/bin/_cctally_db.py +211 -31
  6. package/bin/_cctally_milestones.py +126 -166
  7. package/bin/_cctally_record.py +161 -192
  8. package/bin/_lib_alert_axes.py +7 -4
  9. package/bin/_lib_conversation.py +59 -8
  10. package/bin/_lib_conversation_query.py +306 -52
  11. package/bin/_lib_jsonl.py +69 -50
  12. package/bin/cctally +5 -5
  13. package/dashboard/static/assets/index-4OxMhN7N.js +53 -0
  14. package/dashboard/static/assets/index-DEDO-eqP.css +1 -0
  15. package/dashboard/static/assets/newsreader-latin-400-italic-CEihAR-f.woff2 +0 -0
  16. package/dashboard/static/assets/newsreader-latin-400-italic-CNZoH1hn.woff +0 -0
  17. package/dashboard/static/assets/newsreader-latin-400-normal-BFBkh4jY.woff2 +0 -0
  18. package/dashboard/static/assets/newsreader-latin-400-normal-gRTjlS2D.woff +0 -0
  19. package/dashboard/static/assets/newsreader-latin-500-normal-B66TYsaK.woff2 +0 -0
  20. package/dashboard/static/assets/newsreader-latin-500-normal-DFwuUcdu.woff +0 -0
  21. package/dashboard/static/assets/newsreader-latin-600-normal-30OJ_TG_.woff2 +0 -0
  22. package/dashboard/static/assets/newsreader-latin-600-normal-DUnT2r2g.woff +0 -0
  23. package/dashboard/static/assets/newsreader-latin-ext-400-italic-BMTE_bNQ.woff2 +0 -0
  24. package/dashboard/static/assets/newsreader-latin-ext-400-italic-qdgKLcPG.woff +0 -0
  25. package/dashboard/static/assets/newsreader-latin-ext-400-normal-DYA1XoQK.woff +0 -0
  26. package/dashboard/static/assets/newsreader-latin-ext-400-normal-svq1FPys.woff2 +0 -0
  27. package/dashboard/static/assets/newsreader-latin-ext-500-normal-BNHmvKvI.woff2 +0 -0
  28. package/dashboard/static/assets/newsreader-latin-ext-500-normal-CZruMFou.woff +0 -0
  29. package/dashboard/static/assets/newsreader-latin-ext-600-normal-BXv5iMHi.woff2 +0 -0
  30. package/dashboard/static/assets/newsreader-latin-ext-600-normal-BrbfzHZ5.woff +0 -0
  31. package/dashboard/static/assets/newsreader-vietnamese-400-italic-QbB8kb5s.woff +0 -0
  32. package/dashboard/static/assets/newsreader-vietnamese-400-italic-bZegYFuM.woff2 +0 -0
  33. package/dashboard/static/assets/newsreader-vietnamese-400-normal-BekUZro8.woff +0 -0
  34. package/dashboard/static/assets/newsreader-vietnamese-400-normal-DdKr49mV.woff2 +0 -0
  35. package/dashboard/static/assets/newsreader-vietnamese-500-normal-BEAbKU8A.woff +0 -0
  36. package/dashboard/static/assets/newsreader-vietnamese-500-normal-CL6a8tp2.woff2 +0 -0
  37. package/dashboard/static/assets/newsreader-vietnamese-600-normal-CVAR0otO.woff +0 -0
  38. package/dashboard/static/assets/newsreader-vietnamese-600-normal-CaH84vfx.woff2 +0 -0
  39. package/dashboard/static/dashboard.html +2 -2
  40. package/package.json +1 -1
  41. package/dashboard/static/assets/index-Bj5ckRUE.css +0 -1
  42. 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; handles ``SIGINT`` / ``SIGTERM`` for clean
16
- shutdown.
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 _envelope_rows_budget(conn, descriptor, limit, severity_for) -> list[dict]:
4188
- # Third axis (issue #19): equiv-$ budget threshold crossings. Budget
4189
- # alerts are keyed by the effective (post-reset) week_start_at + the
4190
- # write-once ``period`` discriminator + the integer threshold; the envelope
4191
- # id mirrors the dispatch payload's
4192
- # ``budget:<week_start_at>:<period>:<threshold>`` shape
4193
- # (``_build_alert_payload_budget``). No ``reset_event_id`` segment —
4194
- # a mid-week reset re-anchors ``week_start_at`` so the new window
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
- # Period generalization (calendar-period-codex-budgets, spec §5/§6): the
4199
- # ``week_start_at`` key column carries either a subscription-week instant
4200
- # OR a calendar period-start instant (back-compat misnomer, like
4201
- # ``weekly_usd``); the ``period`` discriminator (#137) tells them apart.
4202
- # ``period`` is read FROM THE ROW (snapshotted at crossing), never live
4203
- # config that may have changed since (the Codex P0-4 lesson) — so a user who
4204
- # fires subscription-week alerts then switches ``budget.period`` keeps the
4205
- # historical row's noun (Symptom 1 fix). ``COALESCE(period,
4206
- # 'subscription-week')`` renders a pre-011 NULL-sentinel row with the
4207
- # vendor-default noun and keeps the ``id`` non-``None``.
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 week_start_at,
4211
- COALESCE(period, 'subscription-week') AS 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": f"budget:{r['week_start_at']}:{r['period']}:{threshold}",
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": _envelope_rows_budget,
4398
+ "budget": _envelope_rows_budget_family,
4423
4399
  "projected": _envelope_rows_projected,
4424
4400
  "project_budget": _envelope_rows_project_budget,
4425
- "codex_budget": _envelope_rows_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``, ``projected_milestones``,
4437
- ``project_budget_milestones``, and ``codex_budget_milestones`` rows with
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
- Both `_require_transcripts_allowed` (the GET-route 403 gate) and the
5527
- `transcriptsEnabled` client signal in `_serve_api_data` route through
5528
- this predicate so the two are contractually identical — a future
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
- if touched & {"weekly_usd", "alerts_enabled", "alert_thresholds"}:
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.get("sort", ["recent"]) or ["recent"])[0]
7285
+ sort = _qs_str(q, "sort", "recent")
7262
7286
  limit = _qs_int(q, "limit", 50)
7263
7287
  offset = _qs_int(q, "offset", 0)
7264
- try:
7265
- conn = open_cache_db()
7266
- except (sqlite3.DatabaseError, OSError) as exc:
7267
- self._respond_json(500, {"error": f"cache unavailable: {exc}"})
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.get("after", [None]) or [None])[0]
7315
+ after = _qs_str(q, "after", None)
7300
7316
  limit = _qs_int(q, "limit", 500)
7301
- try:
7302
- conn = open_cache_db()
7303
- except (sqlite3.DatabaseError, OSError) as exc:
7304
- self._respond_json(500, {"error": f"cache unavailable: {exc}"})
7305
- return
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.get("q", [""]) or [""])[0]
7336
+ query = _qs_str(q, "q", "")
7329
7337
  limit = _qs_int(q, "limit", 50)
7330
7338
  offset = _qs_int(q, "offset", 0)
7331
- try:
7332
- conn = open_cache_db()
7333
- except (sqlite3.DatabaseError, OSError) as exc:
7334
- self._respond_json(500, {"error": f"cache unavailable: {exc}"})
7335
- return
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
- stop = threading.Event()
7985
- def _handler(signum, frame):
7986
- stop.set()
7987
- _signal.signal(_signal.SIGINT, _handler)
7988
- _signal.signal(_signal.SIGTERM, _handler)
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
- stop.wait()
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()