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.
@@ -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``,
@@ -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 _envelope_rows_budget(conn, descriptor, limit, severity_for) -> list[dict]:
4182
- # Third axis (issue #19): equiv-$ budget threshold crossings. Budget
4183
- # alerts are keyed by the effective (post-reset) week_start_at + the
4184
- # integer threshold; the envelope id mirrors the dispatch payload's
4185
- # ``budget:<week_start_at>:<threshold>`` shape
4186
- # (``_build_alert_payload_budget``). No ``reset_event_id`` segment —
4187
- # a mid-week reset re-anchors ``week_start_at`` so the new window
4188
- # naturally gets fresh rows under ``UNIQUE(week_start_at, 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.
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 week_start_at, threshold, crossed_at_utc, alerted_at,
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": f"budget:{r['week_start_at']}:{threshold}",
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``) drives the
4226
- # frontend's metric-aware context renderer; ``denominator`` +
4227
- # ``projected_value`` are rendered FROM THE ROW (the values snapshotted at
4228
- # crossing), never live config that may have changed since (Codex P0-4).
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, metric, threshold, projected_value,
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": f"projected:{r['week_start_at']}:{metric}:{threshold}",
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": _envelope_rows_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``, ``projected_milestones``, and
4343
- ``project_budget_milestones`` rows with ``alerted_at IS NOT NULL``,
4344
- ordered newest-first by ``alerted_at``, capped at ``limit`` (default 100).
4345
- Single source of truth for both the dashboard panel (slices to 10
4346
- client-side) and the modal (renders all 100). Forward-only semantics: only
4347
- rows the alert-dispatch path stamped get included; pre-deploy crossings
4348
- stay NULL and are intentionally invisible (spec §4.3).
4349
-
4350
- All five axes share the same envelope schema; the ``axis`` field
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 additionally
4353
- carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``) so the
4354
- frontend can pick its metric-aware context renderer; ``project_budget``
4355
- carries the project basename + ``$spent of $budget`` in its context.
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}}`` — every
5388
- top-level key is optional; any subset may be sent together
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
- 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"}:
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
- f"'projected' or 'project_budget', got {axis!r}"
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 only when it
5973
- # matters so a stray metric on a weekly/budget test isn't a 400.
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 ("weekly_pct", "budget_usd"):
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' or 'budget_usd', "
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
- stop = threading.Event()
7623
- def _handler(signum, frame):
7624
- stop.set()
7625
- _signal.signal(_signal.SIGINT, _handler)
7626
- _signal.signal(_signal.SIGTERM, _handler)
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
- stop.wait()
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()