cctally 1.27.1 → 1.28.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 +19 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_cache.py +278 -6
- package/bin/_cctally_config.py +153 -11
- package/bin/_cctally_core.py +230 -41
- package/bin/_cctally_dashboard.py +399 -37
- package/bin/_cctally_db.py +594 -163
- package/bin/_cctally_doctor.py +11 -0
- package/bin/_cctally_forecast.py +700 -57
- package/bin/_cctally_milestones.py +273 -28
- package/bin/_cctally_parser.py +44 -4
- package/bin/_cctally_record.py +328 -50
- package/bin/_cctally_weekrefs.py +30 -6
- package/bin/_lib_alert_axes.py +8 -1
- package/bin/_lib_alerts_payload.py +95 -3
- package/bin/_lib_budget.py +48 -0
- package/bin/_lib_conversation.py +162 -0
- package/bin/_lib_conversation_query.py +524 -0
- package/bin/_lib_doctor.py +60 -1
- package/bin/_lib_transcript_access.py +80 -0
- package/bin/cctally +27 -0
- package/dashboard/static/assets/{index-D34qf0LE.css → index-Bj5ckRUE.css} +1 -1
- package/dashboard/static/assets/index-Dw4G5FD9.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-C2F1_Mxt.js +0 -18
|
@@ -286,7 +286,7 @@ from _lib_subscription_weeks import _compute_subscription_weeks
|
|
|
286
286
|
from _lib_blocks import _group_entries_into_blocks
|
|
287
287
|
from _cctally_config import save_config, _load_config_unlocked
|
|
288
288
|
from _cctally_db import _render_migration_error_banner
|
|
289
|
-
from _cctally_cache import get_entries
|
|
289
|
+
from _cctally_cache import get_entries, open_cache_db
|
|
290
290
|
|
|
291
291
|
|
|
292
292
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
@@ -347,6 +347,12 @@ def _build_alert_payload_project_budget(*args, **kwargs):
|
|
|
347
347
|
)
|
|
348
348
|
|
|
349
349
|
|
|
350
|
+
def _build_alert_payload_codex_budget(*args, **kwargs):
|
|
351
|
+
return sys.modules["cctally"]._build_alert_payload_codex_budget(
|
|
352
|
+
*args, **kwargs
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
350
356
|
def _dispatch_alert_notification(*args, **kwargs):
|
|
351
357
|
return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
|
|
352
358
|
|
|
@@ -4181,14 +4187,29 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
4181
4187
|
def _envelope_rows_budget(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
4182
4188
|
# Third axis (issue #19): equiv-$ budget threshold crossings. Budget
|
|
4183
4189
|
# alerts are keyed by the effective (post-reset) week_start_at + the
|
|
4184
|
-
# integer threshold; the envelope
|
|
4185
|
-
#
|
|
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
|
|
4186
4193
|
# (``_build_alert_payload_budget``). No ``reset_event_id`` segment —
|
|
4187
4194
|
# a mid-week reset re-anchors ``week_start_at`` so the new window
|
|
4188
|
-
# naturally gets fresh rows under
|
|
4195
|
+
# naturally gets fresh rows under
|
|
4196
|
+
# ``UNIQUE(week_start_at, period, threshold)``.
|
|
4197
|
+
#
|
|
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``.
|
|
4189
4208
|
rows = conn.execute(
|
|
4190
4209
|
f"""
|
|
4191
|
-
SELECT week_start_at,
|
|
4210
|
+
SELECT week_start_at,
|
|
4211
|
+
COALESCE(period, 'subscription-week') AS period,
|
|
4212
|
+
threshold, crossed_at_utc, alerted_at,
|
|
4192
4213
|
budget_usd, spent_usd, consumption_pct
|
|
4193
4214
|
FROM {descriptor.milestone_table}
|
|
4194
4215
|
WHERE alerted_at IS NOT NULL
|
|
@@ -4201,7 +4222,7 @@ def _envelope_rows_budget(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
|
4201
4222
|
for r in rows:
|
|
4202
4223
|
threshold = int(r["threshold"])
|
|
4203
4224
|
out.append({
|
|
4204
|
-
"id": f"budget:{r['week_start_at']}:{threshold}",
|
|
4225
|
+
"id": f"budget:{r['week_start_at']}:{r['period']}:{threshold}",
|
|
4205
4226
|
"axis": descriptor.id,
|
|
4206
4227
|
"threshold": threshold,
|
|
4207
4228
|
"severity": severity_for(threshold),
|
|
@@ -4209,6 +4230,8 @@ def _envelope_rows_budget(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
|
4209
4230
|
"alerted_at": r["alerted_at"],
|
|
4210
4231
|
"context": {
|
|
4211
4232
|
"week_start_at": r["week_start_at"],
|
|
4233
|
+
"period": r["period"],
|
|
4234
|
+
"period_start_at": r["week_start_at"],
|
|
4212
4235
|
"budget_usd": float(r["budget_usd"]),
|
|
4213
4236
|
"spent_usd": float(r["spent_usd"]),
|
|
4214
4237
|
"consumption_pct": float(r["consumption_pct"]),
|
|
@@ -4221,16 +4244,23 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
4221
4244
|
# Fourth axis (issue #121): projected-pace threshold crossings. Like
|
|
4222
4245
|
# budget, projected alerts re-anchor ``week_start_at`` on a mid-week
|
|
4223
4246
|
# reset, so there is NO ``reset_event_id`` segment — the new window gets
|
|
4224
|
-
# fresh rows under ``UNIQUE(week_start_at, metric, threshold)``. The
|
|
4225
|
-
# ``metric`` discriminator (``weekly_pct`` | ``budget_usd``
|
|
4226
|
-
# frontend's metric-aware context renderer;
|
|
4227
|
-
# ``projected_value`` are rendered FROM THE ROW (the values
|
|
4228
|
-
# crossing), never live config that may have changed since
|
|
4229
|
-
# The envelope id mirrors the dispatch payload's
|
|
4230
|
-
# ``projected:<week_start_at>:<metric>:<threshold>`` shape.
|
|
4247
|
+
# fresh rows under ``UNIQUE(week_start_at, period, metric, threshold)``. The
|
|
4248
|
+
# ``metric`` discriminator (``weekly_pct`` | ``budget_usd`` |
|
|
4249
|
+
# ``codex_budget_usd``) drives the frontend's metric-aware context renderer;
|
|
4250
|
+
# ``denominator`` + ``projected_value`` are rendered FROM THE ROW (the values
|
|
4251
|
+
# snapshotted at crossing), never live config that may have changed since
|
|
4252
|
+
# (Codex P0-4). The envelope id mirrors the dispatch payload's
|
|
4253
|
+
# ``projected:<week_start_at>:<period>:<metric>:<threshold>`` shape. The
|
|
4254
|
+
# write-once ``period`` discriminator (#137) carries no symptom-1 label here
|
|
4255
|
+
# — projected's ``context`` is metric-driven, never a live-config period
|
|
4256
|
+
# noun — so ``COALESCE(period, 'subscription-week')`` is purely for a stable
|
|
4257
|
+
# non-``None`` id segment on a pre-011 NULL-sentinel row (the calendar-week ↔
|
|
4258
|
+
# calendar-month within-metric collision otherwise shares a React key).
|
|
4231
4259
|
rows = conn.execute(
|
|
4232
4260
|
f"""
|
|
4233
|
-
SELECT week_start_at,
|
|
4261
|
+
SELECT week_start_at,
|
|
4262
|
+
COALESCE(period, 'subscription-week') AS period,
|
|
4263
|
+
metric, threshold, projected_value,
|
|
4234
4264
|
denominator, crossed_at_utc, alerted_at
|
|
4235
4265
|
FROM {descriptor.milestone_table}
|
|
4236
4266
|
WHERE alerted_at IS NOT NULL
|
|
@@ -4244,7 +4274,10 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
4244
4274
|
threshold = int(r["threshold"])
|
|
4245
4275
|
metric = str(r["metric"])
|
|
4246
4276
|
out.append({
|
|
4247
|
-
"id":
|
|
4277
|
+
"id": (
|
|
4278
|
+
f"projected:{r['week_start_at']}:{r['period']}"
|
|
4279
|
+
f":{metric}:{threshold}"
|
|
4280
|
+
),
|
|
4248
4281
|
"axis": descriptor.id,
|
|
4249
4282
|
"metric": metric,
|
|
4250
4283
|
"threshold": threshold,
|
|
@@ -4321,6 +4354,66 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
|
|
|
4321
4354
|
return out
|
|
4322
4355
|
|
|
4323
4356
|
|
|
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
|
+
|
|
4324
4417
|
# Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
|
|
4325
4418
|
# in what order; this table supplies the bespoke heterogeneous row-mapper.
|
|
4326
4419
|
_ENVELOPE_AXIS_MAPPERS = {
|
|
@@ -4329,6 +4422,7 @@ _ENVELOPE_AXIS_MAPPERS = {
|
|
|
4329
4422
|
"budget": _envelope_rows_budget,
|
|
4330
4423
|
"projected": _envelope_rows_projected,
|
|
4331
4424
|
"project_budget": _envelope_rows_project_budget,
|
|
4425
|
+
"codex_budget": _envelope_rows_codex_budget,
|
|
4332
4426
|
}
|
|
4333
4427
|
|
|
4334
4428
|
|
|
@@ -4339,20 +4433,24 @@ def _build_alerts_envelope_array(
|
|
|
4339
4433
|
"""Return the ``alerts`` array for the SSE snapshot envelope.
|
|
4340
4434
|
|
|
4341
4435
|
Union of ``percent_milestones``, ``five_hour_milestones``,
|
|
4342
|
-
``budget_milestones``, ``projected_milestones``,
|
|
4343
|
-
``project_budget_milestones`` rows with
|
|
4344
|
-
ordered newest-first by ``alerted_at``, capped at
|
|
4345
|
-
Single source of truth for both the dashboard panel
|
|
4346
|
-
client-side) and the modal (renders all 100). Forward-only
|
|
4347
|
-
rows the alert-dispatch path stamped get included; pre-deploy
|
|
4348
|
-
stay NULL and are intentionally invisible (spec §4.3).
|
|
4349
|
-
|
|
4350
|
-
All
|
|
4436
|
+
``budget_milestones``, ``projected_milestones``,
|
|
4437
|
+
``project_budget_milestones``, and ``codex_budget_milestones`` rows with
|
|
4438
|
+
``alerted_at IS NOT NULL``, ordered newest-first by ``alerted_at``, capped at
|
|
4439
|
+
``limit`` (default 100). Single source of truth for both the dashboard panel
|
|
4440
|
+
(slices to 10 client-side) and the modal (renders all 100). Forward-only
|
|
4441
|
+
semantics: only rows the alert-dispatch path stamped get included; pre-deploy
|
|
4442
|
+
crossings stay NULL and are intentionally invisible (spec §4.3).
|
|
4443
|
+
|
|
4444
|
+
All six axes share the same envelope schema; the ``axis`` field
|
|
4351
4445
|
(``weekly`` / ``five_hour`` / ``budget`` / ``projected`` /
|
|
4352
|
-
``project_budget``) discriminates. The ``projected`` axis
|
|
4353
|
-
carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``
|
|
4354
|
-
frontend can pick its metric-aware context
|
|
4355
|
-
|
|
4446
|
+
``project_budget`` / ``codex_budget``) discriminates. The ``projected`` axis
|
|
4447
|
+
additionally carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``
|
|
4448
|
+
| ``codex_budget_usd``) so the frontend can pick its metric-aware context
|
|
4449
|
+
renderer; ``project_budget``
|
|
4450
|
+
carries the project basename + ``$spent of $budget`` in its context;
|
|
4451
|
+
``budget`` + ``codex_budget`` carry a ``period`` discriminator
|
|
4452
|
+
(subscription-week / calendar-week / calendar-month) so the frontend renders
|
|
4453
|
+
a period-aware "Month" / "Calendar week" / "Week" label.
|
|
4356
4454
|
|
|
4357
4455
|
Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
|
|
4358
4456
|
up to ``limit``) and the union is re-sorted + sliced — important for
|
|
@@ -4792,6 +4890,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4792
4890
|
# getter's ``project_alerts_enabled`` (default False) — the frontend
|
|
4793
4891
|
# SettingsOverlay seeds a single on/off toggle from it.
|
|
4794
4892
|
"project_alerts_enabled": bool(_budget_cfg.get("project_alerts_enabled")),
|
|
4893
|
+
# Codex budget toggle mirrors (#134). The frontend SettingsOverlay
|
|
4894
|
+
# seeds two toggles (alerts + projected) + a disabled-with-hint empty
|
|
4895
|
+
# state from these three flags. ``_budget_cfg["codex"]`` is ``None`` by
|
|
4896
|
+
# default (no Codex budget) → all three default false/absent safely;
|
|
4897
|
+
# the ``_BudgetConfigError`` fallback dict above lacks a ``codex`` key,
|
|
4898
|
+
# so ``.get("codex")`` → ``None`` is likewise safe.
|
|
4899
|
+
"codex_budget_configured": _budget_cfg.get("codex") is not None,
|
|
4900
|
+
"codex_budget_alerts_enabled": bool((_budget_cfg.get("codex") or {}).get("alerts_enabled")),
|
|
4901
|
+
"codex_projected_enabled": bool((_budget_cfg.get("codex") or {}).get("projected_enabled")),
|
|
4795
4902
|
# Alert-dispatch notifier mirror (Phase B). `notifier` is the
|
|
4796
4903
|
# validated backend selector ("auto"/"command"/etc.). The raw
|
|
4797
4904
|
# `command_template` is NEVER mirrored — it routinely holds secrets
|
|
@@ -5106,6 +5213,21 @@ _DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS = 2.0
|
|
|
5106
5213
|
# Pre-extract location: bin/cctally L17694.
|
|
5107
5214
|
|
|
5108
5215
|
|
|
5216
|
+
def _qs_int(q: dict, key: str, default: int) -> int:
|
|
5217
|
+
"""Parse a single query-string int with a fallback.
|
|
5218
|
+
|
|
5219
|
+
``q`` is a ``urllib.parse.parse_qs`` mapping (list-valued). A missing key,
|
|
5220
|
+
an empty value, or a non-integer spelling all fall back to ``default`` —
|
|
5221
|
+
the kernels clamp bounds, so this only needs to be permissive, not strict.
|
|
5222
|
+
"""
|
|
5223
|
+
vals = q.get(key, [None])
|
|
5224
|
+
raw = vals[0] if vals else None
|
|
5225
|
+
try:
|
|
5226
|
+
return int(raw) if raw is not None else default
|
|
5227
|
+
except (TypeError, ValueError):
|
|
5228
|
+
return default
|
|
5229
|
+
|
|
5230
|
+
|
|
5109
5231
|
class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
5110
5232
|
"""Routes:
|
|
5111
5233
|
GET / → dashboard.html
|
|
@@ -5146,6 +5268,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5146
5268
|
# path leaves this None and only sees the `config.json` view, which
|
|
5147
5269
|
# is the Codex H4 finding. Set by cmd_dashboard before serve_forever.
|
|
5148
5270
|
cctally_host: "str | None" = None
|
|
5271
|
+
# Conversation viewer (Plan 2, spec §5): the resolved
|
|
5272
|
+
# `dashboard.expose_transcripts` opt-in. False = transcript endpoints
|
|
5273
|
+
# are served only over loopback; True = LAN devices reach them at the
|
|
5274
|
+
# bind's IP literal (anti-DNS-rebinding still rejects hostnames). Set by
|
|
5275
|
+
# cmd_dashboard before serve_forever; the per-request gate
|
|
5276
|
+
# (`_require_transcripts_allowed`) ANDs this with the request Host.
|
|
5277
|
+
cctally_expose_transcripts: bool = False
|
|
5149
5278
|
|
|
5150
5279
|
# Silence the default per-request access log — noisy in the parent
|
|
5151
5280
|
# terminal, and we pipe it through our own logger in cmd_dashboard.
|
|
@@ -5218,6 +5347,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5218
5347
|
self._handle_share_history_get()
|
|
5219
5348
|
elif path == "/api/doctor":
|
|
5220
5349
|
self._handle_get_doctor()
|
|
5350
|
+
elif path == "/api/conversations":
|
|
5351
|
+
self._handle_get_conversations()
|
|
5352
|
+
elif path == "/api/conversation/search":
|
|
5353
|
+
self._handle_get_conversation_search()
|
|
5354
|
+
elif path.startswith("/api/conversation/"):
|
|
5355
|
+
self._handle_get_conversation_detail(path)
|
|
5221
5356
|
else:
|
|
5222
5357
|
self.send_error(404, "not found")
|
|
5223
5358
|
|
|
@@ -5377,6 +5512,37 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5377
5512
|
else:
|
|
5378
5513
|
self._respond_json(403, {"error": reason})
|
|
5379
5514
|
|
|
5515
|
+
@staticmethod
|
|
5516
|
+
def _transcript_gate():
|
|
5517
|
+
"""Lazy-load the pure transcript-access gate kernel (Plan 2, §5)."""
|
|
5518
|
+
return sys.modules["cctally"]._load_sibling("_lib_transcript_access")
|
|
5519
|
+
|
|
5520
|
+
def _transcripts_visible_to_request(self) -> bool:
|
|
5521
|
+
"""Single source of truth for "may transcripts be served to THIS
|
|
5522
|
+
request?" Composes the bind gate (`transcripts_allowed`) with the
|
|
5523
|
+
per-request Host allowlist (`host_allowed_for_transcripts`,
|
|
5524
|
+
anti-DNS-rebinding). Spec §5.
|
|
5525
|
+
|
|
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
|
|
5529
|
+
one-line drift can never re-introduce the enabled-then-403 desync.
|
|
5530
|
+
"""
|
|
5531
|
+
ta = self._transcript_gate()
|
|
5532
|
+
expose = bool(type(self).cctally_expose_transcripts)
|
|
5533
|
+
return (ta.transcripts_allowed(type(self).cctally_host, expose)
|
|
5534
|
+
and ta.host_allowed_for_transcripts(
|
|
5535
|
+
self.headers.get("Host", ""), expose))
|
|
5536
|
+
|
|
5537
|
+
def _require_transcripts_allowed(self) -> bool:
|
|
5538
|
+
"""True if transcripts may be served to THIS request; else emit 403 and
|
|
5539
|
+
return False. Spec §5.
|
|
5540
|
+
"""
|
|
5541
|
+
if not self._transcripts_visible_to_request():
|
|
5542
|
+
self._respond_403("transcripts not exposed")
|
|
5543
|
+
return False
|
|
5544
|
+
return True
|
|
5545
|
+
|
|
5380
5546
|
def _handle_post_settings(self) -> None:
|
|
5381
5547
|
"""Persist a settings update and trigger an immediate SSE broadcast.
|
|
5382
5548
|
|
|
@@ -5384,8 +5550,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5384
5550
|
"update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}},
|
|
5385
5551
|
"cache_report"?: {"anomaly_threshold_pp"?: int},
|
|
5386
5552
|
"budget"?: {"weekly_usd"?: number|null, "alerts_enabled"?: bool,
|
|
5387
|
-
"alert_thresholds"?: int[], "projected_enabled"?: bool
|
|
5388
|
-
|
|
5553
|
+
"alert_thresholds"?: int[], "projected_enabled"?: bool,
|
|
5554
|
+
"codex"?: {"alerts_enabled"?: bool, "projected_enabled"?: bool}}}``
|
|
5555
|
+
— every top-level key is optional; any subset may be sent together
|
|
5389
5556
|
(combined save). Unknown top-level keys are rejected with 400.
|
|
5390
5557
|
|
|
5391
5558
|
Per-block validation:
|
|
@@ -5412,6 +5579,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5412
5579
|
block and validated via ``_get_budget_config(merged)`` (issue
|
|
5413
5580
|
#19, projected toggle #121); ``_BudgetConfigError`` → 400.
|
|
5414
5581
|
Budget is its OWN config block, distinct from ``alerts``.
|
|
5582
|
+
``budget.codex`` (#134) is a nested partial-merge: only the two
|
|
5583
|
+
toggles (``alerts_enabled`` / ``projected_enabled``) are
|
|
5584
|
+
dashboard-writable — ``amount_usd`` / ``period`` /
|
|
5585
|
+
``alert_thresholds`` stay CLI-only and are preserved from the
|
|
5586
|
+
persisted block. A non-dict ``budget.codex`` → 400; toggling
|
|
5587
|
+
``budget.codex.*`` when no Codex budget is configured → 400
|
|
5588
|
+
(fail-closed; amounts are never invented from the dashboard).
|
|
5415
5589
|
|
|
5416
5590
|
Atomic merged write: if all touched blocks validate, the merged
|
|
5417
5591
|
config is persisted in a single ``save_config`` call inside the
|
|
@@ -5755,6 +5929,36 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5755
5929
|
):
|
|
5756
5930
|
if leaf in budget_in:
|
|
5757
5931
|
merged_budget[leaf] = budget_in[leaf]
|
|
5932
|
+
# Nested partial-merge for the Codex sub-block (#134). Mirrors
|
|
5933
|
+
# the ``update.check`` nested-dict merge below: only the two
|
|
5934
|
+
# alert toggles (``alerts_enabled`` / ``projected_enabled``) are
|
|
5935
|
+
# dashboard-writable — ``amount_usd`` / ``period`` /
|
|
5936
|
+
# ``alert_thresholds`` stay CLI-only (ignored if sent), and the
|
|
5937
|
+
# merge preserves them from the persisted block rather than
|
|
5938
|
+
# replacing the whole ``codex`` dict (the clobber regression).
|
|
5939
|
+
if "codex" in budget_in:
|
|
5940
|
+
incoming_codex = budget_in["codex"]
|
|
5941
|
+
if not isinstance(incoming_codex, dict):
|
|
5942
|
+
self._respond_json(
|
|
5943
|
+
400, {"error": "budget.codex must be an object"}
|
|
5944
|
+
)
|
|
5945
|
+
return
|
|
5946
|
+
existing_codex = merged_budget.get("codex")
|
|
5947
|
+
if existing_codex is None:
|
|
5948
|
+
# Fail closed: the dashboard only TOGGLES an existing
|
|
5949
|
+
# Codex budget — amounts are CLI-only — so it must never
|
|
5950
|
+
# invent one. The frontend disables the toggle, this
|
|
5951
|
+
# backstops a direct POST.
|
|
5952
|
+
self._respond_json(400, {"error": (
|
|
5953
|
+
"no Codex budget configured — set one via the CLI "
|
|
5954
|
+
"first (cctally budget set <amount> --vendor codex)"
|
|
5955
|
+
)})
|
|
5956
|
+
return
|
|
5957
|
+
merged_codex = dict(existing_codex)
|
|
5958
|
+
for sub in ("alerts_enabled", "projected_enabled"):
|
|
5959
|
+
if sub in incoming_codex:
|
|
5960
|
+
merged_codex[sub] = bool(incoming_codex[sub])
|
|
5961
|
+
merged_budget["codex"] = merged_codex
|
|
5758
5962
|
merged["budget"] = merged_budget
|
|
5759
5963
|
# Final validation against the merged block.
|
|
5760
5964
|
# _BudgetConfigError → 400 (no partial write — save_config
|
|
@@ -5861,6 +6065,21 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5861
6065
|
_cctally()._reconcile_project_budget_milestones_on_write(
|
|
5862
6066
|
validated_budget
|
|
5863
6067
|
)
|
|
6068
|
+
# Codex actual-spend reconcile (#134). Key it on the ``alerts_enabled``
|
|
6069
|
+
# SUB-leaf specifically — NOT ``"codex" in touched``. The helper
|
|
6070
|
+
# (`_reconcile_codex_budget_on_config_write`) is itself gated on
|
|
6071
|
+
# alerts_enabled && amount && thresholds, so a coarse "codex touched"
|
|
6072
|
+
# check would run it whenever alerts is already True — meaning a
|
|
6073
|
+
# ``projected_enabled``-only toggle would latch (silently suppress) a
|
|
6074
|
+
# still-unfired actual-spend crossing. Keying on the sub-leaf means
|
|
6075
|
+
# flipping alerts ON latches already-crossed thresholds (intended),
|
|
6076
|
+
# while toggling projected reconciles nothing (projected stays
|
|
6077
|
+
# live-pace, Q4).
|
|
6078
|
+
codex_in = budget_in.get("codex")
|
|
6079
|
+
if isinstance(codex_in, dict) and "alerts_enabled" in codex_in:
|
|
6080
|
+
_cctally()._reconcile_codex_budget_on_config_write(
|
|
6081
|
+
validated_budget
|
|
6082
|
+
)
|
|
5864
6083
|
if update_check_validated is not None:
|
|
5865
6084
|
# Echo the full merged check block (cooked defaults included)
|
|
5866
6085
|
# so the SettingsOverlay can repaint without a follow-up GET.
|
|
@@ -5959,25 +6178,30 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5959
6178
|
axis = body.get("axis", "weekly")
|
|
5960
6179
|
if axis not in (
|
|
5961
6180
|
"weekly", "five_hour", "budget", "projected", "project_budget",
|
|
6181
|
+
"codex_budget",
|
|
5962
6182
|
):
|
|
5963
6183
|
self._respond_json(
|
|
5964
6184
|
400,
|
|
5965
6185
|
{"error": (
|
|
5966
6186
|
"axis must be 'weekly', 'five_hour', 'budget', "
|
|
5967
|
-
|
|
6187
|
+
"'projected', 'project_budget' or 'codex_budget', "
|
|
6188
|
+
f"got {axis!r}"
|
|
5968
6189
|
)},
|
|
5969
6190
|
)
|
|
5970
6191
|
return
|
|
5971
6192
|
# ``metric`` discriminates the projected axis (weekly_pct vs
|
|
5972
|
-
# budget_usd); the other axes ignore it. Validate
|
|
5973
|
-
# matters so a stray metric on a weekly/budget test isn't
|
|
6193
|
+
# budget_usd vs codex_budget_usd); the other axes ignore it. Validate
|
|
6194
|
+
# only when it matters so a stray metric on a weekly/budget test isn't
|
|
6195
|
+
# a 400.
|
|
5974
6196
|
metric = body.get("metric", "weekly_pct")
|
|
5975
|
-
if axis == "projected" and metric not in (
|
|
6197
|
+
if axis == "projected" and metric not in (
|
|
6198
|
+
"weekly_pct", "budget_usd", "codex_budget_usd",
|
|
6199
|
+
):
|
|
5976
6200
|
self._respond_json(
|
|
5977
6201
|
400,
|
|
5978
6202
|
{"error": (
|
|
5979
|
-
"metric must be 'weekly_pct'
|
|
5980
|
-
f"got {metric!r}"
|
|
6203
|
+
"metric must be 'weekly_pct', 'budget_usd' or "
|
|
6204
|
+
f"'codex_budget_usd', got {metric!r}"
|
|
5981
6205
|
)},
|
|
5982
6206
|
)
|
|
5983
6207
|
return
|
|
@@ -6031,6 +6255,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6031
6255
|
spent_usd=26.0,
|
|
6032
6256
|
consumption_pct=104.0,
|
|
6033
6257
|
)
|
|
6258
|
+
elif axis == "codex_budget":
|
|
6259
|
+
# Synthetic Codex budget payload — mirrors the CLI
|
|
6260
|
+
# ``alerts test --axis codex-budget`` branch (NO DB writes,
|
|
6261
|
+
# test/real divergence contract, NO real budget.codex entry
|
|
6262
|
+
# required). A $200 calendar-month budget reads plausibly; spent
|
|
6263
|
+
# scaled to the threshold so the body line reads as the
|
|
6264
|
+
# at-crossing snapshot the dashboard would render (R4).
|
|
6265
|
+
payload = _build_alert_payload_codex_budget(
|
|
6266
|
+
threshold=threshold,
|
|
6267
|
+
crossed_at_utc=now_utc_iso(),
|
|
6268
|
+
period_start_at=dt.date.today().replace(day=1).isoformat(),
|
|
6269
|
+
period="calendar-month",
|
|
6270
|
+
budget_usd=200.0,
|
|
6271
|
+
spent_usd=200.0 * threshold / 100.0,
|
|
6272
|
+
consumption_pct=float(threshold),
|
|
6273
|
+
)
|
|
6034
6274
|
elif axis == "projected":
|
|
6035
6275
|
# Synthetic projected-pace payload — mirrors the CLI
|
|
6036
6276
|
# cmd_alerts_test projected branch (NO DB writes, test/real
|
|
@@ -6039,10 +6279,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6039
6279
|
# value (so the body reads plausibly, e.g. weekly 100% → "~100% of
|
|
6040
6280
|
# cap", budget 100% → "$300 of $300"). denominator is the
|
|
6041
6281
|
# at-crossing target the row would carry (Codex P0-4): 100.0 for
|
|
6042
|
-
# weekly_pct, $300 for budget_usd
|
|
6282
|
+
# weekly_pct, $300 for budget_usd, $200 for codex_budget_usd
|
|
6283
|
+
# (matching the codex_budget axis test-alert budget).
|
|
6043
6284
|
if metric == "budget_usd":
|
|
6044
6285
|
denominator = 300.0
|
|
6045
6286
|
projected_value = 300.0 * threshold / 100.0
|
|
6287
|
+
elif metric == "codex_budget_usd":
|
|
6288
|
+
denominator = 200.0
|
|
6289
|
+
projected_value = 200.0 * threshold / 100.0
|
|
6046
6290
|
else: # weekly_pct
|
|
6047
6291
|
denominator = 100.0
|
|
6048
6292
|
projected_value = float(threshold)
|
|
@@ -6901,6 +7145,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6901
7145
|
display_tz_pref_override=type(self).display_tz_pref_override,
|
|
6902
7146
|
runtime_bind=type(self).cctally_host,
|
|
6903
7147
|
)
|
|
7148
|
+
# Conversation viewer (Plan 2, spec §5): inject the client signal
|
|
7149
|
+
# PER-REQUEST and Host-aware — NOT inside snapshot_to_envelope (the
|
|
7150
|
+
# request-independent SSE snapshot has no Host header). Routes through
|
|
7151
|
+
# the SAME predicate as the transcript GET-route gate so a LAN-hostname
|
|
7152
|
+
# request that the transcript GETs would 403 shows
|
|
7153
|
+
# transcriptsEnabled=false, never enabled-then-403 (the pass-2 P2
|
|
7154
|
+
# finding) — one predicate, two consumers, desync impossible.
|
|
7155
|
+
env["transcriptsEnabled"] = self._transcripts_visible_to_request()
|
|
6904
7156
|
body = json.dumps(env, ensure_ascii=False).encode("utf-8")
|
|
6905
7157
|
self.send_response(200)
|
|
6906
7158
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
@@ -6990,6 +7242,108 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6990
7242
|
self.end_headers()
|
|
6991
7243
|
self.wfile.write(body)
|
|
6992
7244
|
|
|
7245
|
+
@staticmethod
|
|
7246
|
+
def _conversation_query():
|
|
7247
|
+
"""Lazy-load the pure conversation query kernel (Plan 2, §3)."""
|
|
7248
|
+
return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
|
|
7249
|
+
|
|
7250
|
+
def _handle_get_conversations(self) -> None:
|
|
7251
|
+
"""``GET /api/conversations`` — the browse rail (spec §3.1).
|
|
7252
|
+
|
|
7253
|
+
Gated first (loopback / Host allowlist). ``sort``/``limit``/``offset``
|
|
7254
|
+
are read from the query string; the kernel clamps bounds. Cache-open
|
|
7255
|
+
failures are 500s, never 5xx-with-stacktrace.
|
|
7256
|
+
"""
|
|
7257
|
+
if not self._require_transcripts_allowed():
|
|
7258
|
+
return
|
|
7259
|
+
import urllib.parse as _u
|
|
7260
|
+
q = _u.parse_qs(self.path.partition("?")[2])
|
|
7261
|
+
sort = (q.get("sort", ["recent"]) or ["recent"])[0]
|
|
7262
|
+
limit = _qs_int(q, "limit", 50)
|
|
7263
|
+
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}"})
|
|
7268
|
+
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
|
+
self._respond_json(200, body)
|
|
7279
|
+
|
|
7280
|
+
def _handle_get_conversation_detail(self, path: str) -> None:
|
|
7281
|
+
"""``GET /api/conversation/<session-id>`` — the reader (spec §3.2).
|
|
7282
|
+
|
|
7283
|
+
The id is percent-decoded so clients that encode reserved chars
|
|
7284
|
+
round-trip. Unknown id → 404. ``after``/``limit`` page the items.
|
|
7285
|
+
"""
|
|
7286
|
+
if not self._require_transcripts_allowed():
|
|
7287
|
+
return
|
|
7288
|
+
import urllib.parse as _u
|
|
7289
|
+
# ``path`` is already query-stripped by ``do_GET`` (``self.path.split("?")``),
|
|
7290
|
+
# so the cursor params (?after=/?limit=) live ONLY on the raw ``self.path``.
|
|
7291
|
+
# Sibling handlers read ``self.path`` directly — the detail route must too,
|
|
7292
|
+
# or every request re-serves the head and pagination is dead.
|
|
7293
|
+
query_str = self.path.partition("?")[2]
|
|
7294
|
+
session_id = _u.unquote(path[len("/api/conversation/"):])
|
|
7295
|
+
if not session_id:
|
|
7296
|
+
self.send_error(404, "conversation not found")
|
|
7297
|
+
return
|
|
7298
|
+
q = _u.parse_qs(query_str)
|
|
7299
|
+
after = (q.get("after", [None]) or [None])[0]
|
|
7300
|
+
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}"})
|
|
7312
|
+
return
|
|
7313
|
+
finally:
|
|
7314
|
+
conn.close()
|
|
7315
|
+
if body is None:
|
|
7316
|
+
self.send_error(404, "conversation not found")
|
|
7317
|
+
return
|
|
7318
|
+
self._respond_json(200, body)
|
|
7319
|
+
|
|
7320
|
+
def _handle_get_conversation_search(self) -> None:
|
|
7321
|
+
"""``GET /api/conversation/search?q=...`` — cross-session FTS/LIKE
|
|
7322
|
+
search (spec §3.3). Matched BEFORE the ``<id>`` reader in ``do_GET``.
|
|
7323
|
+
"""
|
|
7324
|
+
if not self._require_transcripts_allowed():
|
|
7325
|
+
return
|
|
7326
|
+
import urllib.parse as _u
|
|
7327
|
+
q = _u.parse_qs(self.path.partition("?")[2])
|
|
7328
|
+
query = (q.get("q", [""]) or [""])[0]
|
|
7329
|
+
limit = _qs_int(q, "limit", 50)
|
|
7330
|
+
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}"})
|
|
7342
|
+
return
|
|
7343
|
+
finally:
|
|
7344
|
+
conn.close()
|
|
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).
|
|
@@ -7515,6 +7869,14 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
7515
7869
|
# in the doctor SSE block + /api/doctor reflects the actual --host
|
|
7516
7870
|
# the process is serving, not just the config-only view the CLI sees.
|
|
7517
7871
|
DashboardHTTPHandler.cctally_host = args.host
|
|
7872
|
+
# Conversation viewer (Plan 2, spec §5): the resolved
|
|
7873
|
+
# `dashboard.expose_transcripts` opt-in. Read off the already-loaded
|
|
7874
|
+
# `config` the same way `dashboard.bind` is resolved above (the
|
|
7875
|
+
# `_config_known_value` shim surfaces the boolean default of False for
|
|
7876
|
+
# an absent or hand-edited-junk value).
|
|
7877
|
+
DashboardHTTPHandler.cctally_expose_transcripts = bool(
|
|
7878
|
+
_config_known_value(config, "dashboard.expose_transcripts")
|
|
7879
|
+
)
|
|
7518
7880
|
DashboardHTTPHandler.run_sync_now = staticmethod(
|
|
7519
7881
|
lambda: _run_sync_now(skip_sync=args.no_sync)
|
|
7520
7882
|
)
|