cctally 1.68.0 → 1.69.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 +56 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +644 -107
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +168 -5
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +303 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +118 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +298 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +202 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +324 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
|
@@ -38,7 +38,9 @@ import json
|
|
|
38
38
|
import pathlib
|
|
39
39
|
import sqlite3
|
|
40
40
|
import sys
|
|
41
|
+
from collections.abc import Mapping
|
|
41
42
|
from dataclasses import replace
|
|
43
|
+
from types import SimpleNamespace
|
|
42
44
|
from zoneinfo import ZoneInfo
|
|
43
45
|
|
|
44
46
|
from _cctally_core import open_db, parse_iso_datetime
|
|
@@ -1254,6 +1256,405 @@ def _share_load_templates_module_impl(handler):
|
|
|
1254
1256
|
raise
|
|
1255
1257
|
return mod
|
|
1256
1258
|
|
|
1259
|
+
|
|
1260
|
+
def _share_source_selection(req: dict) -> tuple[str, bool]:
|
|
1261
|
+
"""Resolve S4's optional source field without changing legacy requests."""
|
|
1262
|
+
explicit = "source" in req
|
|
1263
|
+
source = req.get("source", "claude")
|
|
1264
|
+
if source not in ("claude", "codex", "all"):
|
|
1265
|
+
raise ValueError("source capability unavailable")
|
|
1266
|
+
return source, explicit
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _source_state_for_share(data_snap, source: str):
|
|
1270
|
+
try:
|
|
1271
|
+
bundle = data_snap.source_bundle
|
|
1272
|
+
return bundle.sources[source]
|
|
1273
|
+
except (AttributeError, KeyError, TypeError) as exc:
|
|
1274
|
+
raise ValueError("source capability unavailable") from exc
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _share_codex_range_start(panel: str, now_utc: "dt.datetime",
|
|
1278
|
+
custom_start: "dt.datetime | None") -> "dt.datetime":
|
|
1279
|
+
"""Return the bounded cache range used for a non-current Codex share.
|
|
1280
|
+
|
|
1281
|
+
The native source projection has no hidden live-current fallback: every
|
|
1282
|
+
range is derived from the requested panel period and the source builder is
|
|
1283
|
+
called with ``sync=False`` by construction. Keep these spans aligned with
|
|
1284
|
+
the legacy dashboard builders' visible windows.
|
|
1285
|
+
"""
|
|
1286
|
+
if custom_start is not None:
|
|
1287
|
+
return custom_start
|
|
1288
|
+
if panel == "daily":
|
|
1289
|
+
return now_utc - dt.timedelta(days=31) # 30 rows plus boundary slack
|
|
1290
|
+
if panel == "monthly":
|
|
1291
|
+
year, month = now_utc.year, now_utc.month
|
|
1292
|
+
for _ in range(11):
|
|
1293
|
+
month -= 1
|
|
1294
|
+
if month == 0:
|
|
1295
|
+
year, month = year - 1, 12
|
|
1296
|
+
return dt.datetime(year, month, 1, tzinfo=dt.timezone.utc)
|
|
1297
|
+
if panel == "weekly":
|
|
1298
|
+
return now_utc - dt.timedelta(days=7 * 13)
|
|
1299
|
+
if panel == "blocks":
|
|
1300
|
+
return now_utc - dt.timedelta(days=7)
|
|
1301
|
+
return now_utc - dt.timedelta(days=30)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _share_codex_state_for_period(data_snap, *, panel: str, options: dict):
|
|
1305
|
+
"""Return the selected Codex state, rebuilding non-current requests safely.
|
|
1306
|
+
|
|
1307
|
+
Dashboard snapshots intentionally contain the live/current source bundle.
|
|
1308
|
+
Share period overrides rebuild their legacy Claude panel fields, but using
|
|
1309
|
+
that unchanged bundle for Codex would mislabel current provider data as a
|
|
1310
|
+
past/custom export. Rebuild only the selected Codex read model over the
|
|
1311
|
+
requested bounded range; its source adapters are cache/stats readers and
|
|
1312
|
+
use ``sync=False`` internally. The resulting state is request-local and
|
|
1313
|
+
never replaces the published snapshot.
|
|
1314
|
+
"""
|
|
1315
|
+
now_override, start_override, err = _share_resolve_period(panel, options)
|
|
1316
|
+
if err is not None:
|
|
1317
|
+
raise ValueError("source capability unavailable")
|
|
1318
|
+
if now_override is None:
|
|
1319
|
+
return _source_state_for_share(data_snap, "codex")
|
|
1320
|
+
|
|
1321
|
+
from _cctally_cache import open_cache_db
|
|
1322
|
+
from _cctally_dashboard_sources import (
|
|
1323
|
+
DashboardReadContext,
|
|
1324
|
+
build_codex_source_state,
|
|
1325
|
+
resolve_dashboard_source_semantics,
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
range_start = _share_codex_range_start(panel, now_override, start_override)
|
|
1329
|
+
config = sys.modules["cctally"].load_config()
|
|
1330
|
+
display_tz_name = options.get("display_tz")
|
|
1331
|
+
if display_tz_name == "utc":
|
|
1332
|
+
display_tz_name = "UTC"
|
|
1333
|
+
elif display_tz_name == "local" or not isinstance(display_tz_name, str):
|
|
1334
|
+
display_tz_name = None
|
|
1335
|
+
semantics = resolve_dashboard_source_semantics(
|
|
1336
|
+
config, display_tz_name=display_tz_name,
|
|
1337
|
+
)
|
|
1338
|
+
stats_conn = open_db()
|
|
1339
|
+
cache_conn = open_cache_db()
|
|
1340
|
+
try:
|
|
1341
|
+
return build_codex_source_state(
|
|
1342
|
+
DashboardReadContext(
|
|
1343
|
+
cache_conn=cache_conn,
|
|
1344
|
+
stats_conn=stats_conn,
|
|
1345
|
+
range_start=range_start,
|
|
1346
|
+
now_utc=now_override,
|
|
1347
|
+
display_tz_name=semantics.display_tz_name,
|
|
1348
|
+
week_start_idx=semantics.week_start_idx,
|
|
1349
|
+
week_start_name=semantics.week_start_name,
|
|
1350
|
+
speed=semantics.speed,
|
|
1351
|
+
codex_budget=semantics.codex_budget,
|
|
1352
|
+
),
|
|
1353
|
+
data_version=(
|
|
1354
|
+
f"share:codex:{panel}:{range_start.isoformat()}:"
|
|
1355
|
+
f"{now_override.isoformat()}:{semantics.identity}"
|
|
1356
|
+
),
|
|
1357
|
+
)
|
|
1358
|
+
finally:
|
|
1359
|
+
cache_conn.close()
|
|
1360
|
+
stats_conn.close()
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _share_parse_bucket_start(panel: str, label: object) -> "dt.datetime | None":
|
|
1364
|
+
try:
|
|
1365
|
+
if panel == "monthly":
|
|
1366
|
+
return dt.datetime.strptime(str(label), "%Y-%m").replace(tzinfo=dt.timezone.utc)
|
|
1367
|
+
return dt.datetime.fromisoformat(str(label)).replace(tzinfo=dt.timezone.utc)
|
|
1368
|
+
except ValueError:
|
|
1369
|
+
return None
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _share_codex_period_bounds(*, state, panel: str, options: dict, rows) -> tuple:
|
|
1373
|
+
now_override, start_override, err = _share_resolve_period(panel, options)
|
|
1374
|
+
if err is not None:
|
|
1375
|
+
raise ValueError("source capability unavailable")
|
|
1376
|
+
end = now_override or state.last_success_at or dt.datetime.now(dt.timezone.utc)
|
|
1377
|
+
if end.tzinfo is None or end.utcoffset() is None:
|
|
1378
|
+
end = end.replace(tzinfo=dt.timezone.utc)
|
|
1379
|
+
end = end.astimezone(dt.timezone.utc)
|
|
1380
|
+
if start_override is not None:
|
|
1381
|
+
return start_override.astimezone(dt.timezone.utc), end
|
|
1382
|
+
starts = []
|
|
1383
|
+
for row in rows:
|
|
1384
|
+
if not isinstance(row, Mapping):
|
|
1385
|
+
continue
|
|
1386
|
+
raw = row.get("first_seen") or row.get("last_activity") or row.get("label")
|
|
1387
|
+
parsed = _share_parse_bucket_start("monthly" if panel == "monthly" else "daily", raw)
|
|
1388
|
+
if parsed is not None:
|
|
1389
|
+
starts.append(parsed)
|
|
1390
|
+
if panel == "current-week":
|
|
1391
|
+
starts = [
|
|
1392
|
+
parsed for row in rows if isinstance(row, Mapping)
|
|
1393
|
+
if (parsed := _share_parse_bucket_start("weekly", row.get("label"))) is not None
|
|
1394
|
+
]
|
|
1395
|
+
return (max(starts) if starts else end - dt.timedelta(days=7)), end
|
|
1396
|
+
return (min(starts) if starts else end), end
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
1400
|
+
template_id: str, options: dict):
|
|
1401
|
+
"""Adapt S4 normalized data through canonical Codex share kernels."""
|
|
1402
|
+
data = state.data
|
|
1403
|
+
if not isinstance(data, Mapping) or state.availability == "unavailable":
|
|
1404
|
+
raise ValueError("source capability unavailable")
|
|
1405
|
+
required_domain = {
|
|
1406
|
+
"current-week": "hero",
|
|
1407
|
+
"daily": "periods",
|
|
1408
|
+
"monthly": "periods",
|
|
1409
|
+
"weekly": "periods",
|
|
1410
|
+
"blocks": "quota",
|
|
1411
|
+
"sessions": "sessions",
|
|
1412
|
+
"projects": "projects",
|
|
1413
|
+
}.get(panel)
|
|
1414
|
+
if required_domain is None or required_domain not in data:
|
|
1415
|
+
raise ValueError("source capability unavailable")
|
|
1416
|
+
availability = state.availability if state.availability in ("ok", "empty") else "unavailable"
|
|
1417
|
+
reason = "source data unavailable" if availability == "unavailable" else None
|
|
1418
|
+
hero = data.get("hero") if isinstance(data.get("hero"), Mapping) else {}
|
|
1419
|
+
if panel == "current-week":
|
|
1420
|
+
periods = data.get("periods")
|
|
1421
|
+
weekly = periods.get("weekly") if isinstance(periods, Mapping) else None
|
|
1422
|
+
if not isinstance(weekly, Mapping):
|
|
1423
|
+
raise ValueError("source capability unavailable")
|
|
1424
|
+
all_rows = tuple(weekly.get("rows", ()))
|
|
1425
|
+
source_rows = all_rows[-1:] if all_rows else ()
|
|
1426
|
+
command = "codex-weekly"
|
|
1427
|
+
display_tz = str(weekly.get("display_tz") or "UTC")
|
|
1428
|
+
elif panel in ("daily", "monthly", "weekly"):
|
|
1429
|
+
periods = data.get("periods")
|
|
1430
|
+
panel_data = periods.get(panel) if isinstance(periods, Mapping) else {}
|
|
1431
|
+
if not isinstance(panel_data, Mapping):
|
|
1432
|
+
raise ValueError("source capability unavailable")
|
|
1433
|
+
source_rows = tuple(panel_data.get("rows", ()))
|
|
1434
|
+
command = f"codex-{panel}"
|
|
1435
|
+
display_tz = str(panel_data.get("display_tz") or "UTC")
|
|
1436
|
+
elif panel == "sessions":
|
|
1437
|
+
panel_data = data.get(panel) if isinstance(data.get(panel), Mapping) else {}
|
|
1438
|
+
source_rows = tuple(panel_data.get("rows", ())) if isinstance(panel_data, Mapping) else ()
|
|
1439
|
+
command = "codex-session"
|
|
1440
|
+
display_tz = "UTC"
|
|
1441
|
+
elif panel == "projects":
|
|
1442
|
+
panel_data = data.get("projects") if isinstance(data.get("projects"), Mapping) else {}
|
|
1443
|
+
source_rows = tuple(panel_data.get("rows", ())) if isinstance(panel_data, Mapping) else ()
|
|
1444
|
+
start, end = _share_codex_period_bounds(
|
|
1445
|
+
state=state, panel=panel, options=options, rows=source_rows,
|
|
1446
|
+
)
|
|
1447
|
+
rows = tuple(ls.Row(cells={
|
|
1448
|
+
"project": ls.ProjectCell(
|
|
1449
|
+
str(row.get("label", "Project")),
|
|
1450
|
+
float(row.get("cost_usd", 0.0) or 0.0),
|
|
1451
|
+
identity=str(row.get("key")),
|
|
1452
|
+
),
|
|
1453
|
+
"tokens": ls.TextCell(f"{int(row.get('total_tokens', 0) or 0):,}"),
|
|
1454
|
+
"cost": ls.MoneyCell(float(row.get("cost_usd", 0.0) or 0.0)),
|
|
1455
|
+
}) for row in source_rows if isinstance(row, Mapping))
|
|
1456
|
+
return ls.ShareSnapshot(
|
|
1457
|
+
cmd="project", title="Codex Project Usage", subtitle=None,
|
|
1458
|
+
period=ls.PeriodSpec(start=start, end=end, display_tz="UTC", label=None),
|
|
1459
|
+
columns=(
|
|
1460
|
+
ls.ColumnSpec(key="project", label="Project"),
|
|
1461
|
+
ls.ColumnSpec(key="tokens", label="Tokens", align="right"),
|
|
1462
|
+
ls.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
1463
|
+
),
|
|
1464
|
+
rows=rows, chart=None,
|
|
1465
|
+
totals=(ls.Totalled(label="Total", value=f"${float(panel_data.get('total_cost_usd', 0.0) or 0.0):,.2f}"),),
|
|
1466
|
+
notes=(), generated_at=end, version=sys.modules["cctally"]._share_resolve_version(),
|
|
1467
|
+
template_id=template_id, source="codex", source_label="Codex",
|
|
1468
|
+
availability=availability, availability_reason=reason,
|
|
1469
|
+
)
|
|
1470
|
+
else: # blocks
|
|
1471
|
+
quota = data.get("quota")
|
|
1472
|
+
panel_data = quota if isinstance(quota, Mapping) else {}
|
|
1473
|
+
source_rows = tuple(panel_data.get("blocks", ())) if isinstance(panel_data, Mapping) else ()
|
|
1474
|
+
start, end = _share_codex_period_bounds(
|
|
1475
|
+
state=state, panel=panel, options=options, rows=source_rows,
|
|
1476
|
+
)
|
|
1477
|
+
columns = (
|
|
1478
|
+
ls.ColumnSpec(key="label", label="Quota", align="left"),
|
|
1479
|
+
ls.ColumnSpec(key="usage", label="Usage", align="right"),
|
|
1480
|
+
ls.ColumnSpec(key="resets", label="Resets", align="right"),
|
|
1481
|
+
)
|
|
1482
|
+
def cells(row):
|
|
1483
|
+
percent = row.get("current_percent", 0.0)
|
|
1484
|
+
return {
|
|
1485
|
+
"label": ls.TextCell(str(row.get("label", "Codex quota"))),
|
|
1486
|
+
"usage": ls.TextCell(f"{float(percent or 0.0):.1f}%"),
|
|
1487
|
+
"resets": ls.TextCell(str(row.get("resets_at", "—"))),
|
|
1488
|
+
}
|
|
1489
|
+
rows = tuple(ls.Row(cells=cells(row)) for row in source_rows if isinstance(row, Mapping))
|
|
1490
|
+
return ls.ShareSnapshot(
|
|
1491
|
+
cmd="codex-quota", title="Codex Quota Windows", subtitle=None,
|
|
1492
|
+
period=ls.PeriodSpec(start=start, end=end, display_tz="UTC", label=None),
|
|
1493
|
+
columns=columns, rows=rows, chart=None,
|
|
1494
|
+
totals=(), notes=(), generated_at=end,
|
|
1495
|
+
version=sys.modules["cctally"]._share_resolve_version(),
|
|
1496
|
+
template_id=template_id, source="codex", source_label="Codex",
|
|
1497
|
+
availability=availability, availability_reason=reason,
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
start, end = _share_codex_period_bounds(
|
|
1501
|
+
state=state, panel=panel, options=options, rows=source_rows,
|
|
1502
|
+
)
|
|
1503
|
+
normalized_rows = tuple(
|
|
1504
|
+
SimpleNamespace(
|
|
1505
|
+
bucket=str(row.get("label", "—")),
|
|
1506
|
+
total_tokens=int(row.get("total_tokens", 0) or 0),
|
|
1507
|
+
cost_usd=float(row.get("cost_usd", 0.0) or 0.0),
|
|
1508
|
+
last_activity=parse_iso_datetime(str(row.get("last_activity")), "codex.session.last_activity")
|
|
1509
|
+
if command == "codex-session" else None,
|
|
1510
|
+
)
|
|
1511
|
+
for row in source_rows if isinstance(row, Mapping)
|
|
1512
|
+
)
|
|
1513
|
+
view = SimpleNamespace(
|
|
1514
|
+
rows=normalized_rows,
|
|
1515
|
+
total_cost_usd=stable_sum(row.cost_usd for row in normalized_rows),
|
|
1516
|
+
total_tokens=sum(row.total_tokens for row in normalized_rows),
|
|
1517
|
+
period_start=start,
|
|
1518
|
+
period_end=end,
|
|
1519
|
+
display_tz_label=display_tz,
|
|
1520
|
+
)
|
|
1521
|
+
codex_module = sys.modules["cctally"]._load_sibling("_cctally_codex")
|
|
1522
|
+
return replace(
|
|
1523
|
+
codex_module._build_codex_share_snapshot(command, view, normalized_rows),
|
|
1524
|
+
template_id=template_id,
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _share_plain_value(value):
|
|
1529
|
+
if isinstance(value, Mapping):
|
|
1530
|
+
return {str(key): _share_plain_value(item) for key, item in value.items()}
|
|
1531
|
+
if isinstance(value, (tuple, list)):
|
|
1532
|
+
return [_share_plain_value(item) for item in value]
|
|
1533
|
+
if isinstance(value, dt.datetime):
|
|
1534
|
+
return value.astimezone(dt.timezone.utc).isoformat()
|
|
1535
|
+
return value
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def _share_state_domain(state, panel: str):
|
|
1539
|
+
data = state.data if isinstance(state.data, Mapping) else {}
|
|
1540
|
+
if panel in ("daily", "monthly", "weekly", "current-week"):
|
|
1541
|
+
periods = data.get("periods") if isinstance(data.get("periods"), Mapping) else {}
|
|
1542
|
+
key = "weekly" if panel == "current-week" else panel
|
|
1543
|
+
return periods.get(key)
|
|
1544
|
+
if panel == "blocks":
|
|
1545
|
+
return data.get("quota")
|
|
1546
|
+
return data.get(panel)
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _share_digest_input(*, panel: str, template_id: str, source: str,
|
|
1550
|
+
source_explicit: bool, states, snapshots,
|
|
1551
|
+
panel_data):
|
|
1552
|
+
if not source_explicit:
|
|
1553
|
+
return {
|
|
1554
|
+
"panel": panel,
|
|
1555
|
+
"template_id": template_id,
|
|
1556
|
+
"panel_data": panel_data,
|
|
1557
|
+
}
|
|
1558
|
+
providers = []
|
|
1559
|
+
for state, snapshot in zip(states, snapshots):
|
|
1560
|
+
providers.append({
|
|
1561
|
+
"source": state.source,
|
|
1562
|
+
"data_version": state.data_version,
|
|
1563
|
+
"availability": state.availability,
|
|
1564
|
+
"period": {
|
|
1565
|
+
"start": snapshot.period.start,
|
|
1566
|
+
"end": snapshot.period.end,
|
|
1567
|
+
"display_tz": snapshot.period.display_tz,
|
|
1568
|
+
},
|
|
1569
|
+
"data": _share_plain_value(_share_state_domain(state, panel)),
|
|
1570
|
+
})
|
|
1571
|
+
return {
|
|
1572
|
+
"panel": panel,
|
|
1573
|
+
"template_id": template_id,
|
|
1574
|
+
"source": source,
|
|
1575
|
+
"providers": providers,
|
|
1576
|
+
**(
|
|
1577
|
+
{"claude_panel_data": panel_data}
|
|
1578
|
+
if source in ("claude", "all") else {}
|
|
1579
|
+
),
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def _share_build_source_snapshots(*, ls, template, template_id: str,
|
|
1584
|
+
panel: str, options: dict, source: str,
|
|
1585
|
+
source_explicit: bool, data_snap):
|
|
1586
|
+
"""Branch by provider before invoking any provider-specific builder."""
|
|
1587
|
+
claude_snapshot = None
|
|
1588
|
+
claude_state = None
|
|
1589
|
+
panel_data = None
|
|
1590
|
+
if source in ("claude", "all"):
|
|
1591
|
+
claude_data_snap, period_err = _share_apply_period_override(
|
|
1592
|
+
panel, options, data_snap,
|
|
1593
|
+
)
|
|
1594
|
+
if period_err is not None:
|
|
1595
|
+
raise _SharePeriodError(period_err)
|
|
1596
|
+
panel_data = _build_share_panel_data(panel, options, claude_data_snap)
|
|
1597
|
+
claude_snapshot = replace(
|
|
1598
|
+
template.builder(panel_data=panel_data, options=options),
|
|
1599
|
+
template_id=template_id,
|
|
1600
|
+
)
|
|
1601
|
+
if source_explicit or source == "all":
|
|
1602
|
+
claude_snapshot = replace(
|
|
1603
|
+
claude_snapshot, source="claude", source_label="Claude",
|
|
1604
|
+
)
|
|
1605
|
+
# A source-less request is the shipped legacy Claude contract. It
|
|
1606
|
+
# must remain usable by callers whose synthetic/older DataSnapshot
|
|
1607
|
+
# does not carry the additive S4 source bundle.
|
|
1608
|
+
if source_explicit or source == "all":
|
|
1609
|
+
claude_state = _source_state_for_share(data_snap, "claude")
|
|
1610
|
+
|
|
1611
|
+
codex_snapshot = None
|
|
1612
|
+
codex_state = None
|
|
1613
|
+
if source in ("codex", "all"):
|
|
1614
|
+
codex_state = _share_codex_state_for_period(
|
|
1615
|
+
data_snap, panel=panel, options=options,
|
|
1616
|
+
)
|
|
1617
|
+
codex_snapshot = _build_codex_source_share_snapshot(
|
|
1618
|
+
ls,
|
|
1619
|
+
state=codex_state,
|
|
1620
|
+
panel=panel,
|
|
1621
|
+
template_id=template_id,
|
|
1622
|
+
options=options,
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
if source == "claude":
|
|
1626
|
+
return (claude_snapshot,), (claude_state,), panel_data
|
|
1627
|
+
if source == "codex":
|
|
1628
|
+
return (codex_snapshot,), (codex_state,), None
|
|
1629
|
+
return (
|
|
1630
|
+
(claude_snapshot, codex_snapshot),
|
|
1631
|
+
(claude_state, codex_state),
|
|
1632
|
+
panel_data,
|
|
1633
|
+
)
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
class _SharePeriodError(ValueError):
|
|
1637
|
+
"""Carry the established period-validation envelope across dispatch."""
|
|
1638
|
+
|
|
1639
|
+
def __init__(self, payload: Mapping):
|
|
1640
|
+
super().__init__(str(payload.get("error", "invalid period")))
|
|
1641
|
+
self.payload = dict(payload)
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _share_public_failure(handler, exc: Exception, *, phase: str,
|
|
1645
|
+
capability: bool = False) -> None:
|
|
1646
|
+
handler.log_error("/api/share/%s failed: %r", phase, exc)
|
|
1647
|
+
if capability:
|
|
1648
|
+
handler._respond_json(400, {
|
|
1649
|
+
"code": "source_capability_unavailable",
|
|
1650
|
+
"error": "source capability unavailable",
|
|
1651
|
+
})
|
|
1652
|
+
else:
|
|
1653
|
+
handler._respond_json(500, {
|
|
1654
|
+
"code": "source_render_failed",
|
|
1655
|
+
"error": "source render failed",
|
|
1656
|
+
})
|
|
1657
|
+
|
|
1257
1658
|
def _handle_share_templates_get_impl(handler) -> None:
|
|
1258
1659
|
"""List share templates registered for the requested panel.
|
|
1259
1660
|
|
|
@@ -1325,6 +1726,14 @@ def _handle_share_render_post_impl(handler) -> None:
|
|
|
1325
1726
|
if not isinstance(req, dict):
|
|
1326
1727
|
handler._respond_json(400, {"error": "expected JSON object"})
|
|
1327
1728
|
return
|
|
1729
|
+
try:
|
|
1730
|
+
source, source_explicit = _share_source_selection(req)
|
|
1731
|
+
except ValueError:
|
|
1732
|
+
handler._respond_json(400, {
|
|
1733
|
+
"code": "source_capability_unavailable",
|
|
1734
|
+
"error": "source capability unavailable",
|
|
1735
|
+
})
|
|
1736
|
+
return
|
|
1328
1737
|
panel = req.get("panel")
|
|
1329
1738
|
template_id = req.get("template_id")
|
|
1330
1739
|
options = req.get("options") or {}
|
|
@@ -1405,53 +1814,60 @@ def _handle_share_render_post_impl(handler) -> None:
|
|
|
1405
1814
|
})
|
|
1406
1815
|
return
|
|
1407
1816
|
|
|
1408
|
-
# Build panel_data from the live dashboard snapshot — reuses the
|
|
1409
|
-
# already-built `DataSnapshot` so we don't re-query the DB on the
|
|
1410
|
-
# share hot path. `_build_share_panel_data` dispatches per panel.
|
|
1411
1817
|
snap_ref = type(handler).snapshot_ref
|
|
1412
1818
|
data_snap = snap_ref.get() if snap_ref is not None else None
|
|
1413
|
-
# Period override (current / previous / custom). For
|
|
1414
|
-
# `kind='current'` (the default) this is a no-op; otherwise we
|
|
1415
|
-
# re-build the relevant panel's DataSnapshot field from DB with
|
|
1416
|
-
# a shifted `now_utc` before slicing.
|
|
1417
|
-
data_snap, period_err = _share_apply_period_override(panel, options,
|
|
1418
|
-
data_snap)
|
|
1419
|
-
if period_err is not None:
|
|
1420
|
-
handler._respond_json(400, period_err)
|
|
1421
|
-
return
|
|
1422
|
-
try:
|
|
1423
|
-
panel_data = _build_share_panel_data(panel, options, data_snap)
|
|
1424
|
-
except Exception as exc:
|
|
1425
|
-
handler._respond_json(500, {"error": f"panel_data build failed: {exc}"})
|
|
1426
|
-
return
|
|
1427
|
-
|
|
1428
|
-
# Run template builder → kernel render. Builder produces a
|
|
1429
|
-
# ShareSnapshot; `_scrub` anonymizes project labels when the
|
|
1430
|
-
# client opted in to anon-on-export (`reveal_projects=False`).
|
|
1431
1819
|
ls = _share_load_lib()
|
|
1432
1820
|
try:
|
|
1433
|
-
|
|
1821
|
+
source_snaps, source_states, panel_data = _share_build_source_snapshots(
|
|
1822
|
+
ls=ls,
|
|
1823
|
+
template=template,
|
|
1824
|
+
template_id=template_id,
|
|
1825
|
+
panel=panel,
|
|
1826
|
+
options=options,
|
|
1827
|
+
source=source,
|
|
1828
|
+
source_explicit=source_explicit,
|
|
1829
|
+
data_snap=data_snap,
|
|
1830
|
+
)
|
|
1831
|
+
except _SharePeriodError as exc:
|
|
1832
|
+
handler._respond_json(400, exc.payload)
|
|
1833
|
+
return
|
|
1834
|
+
except ValueError as exc:
|
|
1835
|
+
_share_public_failure(handler, exc, phase="render provider", capability=True)
|
|
1836
|
+
return
|
|
1434
1837
|
except Exception as exc:
|
|
1435
|
-
handler
|
|
1838
|
+
_share_public_failure(handler, exc, phase="render provider")
|
|
1436
1839
|
return
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
# corresponding section from the ShareSnapshot. ShareSnapshot
|
|
1441
|
-
# is frozen so we use dataclasses.replace.
|
|
1442
|
-
snap_built = _share_apply_content_toggles(snap_built, options)
|
|
1840
|
+
source_snaps = tuple(
|
|
1841
|
+
_share_apply_content_toggles(item, options) for item in source_snaps
|
|
1842
|
+
)
|
|
1443
1843
|
reveal = bool(options.get("reveal_projects", True))
|
|
1444
1844
|
if not reveal:
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
body = ls.render(
|
|
1448
|
-
snap_built,
|
|
1449
|
-
format=fmt,
|
|
1450
|
-
theme=options.get("theme", "light"),
|
|
1451
|
-
branding=not options.get("no_branding", False),
|
|
1845
|
+
source_snaps = tuple(
|
|
1846
|
+
ls._scrub(item, reveal_projects=False) for item in source_snaps
|
|
1452
1847
|
)
|
|
1848
|
+
try:
|
|
1849
|
+
if source == "all":
|
|
1850
|
+
body = ls.compose(
|
|
1851
|
+
tuple(
|
|
1852
|
+
ls.ComposedSection(snap=item, drift_detected=False)
|
|
1853
|
+
for item in source_snaps
|
|
1854
|
+
),
|
|
1855
|
+
opts=ls.ComposeOptions(
|
|
1856
|
+
title=f"Claude + Codex {panel.replace('-', ' ').title()}",
|
|
1857
|
+
theme=options.get("theme", "light"), format=fmt,
|
|
1858
|
+
no_branding=bool(options.get("no_branding", False)),
|
|
1859
|
+
reveal_projects=reveal,
|
|
1860
|
+
),
|
|
1861
|
+
)
|
|
1862
|
+
else:
|
|
1863
|
+
body = ls.render(
|
|
1864
|
+
source_snaps[0],
|
|
1865
|
+
format=fmt,
|
|
1866
|
+
theme=options.get("theme", "light"),
|
|
1867
|
+
branding=not options.get("no_branding", False),
|
|
1868
|
+
)
|
|
1453
1869
|
except Exception as exc:
|
|
1454
|
-
handler
|
|
1870
|
+
_share_public_failure(handler, exc, phase="render kernel")
|
|
1455
1871
|
return
|
|
1456
1872
|
content_type = {
|
|
1457
1873
|
"md": "text/markdown",
|
|
@@ -1465,11 +1881,15 @@ def _handle_share_render_post_impl(handler) -> None:
|
|
|
1465
1881
|
# detect "section data has drifted since add-time" (spec §5.2 /
|
|
1466
1882
|
# §7.1) — flipping anon-on-export must not register as drift, since
|
|
1467
1883
|
# the underlying data is identical.
|
|
1468
|
-
digest_input =
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1884
|
+
digest_input = _share_digest_input(
|
|
1885
|
+
panel=panel,
|
|
1886
|
+
template_id=template_id,
|
|
1887
|
+
source=source,
|
|
1888
|
+
source_explicit=source_explicit,
|
|
1889
|
+
states=source_states,
|
|
1890
|
+
snapshots=source_snaps,
|
|
1891
|
+
panel_data=panel_data,
|
|
1892
|
+
)
|
|
1473
1893
|
try:
|
|
1474
1894
|
data_digest = ls._data_digest(digest_input)
|
|
1475
1895
|
except Exception:
|
|
@@ -1488,6 +1908,7 @@ def _handle_share_render_post_impl(handler) -> None:
|
|
|
1488
1908
|
"options": options,
|
|
1489
1909
|
"generated_at": _share_now_utc_iso(),
|
|
1490
1910
|
"data_digest": data_digest,
|
|
1911
|
+
**({"source": source} if source_explicit else {}),
|
|
1491
1912
|
},
|
|
1492
1913
|
})
|
|
1493
1914
|
|
|
@@ -1578,6 +1999,17 @@ def _handle_share_compose_post_impl(handler) -> None:
|
|
|
1578
1999
|
template_id = snap_recipe.get("template_id")
|
|
1579
2000
|
sec_opts = snap_recipe.get("options") or {}
|
|
1580
2001
|
digest_at_add = snap_recipe.get("data_digest_at_add") or ""
|
|
2002
|
+
try:
|
|
2003
|
+
source, source_explicit = _share_source_selection(
|
|
2004
|
+
{"source": snap_recipe["source"]}
|
|
2005
|
+
if "source" in snap_recipe else {}
|
|
2006
|
+
)
|
|
2007
|
+
except ValueError:
|
|
2008
|
+
handler._respond_json(400, {
|
|
2009
|
+
"code": "source_capability_unavailable",
|
|
2010
|
+
"error": "source capability unavailable",
|
|
2011
|
+
})
|
|
2012
|
+
return
|
|
1581
2013
|
if (not isinstance(panel, str)
|
|
1582
2014
|
or panel not in tpl_mod.SHARE_CAPABLE_PANELS):
|
|
1583
2015
|
handler._respond_json(400, {
|
|
@@ -1613,59 +2045,71 @@ def _handle_share_compose_post_impl(handler) -> None:
|
|
|
1613
2045
|
"theme": theme, "format": fmt,
|
|
1614
2046
|
"no_branding": no_branding}
|
|
1615
2047
|
composite_opts.setdefault("display_tz", composite_display_tz)
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
2048
|
+
try:
|
|
2049
|
+
source_snaps, source_states, panel_data = _share_build_source_snapshots(
|
|
2050
|
+
ls=ls,
|
|
2051
|
+
template=template,
|
|
2052
|
+
template_id=template_id,
|
|
2053
|
+
panel=panel,
|
|
2054
|
+
options=composite_opts,
|
|
2055
|
+
source=source,
|
|
2056
|
+
source_explicit=source_explicit,
|
|
2057
|
+
data_snap=data_snap,
|
|
2058
|
+
)
|
|
2059
|
+
except _SharePeriodError as exc:
|
|
1622
2060
|
handler._respond_json(400, {
|
|
1623
|
-
"error": f"sections[{idx}]: {
|
|
1624
|
-
"field": f"sections[{idx}].snapshot.{
|
|
2061
|
+
"error": f"sections[{idx}]: {exc.payload['error']}",
|
|
2062
|
+
"field": f"sections[{idx}].snapshot.{exc.payload['field']}",
|
|
1625
2063
|
})
|
|
1626
2064
|
return
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
handler._respond_json(500, {
|
|
1632
|
-
"error": f"sections[{idx}] panel_data build failed: {exc}",
|
|
1633
|
-
})
|
|
2065
|
+
except ValueError as exc:
|
|
2066
|
+
_share_public_failure(
|
|
2067
|
+
handler, exc, phase=f"compose section {idx} provider", capability=True,
|
|
2068
|
+
)
|
|
1634
2069
|
return
|
|
1635
|
-
try:
|
|
1636
|
-
snap_built = template.builder(panel_data=panel_data,
|
|
1637
|
-
options=composite_opts)
|
|
1638
2070
|
except Exception as exc:
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
2071
|
+
_share_public_failure(
|
|
2072
|
+
handler, exc, phase=f"compose section {idx} provider",
|
|
2073
|
+
)
|
|
1642
2074
|
return
|
|
1643
|
-
snap_built = replace(snap_built, template_id=template_id)
|
|
1644
2075
|
# Same content toggles as the single-section render path.
|
|
1645
2076
|
# Per-section `show_chart`/`show_table` from the basket
|
|
1646
2077
|
# recipe are applied here; the composite anon flag is
|
|
1647
2078
|
# already merged into composite_opts upstream.
|
|
1648
|
-
|
|
2079
|
+
source_snaps = tuple(
|
|
2080
|
+
_share_apply_content_toggles(item, composite_opts)
|
|
2081
|
+
for item in source_snaps
|
|
2082
|
+
)
|
|
1649
2083
|
if not reveal_projects:
|
|
1650
|
-
|
|
2084
|
+
source_snaps = tuple(
|
|
2085
|
+
ls._scrub(item, reveal_projects=False) for item in source_snaps
|
|
2086
|
+
)
|
|
1651
2087
|
|
|
1652
2088
|
# Defensive: digest is non-blocking metadata — fall back to
|
|
1653
2089
|
# "" on failure rather than 500-ing the whole compose
|
|
1654
2090
|
# (mirrors the render handler at bin/cctally:33402-33408).
|
|
1655
2091
|
try:
|
|
1656
|
-
digest_now = ls._data_digest(
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
2092
|
+
digest_now = ls._data_digest(_share_digest_input(
|
|
2093
|
+
panel=panel,
|
|
2094
|
+
template_id=template_id,
|
|
2095
|
+
source=source,
|
|
2096
|
+
source_explicit=source_explicit,
|
|
2097
|
+
states=source_states,
|
|
2098
|
+
snapshots=source_snaps,
|
|
2099
|
+
panel_data=panel_data,
|
|
2100
|
+
))
|
|
1661
2101
|
except Exception:
|
|
1662
2102
|
digest_now = ""
|
|
1663
|
-
composed_sections.
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
2103
|
+
composed_sections.extend(
|
|
2104
|
+
ls.ComposedSection(
|
|
2105
|
+
snap=item,
|
|
2106
|
+
drift_detected=(digest_now != digest_at_add),
|
|
2107
|
+
)
|
|
2108
|
+
for item in source_snaps
|
|
2109
|
+
)
|
|
1667
2110
|
section_results.append({
|
|
1668
2111
|
"snapshot_id": f"{idx:02d}",
|
|
2112
|
+
"source": source,
|
|
1669
2113
|
"drift_detected": digest_now != digest_at_add,
|
|
1670
2114
|
"data_digest_at_add": digest_at_add,
|
|
1671
2115
|
"data_digest_now": digest_now,
|
|
@@ -1678,7 +2122,7 @@ def _handle_share_compose_post_impl(handler) -> None:
|
|
|
1678
2122
|
try:
|
|
1679
2123
|
body = ls.compose(tuple(composed_sections), opts=compose_opts)
|
|
1680
2124
|
except Exception as exc:
|
|
1681
|
-
handler
|
|
2125
|
+
_share_public_failure(handler, exc, phase="compose kernel")
|
|
1682
2126
|
return
|
|
1683
2127
|
|
|
1684
2128
|
content_type = {
|
|
@@ -1720,7 +2164,17 @@ def _handle_share_presets_get_impl(handler) -> None:
|
|
|
1720
2164
|
"""
|
|
1721
2165
|
cfg = sys.modules["cctally"].load_config()
|
|
1722
2166
|
presets = (cfg.get("share") or {}).get("presets") or {}
|
|
1723
|
-
|
|
2167
|
+
# Old records predate S4. Resolve them as Claude on read without mutating
|
|
2168
|
+
# config (a GET must remain read-only).
|
|
2169
|
+
resolved = {
|
|
2170
|
+
panel: {
|
|
2171
|
+
name: ({**record, "source": record.get("source", "claude")}
|
|
2172
|
+
if isinstance(record, dict) else record)
|
|
2173
|
+
for name, record in bucket.items()
|
|
2174
|
+
}
|
|
2175
|
+
for panel, bucket in presets.items() if isinstance(bucket, dict)
|
|
2176
|
+
}
|
|
2177
|
+
handler._respond_json(200, {"presets": resolved})
|
|
1724
2178
|
|
|
1725
2179
|
def _handle_share_presets_post_impl(handler) -> None:
|
|
1726
2180
|
"""Create or overwrite a preset (idempotent on `(panel, name)`).
|
|
@@ -1757,6 +2211,14 @@ def _handle_share_presets_post_impl(handler) -> None:
|
|
|
1757
2211
|
name = req.get("name")
|
|
1758
2212
|
template_id = req.get("template_id")
|
|
1759
2213
|
options = req.get("options")
|
|
2214
|
+
try:
|
|
2215
|
+
source, _ = _share_source_selection(req)
|
|
2216
|
+
except ValueError:
|
|
2217
|
+
handler._respond_json(400, {
|
|
2218
|
+
"code": "source_capability_unavailable",
|
|
2219
|
+
"error": "source capability unavailable",
|
|
2220
|
+
})
|
|
2221
|
+
return
|
|
1760
2222
|
if not isinstance(panel, str) or not panel:
|
|
1761
2223
|
handler._respond_json(400, {
|
|
1762
2224
|
"error": "missing or non-string panel",
|
|
@@ -1807,7 +2269,10 @@ def _handle_share_presets_post_impl(handler) -> None:
|
|
|
1807
2269
|
return
|
|
1808
2270
|
|
|
1809
2271
|
saved_at = _share_now_utc_iso()
|
|
1810
|
-
record = {
|
|
2272
|
+
record = {
|
|
2273
|
+
"template_id": template_id, "options": options,
|
|
2274
|
+
"source": source, "saved_at": saved_at,
|
|
2275
|
+
}
|
|
1811
2276
|
|
|
1812
2277
|
with sys.modules["cctally"].config_writer_lock():
|
|
1813
2278
|
cfg = _load_config_unlocked()
|
|
@@ -1878,7 +2343,11 @@ def _handle_share_history_get_impl(handler) -> None:
|
|
|
1878
2343
|
"""Return the recent-shares ring buffer (newest last, spec §11.4)."""
|
|
1879
2344
|
cfg = sys.modules["cctally"].load_config()
|
|
1880
2345
|
history = (cfg.get("share") or {}).get("history") or []
|
|
1881
|
-
handler._respond_json(200, {"history":
|
|
2346
|
+
handler._respond_json(200, {"history": [
|
|
2347
|
+
({**record, "source": record.get("source", "claude")}
|
|
2348
|
+
if isinstance(record, dict) else record)
|
|
2349
|
+
for record in history
|
|
2350
|
+
]})
|
|
1882
2351
|
|
|
1883
2352
|
def _handle_share_history_post_impl(handler) -> None:
|
|
1884
2353
|
"""Append a recipe to the ring buffer; FIFO trim to 20.
|
|
@@ -1914,6 +2383,14 @@ def _handle_share_history_post_impl(handler) -> None:
|
|
|
1914
2383
|
options = req.get("options") or {}
|
|
1915
2384
|
fmt = req.get("format")
|
|
1916
2385
|
destination = req.get("destination")
|
|
2386
|
+
try:
|
|
2387
|
+
source, _ = _share_source_selection(req)
|
|
2388
|
+
except ValueError:
|
|
2389
|
+
handler._respond_json(400, {
|
|
2390
|
+
"code": "source_capability_unavailable",
|
|
2391
|
+
"error": "source capability unavailable",
|
|
2392
|
+
})
|
|
2393
|
+
return
|
|
1917
2394
|
if not isinstance(panel, str) or not panel:
|
|
1918
2395
|
handler._respond_json(400, {
|
|
1919
2396
|
"error": "missing or non-string panel",
|
|
@@ -1978,6 +2455,7 @@ def _handle_share_history_post_impl(handler) -> None:
|
|
|
1978
2455
|
"panel": panel,
|
|
1979
2456
|
"template_id": template_id,
|
|
1980
2457
|
"options": options,
|
|
2458
|
+
"source": source,
|
|
1981
2459
|
"format": fmt,
|
|
1982
2460
|
"destination": destination,
|
|
1983
2461
|
"exported_at": _share_now_utc_iso(),
|