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
|
@@ -283,6 +283,7 @@ import urllib.parse
|
|
|
283
283
|
import urllib.request
|
|
284
284
|
import webbrowser as _wb
|
|
285
285
|
from dataclasses import dataclass, field, replace
|
|
286
|
+
from collections.abc import Mapping
|
|
286
287
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
287
288
|
from typing import Any, NamedTuple
|
|
288
289
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
@@ -460,6 +461,505 @@ from _cctally_dashboard_envelope import (
|
|
|
460
461
|
_model_breakdowns_to_models,
|
|
461
462
|
)
|
|
462
463
|
|
|
464
|
+
_ensure_sibling_loaded("_cctally_dashboard_sources")
|
|
465
|
+
from _cctally_dashboard_sources import (
|
|
466
|
+
SourceCapabilityUnavailable,
|
|
467
|
+
SourceResourceNotFound,
|
|
468
|
+
source_detail_lookup,
|
|
469
|
+
)
|
|
470
|
+
from _lib_dashboard_sources import dashboard_resource_key
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _source_safe_native_detail(row: Mapping, *, source: str,
|
|
474
|
+
resource: str, key: str) -> dict[str, Any]:
|
|
475
|
+
"""Return a provider-native detail envelope without raw source identity.
|
|
476
|
+
|
|
477
|
+
The published source adapter supplies resource-specific normalized facts;
|
|
478
|
+
details deliberately omit display-only labels and every raw identity field.
|
|
479
|
+
This keeps an opaque key opaque while preserving the provider's native
|
|
480
|
+
vocabulary (inclusive Codex tokens, qualified project totals, or quota
|
|
481
|
+
window state).
|
|
482
|
+
"""
|
|
483
|
+
common = {"detail_kind": f"{source}_{resource}", "key": key}
|
|
484
|
+
allowed = {
|
|
485
|
+
"session": (
|
|
486
|
+
"last_activity", "cost_usd", "input_tokens", "cached_input_tokens",
|
|
487
|
+
"output_tokens", "reasoning_output_tokens", "total_tokens", "models",
|
|
488
|
+
),
|
|
489
|
+
"project": (
|
|
490
|
+
"first_seen", "last_seen", "cost_usd", "input_tokens",
|
|
491
|
+
"cached_input_tokens", "output_tokens", "reasoning_output_tokens",
|
|
492
|
+
"total_tokens", "session_count", "sessions_count",
|
|
493
|
+
),
|
|
494
|
+
"block": (
|
|
495
|
+
"resets_at", "current_percent", "orphaned", "forecast",
|
|
496
|
+
"window_minutes", "observed_slot", "milestones",
|
|
497
|
+
),
|
|
498
|
+
}[resource]
|
|
499
|
+
common.update({name: row[name] for name in allowed if name in row})
|
|
500
|
+
return common
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _codex_model_breakdowns(rows) -> list[dict[str, Any]]:
|
|
504
|
+
"""Copy canonical Codex model totals into the safe dashboard vocabulary."""
|
|
505
|
+
result: list[dict[str, Any]] = []
|
|
506
|
+
for row in rows or ():
|
|
507
|
+
if not isinstance(row, Mapping):
|
|
508
|
+
continue
|
|
509
|
+
result.append({
|
|
510
|
+
name: row[name]
|
|
511
|
+
for name in (
|
|
512
|
+
"modelName", "inputTokens", "cachedInputTokens", "outputTokens",
|
|
513
|
+
"reasoningOutputTokens", "totalTokens", "cost", "isFallback",
|
|
514
|
+
)
|
|
515
|
+
if name in row
|
|
516
|
+
})
|
|
517
|
+
return result
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _codex_totals_wire(totals) -> dict[str, Any]:
|
|
521
|
+
return {
|
|
522
|
+
"cost_usd": float(getattr(totals, "cost_usd", 0.0)),
|
|
523
|
+
"input_tokens": int(getattr(totals, "input_tokens", 0)),
|
|
524
|
+
"cached_input_tokens": int(getattr(totals, "cached_input_tokens", 0)),
|
|
525
|
+
"output_tokens": int(getattr(totals, "output_tokens", 0)),
|
|
526
|
+
"reasoning_output_tokens": int(getattr(totals, "reasoning_output_tokens", 0)),
|
|
527
|
+
"total_tokens": int(getattr(totals, "total_tokens", 0)),
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _codex_detail_inputs(snapshot):
|
|
532
|
+
"""Open one bounded, sync-free Codex relational read for a detail route."""
|
|
533
|
+
from _cctally_cache import open_cache_db
|
|
534
|
+
from _cctally_dashboard_sources import (
|
|
535
|
+
DASHBOARD_QUOTA_OBSERVATION_LIMIT,
|
|
536
|
+
DASHBOARD_QUOTA_RECENT_DAYS,
|
|
537
|
+
DashboardReadContext,
|
|
538
|
+
_codex_entries_from_qualified,
|
|
539
|
+
resolve_dashboard_source_semantics,
|
|
540
|
+
)
|
|
541
|
+
from _cctally_quota import load_codex_quota_observations
|
|
542
|
+
from _cctally_source_analytics import load_qualified_codex_entries
|
|
543
|
+
|
|
544
|
+
now_utc = getattr(snapshot, "generated_at", None) or _command_as_of()
|
|
545
|
+
if now_utc.tzinfo is None or now_utc.utcoffset() is None:
|
|
546
|
+
now_utc = now_utc.replace(tzinfo=dt.timezone.utc)
|
|
547
|
+
now_utc = now_utc.astimezone(dt.timezone.utc)
|
|
548
|
+
range_start = now_utc - dt.timedelta(days=365)
|
|
549
|
+
config = sys.modules["cctally"].load_config()
|
|
550
|
+
codex_state = snapshot.source_bundle.sources["codex"]
|
|
551
|
+
data = codex_state.data if isinstance(codex_state.data, Mapping) else {}
|
|
552
|
+
periods = data.get("periods") if isinstance(data.get("periods"), Mapping) else {}
|
|
553
|
+
daily = periods.get("daily") if isinstance(periods.get("daily"), Mapping) else {}
|
|
554
|
+
display_tz_name = daily.get("display_tz")
|
|
555
|
+
if not isinstance(display_tz_name, str) or not display_tz_name:
|
|
556
|
+
display_tz_name = "UTC"
|
|
557
|
+
semantics = resolve_dashboard_source_semantics(
|
|
558
|
+
config, display_tz_name=display_tz_name,
|
|
559
|
+
)
|
|
560
|
+
cache_conn = open_cache_db()
|
|
561
|
+
stats_conn = open_db()
|
|
562
|
+
try:
|
|
563
|
+
context = DashboardReadContext(
|
|
564
|
+
cache_conn=cache_conn,
|
|
565
|
+
stats_conn=stats_conn,
|
|
566
|
+
range_start=range_start,
|
|
567
|
+
now_utc=now_utc,
|
|
568
|
+
display_tz_name=semantics.display_tz_name,
|
|
569
|
+
week_start_idx=semantics.week_start_idx,
|
|
570
|
+
week_start_name=semantics.week_start_name,
|
|
571
|
+
speed=semantics.speed,
|
|
572
|
+
codex_budget=semantics.codex_budget,
|
|
573
|
+
)
|
|
574
|
+
qualified = load_qualified_codex_entries(
|
|
575
|
+
range_start,
|
|
576
|
+
now_utc + dt.timedelta(microseconds=1),
|
|
577
|
+
speed=semantics.speed,
|
|
578
|
+
sync=False,
|
|
579
|
+
cache_conn=cache_conn,
|
|
580
|
+
)
|
|
581
|
+
entries = _codex_entries_from_qualified(qualified)
|
|
582
|
+
active_roots = tuple(sorted(
|
|
583
|
+
str(row[0]) for row in cache_conn.execute(
|
|
584
|
+
"SELECT source_root_key FROM codex_source_roots"
|
|
585
|
+
)
|
|
586
|
+
))
|
|
587
|
+
observations = load_codex_quota_observations(
|
|
588
|
+
source_root_keys=active_roots,
|
|
589
|
+
cache_conn=cache_conn,
|
|
590
|
+
captured_at_or_after=(
|
|
591
|
+
now_utc - dt.timedelta(days=DASHBOARD_QUOTA_RECENT_DAYS)
|
|
592
|
+
),
|
|
593
|
+
active_at=now_utc,
|
|
594
|
+
max_rows=DASHBOARD_QUOTA_OBSERVATION_LIMIT,
|
|
595
|
+
)
|
|
596
|
+
yield context, qualified, entries, observations
|
|
597
|
+
finally:
|
|
598
|
+
cache_conn.close()
|
|
599
|
+
stats_conn.close()
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _build_codex_session_detail(context, entries, *, key: str) -> dict[str, Any]:
|
|
603
|
+
view = sys.modules["cctally"].build_codex_session_view(
|
|
604
|
+
entries,
|
|
605
|
+
now_utc=context.now_utc,
|
|
606
|
+
tz_name=context.display_tz_name,
|
|
607
|
+
speed=context.speed,
|
|
608
|
+
)
|
|
609
|
+
for row in view.rows:
|
|
610
|
+
candidate = dashboard_resource_key(
|
|
611
|
+
"session", "codex", row.codex_root or "single-root", row.session_id_path,
|
|
612
|
+
)
|
|
613
|
+
if candidate != key:
|
|
614
|
+
continue
|
|
615
|
+
return {
|
|
616
|
+
"detail_kind": "codex_session",
|
|
617
|
+
"key": key,
|
|
618
|
+
"last_activity": row.last_activity.astimezone(dt.timezone.utc).isoformat(),
|
|
619
|
+
"cost_usd": row.cost_usd,
|
|
620
|
+
"input_tokens": row.input_tokens,
|
|
621
|
+
"cached_input_tokens": row.cached_input_tokens,
|
|
622
|
+
"output_tokens": row.output_tokens,
|
|
623
|
+
"reasoning_output_tokens": row.reasoning_output_tokens,
|
|
624
|
+
"total_tokens": row.total_tokens,
|
|
625
|
+
"models": list(row.models),
|
|
626
|
+
"model_breakdowns": _codex_model_breakdowns(row.model_breakdowns),
|
|
627
|
+
}
|
|
628
|
+
raise SourceResourceNotFound()
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _build_codex_project_detail(context, qualified, observations, *, key: str) -> dict[str, Any]:
|
|
632
|
+
from _lib_quota import build_blocks
|
|
633
|
+
from _lib_source_analytics import build_codex_project_result
|
|
634
|
+
|
|
635
|
+
result = build_codex_project_result(
|
|
636
|
+
qualified,
|
|
637
|
+
range_start=context.range_start,
|
|
638
|
+
range_end=context.now_utc + dt.timedelta(microseconds=1),
|
|
639
|
+
blocks=build_blocks(observations),
|
|
640
|
+
as_of=context.now_utc,
|
|
641
|
+
allocation_entries=qualified,
|
|
642
|
+
include_breakdown=True,
|
|
643
|
+
)
|
|
644
|
+
data = result.data
|
|
645
|
+
if data is None:
|
|
646
|
+
raise SourceResourceNotFound()
|
|
647
|
+
for row in data.projects:
|
|
648
|
+
if dashboard_resource_key("project", "codex", row.project_key) != key:
|
|
649
|
+
continue
|
|
650
|
+
selected = tuple(entry for entry in qualified if entry.project_key == row.project_key)
|
|
651
|
+
by_session: dict[str, list[object]] = {}
|
|
652
|
+
for entry in selected:
|
|
653
|
+
by_session.setdefault(entry.conversation_key, []).append(entry)
|
|
654
|
+
sessions = []
|
|
655
|
+
for ordinal, values in enumerate(
|
|
656
|
+
sorted(by_session.values(), key=lambda group: max(item.timestamp for item in group), reverse=True),
|
|
657
|
+
start=1,
|
|
658
|
+
):
|
|
659
|
+
totals = sys.modules["_lib_source_analytics"]._totals(values)
|
|
660
|
+
sessions.append({
|
|
661
|
+
"label": f"Session {ordinal}",
|
|
662
|
+
"last_activity": max(item.timestamp for item in values).astimezone(dt.timezone.utc).isoformat(),
|
|
663
|
+
**_codex_totals_wire(totals),
|
|
664
|
+
})
|
|
665
|
+
return {
|
|
666
|
+
"detail_kind": "codex_project",
|
|
667
|
+
"key": key,
|
|
668
|
+
"range_start": data.range_start.astimezone(dt.timezone.utc).isoformat(),
|
|
669
|
+
"range_end": data.range_end.astimezone(dt.timezone.utc).isoformat(),
|
|
670
|
+
"first_seen": row.first_seen.astimezone(dt.timezone.utc).isoformat(),
|
|
671
|
+
"last_seen": row.last_seen.astimezone(dt.timezone.utc).isoformat(),
|
|
672
|
+
"session_count": row.session_count,
|
|
673
|
+
**_codex_totals_wire(row.totals),
|
|
674
|
+
"models": [
|
|
675
|
+
{"model": model, **_codex_totals_wire(totals)}
|
|
676
|
+
for model, totals in row.models
|
|
677
|
+
],
|
|
678
|
+
"sessions": sessions,
|
|
679
|
+
}
|
|
680
|
+
raise SourceResourceNotFound()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _build_codex_block_detail(context, observations, *, key: str) -> dict[str, Any]:
|
|
684
|
+
from _lib_quota import build_blocks, forecast_quota, percent_milestones, quota_freshness
|
|
685
|
+
|
|
686
|
+
rows = context.stats_conn.execute(
|
|
687
|
+
"SELECT source_root_key, logical_limit_key, observed_slot, window_minutes, "
|
|
688
|
+
"limit_name, resets_at_utc, current_percent, orphaned_at "
|
|
689
|
+
"FROM quota_window_blocks WHERE source='codex' "
|
|
690
|
+
"ORDER BY resets_at_utc DESC, source_root_key, logical_limit_key, observed_slot LIMIT 250"
|
|
691
|
+
).fetchall()
|
|
692
|
+
matched = None
|
|
693
|
+
for row in rows:
|
|
694
|
+
candidate = dashboard_resource_key(
|
|
695
|
+
"block", "codex", row[0], row[1], row[2], row[3], row[5],
|
|
696
|
+
)
|
|
697
|
+
if candidate == key:
|
|
698
|
+
matched = row
|
|
699
|
+
break
|
|
700
|
+
if matched is None:
|
|
701
|
+
raise SourceResourceNotFound()
|
|
702
|
+
reset_at = parse_iso_datetime(str(matched[5]), "codex.block.resets_at")
|
|
703
|
+
matching_observations = tuple(
|
|
704
|
+
observation for observation in observations
|
|
705
|
+
if observation.identity.source_root_key == matched[0]
|
|
706
|
+
and observation.identity.logical_limit_key == matched[1]
|
|
707
|
+
and observation.identity.observed_slot == matched[2]
|
|
708
|
+
and observation.identity.window_minutes == matched[3]
|
|
709
|
+
and observation.resets_at == reset_at
|
|
710
|
+
)
|
|
711
|
+
block = next(
|
|
712
|
+
(item for item in build_blocks(matching_observations) if item.resets_at == reset_at),
|
|
713
|
+
None,
|
|
714
|
+
)
|
|
715
|
+
if block is None:
|
|
716
|
+
raise SourceResourceNotFound()
|
|
717
|
+
forecast = forecast_quota(block.observations, context.now_utc)
|
|
718
|
+
freshness = quota_freshness(block.observations, context.now_utc)
|
|
719
|
+
return {
|
|
720
|
+
"detail_kind": "codex_block",
|
|
721
|
+
"key": key,
|
|
722
|
+
"label": matched[4] or "Codex quota",
|
|
723
|
+
"observed_slot": matched[2],
|
|
724
|
+
"window_minutes": matched[3],
|
|
725
|
+
"resets_at": reset_at.astimezone(dt.timezone.utc).isoformat(),
|
|
726
|
+
"current_percent": matched[6],
|
|
727
|
+
"orphaned": matched[7] is not None,
|
|
728
|
+
"freshness": freshness.state,
|
|
729
|
+
"observations": [
|
|
730
|
+
{
|
|
731
|
+
"captured_at": item.captured_at.astimezone(dt.timezone.utc).isoformat(),
|
|
732
|
+
"used_percent": item.used_percent,
|
|
733
|
+
"resets_at": item.resets_at.astimezone(dt.timezone.utc).isoformat(),
|
|
734
|
+
}
|
|
735
|
+
for item in block.observations[-250:]
|
|
736
|
+
],
|
|
737
|
+
"milestones": [
|
|
738
|
+
{
|
|
739
|
+
"percent": milestone.percent,
|
|
740
|
+
"captured_at": milestone.captured_at.astimezone(dt.timezone.utc).isoformat(),
|
|
741
|
+
}
|
|
742
|
+
for milestone in percent_milestones(block)
|
|
743
|
+
],
|
|
744
|
+
"forecast": {
|
|
745
|
+
"status": forecast.status,
|
|
746
|
+
"current_percent": forecast.current_percent,
|
|
747
|
+
"projected_percent": forecast.projected_percent,
|
|
748
|
+
"resets_at": (
|
|
749
|
+
forecast.resets_at.astimezone(dt.timezone.utc).isoformat()
|
|
750
|
+
if forecast.resets_at else None
|
|
751
|
+
),
|
|
752
|
+
},
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _build_codex_source_detail(snapshot, *, resource: str, key: str) -> dict[str, Any]:
|
|
757
|
+
for context, qualified, entries, observations in _codex_detail_inputs(snapshot):
|
|
758
|
+
if resource == "session":
|
|
759
|
+
return _build_codex_session_detail(context, entries, key=key)
|
|
760
|
+
if resource == "project":
|
|
761
|
+
return _build_codex_project_detail(context, qualified, observations, key=key)
|
|
762
|
+
return _build_codex_block_detail(context, observations, key=key)
|
|
763
|
+
raise SourceResourceNotFound()
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _source_safe_claude_session_detail(detail, *, key: str) -> dict[str, Any]:
|
|
767
|
+
"""Adapt the existing Claude session detail builder to S4's safe route."""
|
|
768
|
+
payload = _session_detail_to_envelope(detail)
|
|
769
|
+
return {
|
|
770
|
+
"detail_kind": "claude_session",
|
|
771
|
+
"key": key,
|
|
772
|
+
"started_utc": payload["started_utc"],
|
|
773
|
+
"last_activity_utc": payload["last_activity_utc"],
|
|
774
|
+
"duration_min": payload["duration_min"],
|
|
775
|
+
"models": payload["models"],
|
|
776
|
+
"input_tokens": payload["input_tokens"],
|
|
777
|
+
"cache_creation_tokens": payload["cache_creation_tokens"],
|
|
778
|
+
"cache_read_tokens": payload["cache_read_tokens"],
|
|
779
|
+
"output_tokens": payload["output_tokens"],
|
|
780
|
+
"cache_hit_pct": payload["cache_hit_pct"],
|
|
781
|
+
"cost_per_model": payload["cost_per_model"],
|
|
782
|
+
"cost_total_usd": payload["cost_total_usd"],
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _source_safe_claude_project_detail(detail: Mapping, *, key: str) -> dict[str, Any]:
|
|
787
|
+
"""Drop legacy raw bucket/session identifiers from a Claude project drill."""
|
|
788
|
+
return {
|
|
789
|
+
"detail_kind": "claude_project",
|
|
790
|
+
"key": key,
|
|
791
|
+
"window_weeks": detail.get("window_weeks"),
|
|
792
|
+
"window_cost_usd": detail.get("window_cost_usd"),
|
|
793
|
+
"window_attributed_pct": detail.get("window_attributed_pct"),
|
|
794
|
+
"models": detail.get("models", []),
|
|
795
|
+
"sessions": [
|
|
796
|
+
{
|
|
797
|
+
"started_at": item.get("started_at"),
|
|
798
|
+
"last_activity_at": item.get("last_activity_at"),
|
|
799
|
+
"primary_model": item.get("primary_model"),
|
|
800
|
+
"cost_usd": item.get("cost_usd"),
|
|
801
|
+
}
|
|
802
|
+
for item in detail.get("sessions", [])
|
|
803
|
+
if isinstance(item, Mapping)
|
|
804
|
+
],
|
|
805
|
+
"models_total": detail.get("models_total", 0),
|
|
806
|
+
"sessions_total": detail.get("sessions_total", 0),
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _source_safe_claude_block_detail(detail: Mapping, *, key: str) -> dict[str, Any]:
|
|
811
|
+
"""Reuse the shipped block builder while retaining source-safe fields."""
|
|
812
|
+
allowed = (
|
|
813
|
+
"start_at", "end_at", "actual_end_at", "anchor", "is_active",
|
|
814
|
+
"entries_count", "cost_usd", "total_tokens", "input_tokens",
|
|
815
|
+
"output_tokens", "cache_creation_tokens", "cache_read_tokens",
|
|
816
|
+
"cache_hit_pct", "models", "burn_rate", "projection", "samples",
|
|
817
|
+
)
|
|
818
|
+
return {
|
|
819
|
+
"detail_kind": "claude_block",
|
|
820
|
+
"key": key,
|
|
821
|
+
**{name: detail[name] for name in allowed if name in detail},
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _claude_session_id_for_source_key(snapshot, key: str) -> str | None:
|
|
826
|
+
for row in getattr(snapshot, "sessions", ()) or ():
|
|
827
|
+
session_id = getattr(row, "session_id", None)
|
|
828
|
+
if isinstance(session_id, str) and session_id and (
|
|
829
|
+
dashboard_resource_key("session", "claude", session_id) == key
|
|
830
|
+
):
|
|
831
|
+
return session_id
|
|
832
|
+
return None
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _claude_project_key_for_source_key(snapshot, key: str) -> str | None:
|
|
836
|
+
env = getattr(snapshot, "projects_envelope", None)
|
|
837
|
+
if not isinstance(env, Mapping):
|
|
838
|
+
return None
|
|
839
|
+
candidates: list[object] = []
|
|
840
|
+
current = env.get("current_week")
|
|
841
|
+
if isinstance(current, Mapping):
|
|
842
|
+
candidates.extend(current.get("rows") or ())
|
|
843
|
+
trend = env.get("trend")
|
|
844
|
+
if isinstance(trend, Mapping):
|
|
845
|
+
candidates.extend(trend.get("projects") or ())
|
|
846
|
+
for row in candidates:
|
|
847
|
+
if not isinstance(row, Mapping):
|
|
848
|
+
continue
|
|
849
|
+
project_key = row.get("key")
|
|
850
|
+
if isinstance(project_key, str) and project_key and (
|
|
851
|
+
dashboard_resource_key("project", "claude", project_key) == key
|
|
852
|
+
):
|
|
853
|
+
return project_key
|
|
854
|
+
return None
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _claude_block_start_for_source_key(snapshot, key: str) -> dt.datetime | None:
|
|
858
|
+
for row in getattr(snapshot, "blocks_panel", ()) or ():
|
|
859
|
+
raw_start = getattr(row, "start_at", None)
|
|
860
|
+
if not isinstance(raw_start, str):
|
|
861
|
+
continue
|
|
862
|
+
try:
|
|
863
|
+
start_at = parse_iso_datetime(raw_start, "source.block.start_at")
|
|
864
|
+
except ValueError:
|
|
865
|
+
continue
|
|
866
|
+
identities = (raw_start, start_at.astimezone(dt.timezone.utc).isoformat(), start_at)
|
|
867
|
+
if any(dashboard_resource_key("block", "claude", value) == key for value in identities):
|
|
868
|
+
return start_at.astimezone(dt.timezone.utc)
|
|
869
|
+
return None
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[str, Any]:
|
|
873
|
+
"""Map a bounded legacy row to its existing, cache-only detail builder."""
|
|
874
|
+
now_utc = getattr(snapshot, "generated_at", None) or _command_as_of()
|
|
875
|
+
if resource == "session":
|
|
876
|
+
session_id = _claude_session_id_for_source_key(snapshot, key)
|
|
877
|
+
if session_id is None:
|
|
878
|
+
raise SourceResourceNotFound()
|
|
879
|
+
detail = sys.modules["cctally"]._tui_build_session_detail(
|
|
880
|
+
session_id, now_utc=now_utc,
|
|
881
|
+
)
|
|
882
|
+
if detail is None:
|
|
883
|
+
raise SourceResourceNotFound()
|
|
884
|
+
return _source_safe_claude_session_detail(detail, key=key)
|
|
885
|
+
|
|
886
|
+
if resource == "project":
|
|
887
|
+
project_key = _claude_project_key_for_source_key(snapshot, key)
|
|
888
|
+
if project_key is None:
|
|
889
|
+
raise SourceResourceNotFound()
|
|
890
|
+
conn = open_db()
|
|
891
|
+
try:
|
|
892
|
+
conn.execute("ATTACH DATABASE ? AS cache_db", (str(_cctally_core.CACHE_DB_PATH),))
|
|
893
|
+
conn.execute("CREATE TEMP VIEW session_entries AS SELECT * FROM cache_db.session_entries")
|
|
894
|
+
conn.execute("CREATE TEMP VIEW session_files AS SELECT * FROM cache_db.session_files")
|
|
895
|
+
detail = _project_detail_for_window(
|
|
896
|
+
conn,
|
|
897
|
+
project_key=project_key,
|
|
898
|
+
weeks_back=1,
|
|
899
|
+
now_utc=now_utc,
|
|
900
|
+
current_week=getattr(snapshot, "current_week", None),
|
|
901
|
+
projects_envelope=getattr(snapshot, "projects_envelope", None),
|
|
902
|
+
)
|
|
903
|
+
finally:
|
|
904
|
+
try:
|
|
905
|
+
conn.execute("DROP VIEW IF EXISTS session_entries")
|
|
906
|
+
conn.execute("DROP VIEW IF EXISTS session_files")
|
|
907
|
+
conn.execute("DETACH DATABASE cache_db")
|
|
908
|
+
except sqlite3.Error:
|
|
909
|
+
pass
|
|
910
|
+
conn.close()
|
|
911
|
+
if detail is None:
|
|
912
|
+
raise SourceResourceNotFound()
|
|
913
|
+
return _source_safe_claude_project_detail(detail, key=key)
|
|
914
|
+
|
|
915
|
+
start_at = _claude_block_start_for_source_key(snapshot, key)
|
|
916
|
+
if start_at is None:
|
|
917
|
+
raise SourceResourceNotFound()
|
|
918
|
+
end_at = start_at + BLOCK_DURATION
|
|
919
|
+
cache_conn = open_cache_db()
|
|
920
|
+
try:
|
|
921
|
+
entries = list(iter_entries(cache_conn, start_at, end_at))
|
|
922
|
+
finally:
|
|
923
|
+
cache_conn.close()
|
|
924
|
+
recorded_windows, block_start_overrides, canonical_intervals = (
|
|
925
|
+
_load_recorded_five_hour_windows(start_at - BLOCK_DURATION, end_at + BLOCK_DURATION)
|
|
926
|
+
)
|
|
927
|
+
blocks = _group_entries_into_blocks(
|
|
928
|
+
entries,
|
|
929
|
+
mode="auto",
|
|
930
|
+
recorded_windows=recorded_windows,
|
|
931
|
+
block_start_overrides=block_start_overrides,
|
|
932
|
+
canonical_intervals=canonical_intervals,
|
|
933
|
+
now=now_utc,
|
|
934
|
+
)
|
|
935
|
+
target = next(
|
|
936
|
+
(block for block in blocks if not block.is_gap and block.start_time == start_at),
|
|
937
|
+
None,
|
|
938
|
+
)
|
|
939
|
+
if target is None:
|
|
940
|
+
raise SourceResourceNotFound()
|
|
941
|
+
block_entries = [
|
|
942
|
+
entry for entry in entries if target.start_time <= entry.timestamp < target.end_time
|
|
943
|
+
]
|
|
944
|
+
return _source_safe_claude_block_detail(
|
|
945
|
+
_build_block_detail(target, block_entries), key=key,
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def build_source_detail(*, snapshot, source: str, resource: str,
|
|
950
|
+
key: str) -> dict[str, Any]:
|
|
951
|
+
"""Resolve one qualified opaque key to a provider-native read-only detail.
|
|
952
|
+
|
|
953
|
+
The frozen published bundle is the first bounded key index. Claude then
|
|
954
|
+
invokes the existing detail builders with cache-only reads; Codex returns
|
|
955
|
+
the native S1-S3 adapter detail without a Claude fallback or a rollout
|
|
956
|
+
parse. Every branch preserves the generic handler errors above it.
|
|
957
|
+
"""
|
|
958
|
+
row = source_detail_lookup(snapshot.source_bundle, source, resource, key)
|
|
959
|
+
if source == "claude":
|
|
960
|
+
return _build_claude_source_detail(snapshot, resource=resource, key=key)
|
|
961
|
+
return _build_codex_source_detail(snapshot, resource=resource, key=key)
|
|
962
|
+
|
|
463
963
|
_ensure_sibling_loaded("_cctally_dashboard_share")
|
|
464
964
|
from _cctally_dashboard_share import (
|
|
465
965
|
# accessor shims (still forward late-binding to sys.modules["cctally"])
|
|
@@ -3492,6 +3992,100 @@ def _debug_cache_table_counts(cache_conn) -> dict:
|
|
|
3492
3992
|
return counts
|
|
3493
3993
|
|
|
3494
3994
|
|
|
3995
|
+
_DEBUG_SOURCE_CACHE_TABLES = {
|
|
3996
|
+
"claude": (("session_entries", None), ("session_files", None)),
|
|
3997
|
+
"codex": (
|
|
3998
|
+
("codex_session_entries", None),
|
|
3999
|
+
("codex_session_files", None),
|
|
4000
|
+
("codex_source_roots", None),
|
|
4001
|
+
("quota_window_snapshots", "source='codex'"),
|
|
4002
|
+
),
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
_DEBUG_SOURCE_STATS_TABLES = {
|
|
4006
|
+
"codex": (
|
|
4007
|
+
("quota_window_blocks", "source='codex'"),
|
|
4008
|
+
("quota_percent_milestones", "source='codex'"),
|
|
4009
|
+
("quota_threshold_events", "source='codex'"),
|
|
4010
|
+
("budget_milestones", "vendor='codex'"),
|
|
4011
|
+
("projected_milestones", "metric='codex_budget_usd'"),
|
|
4012
|
+
),
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
|
|
4016
|
+
def _debug_source_state_wire(bundle, source: str) -> dict:
|
|
4017
|
+
"""Return only public availability/version state from a published bundle."""
|
|
4018
|
+
try:
|
|
4019
|
+
state = bundle.sources[source]
|
|
4020
|
+
availability = getattr(state, "availability", "unavailable")
|
|
4021
|
+
freshness = getattr(state, "freshness", "stale")
|
|
4022
|
+
version = getattr(state, "data_version", None)
|
|
4023
|
+
data = getattr(state, "data", None)
|
|
4024
|
+
except (AttributeError, KeyError, TypeError):
|
|
4025
|
+
availability, freshness, version, data = "unavailable", "stale", None, None
|
|
4026
|
+
def resource_count(domain: str) -> int:
|
|
4027
|
+
try:
|
|
4028
|
+
value = data[domain]["rows"]
|
|
4029
|
+
return len(value) if isinstance(value, (list, tuple)) else 0
|
|
4030
|
+
except (KeyError, TypeError):
|
|
4031
|
+
return 0
|
|
4032
|
+
return {
|
|
4033
|
+
"availability": availability if availability in ("ok", "empty", "partial") else "unavailable",
|
|
4034
|
+
"freshness": freshness if freshness in ("fresh", "stale") else "stale",
|
|
4035
|
+
"data_version": version if isinstance(version, str) else None,
|
|
4036
|
+
"tables": {},
|
|
4037
|
+
"resources": {
|
|
4038
|
+
"projects": resource_count("projects"),
|
|
4039
|
+
"alerts": resource_count("alerts"),
|
|
4040
|
+
},
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
|
|
4044
|
+
def _debug_source_counts(cache_conn, bundle) -> dict:
|
|
4045
|
+
"""Bounded, source-owned counts and opaque state for the debug endpoint.
|
|
4046
|
+
|
|
4047
|
+
Every table and predicate is fixed here. This deliberately reports no
|
|
4048
|
+
values from rows: roots, paths, logical limits, conversation IDs, and
|
|
4049
|
+
project labels never cross the diagnostic boundary.
|
|
4050
|
+
"""
|
|
4051
|
+
result = {
|
|
4052
|
+
source: _debug_source_state_wire(bundle, source)
|
|
4053
|
+
for source in ("claude", "codex", "all")
|
|
4054
|
+
}
|
|
4055
|
+
if cache_conn is None:
|
|
4056
|
+
return result
|
|
4057
|
+
for source, tables in _DEBUG_SOURCE_CACHE_TABLES.items():
|
|
4058
|
+
for table, where in tables:
|
|
4059
|
+
try:
|
|
4060
|
+
predicate = f" WHERE {where}" if where else ""
|
|
4061
|
+
row = cache_conn.execute(
|
|
4062
|
+
f"SELECT COUNT(*) FROM {table}{predicate}" # noqa: S608 -- fixed allowlist
|
|
4063
|
+
).fetchone()
|
|
4064
|
+
result[source]["tables"][table] = int(row[0])
|
|
4065
|
+
except sqlite3.Error:
|
|
4066
|
+
pass
|
|
4067
|
+
stats_conn = None
|
|
4068
|
+
try:
|
|
4069
|
+
stats_conn = sqlite3.connect(
|
|
4070
|
+
f"{_cctally_core.DB_PATH.as_uri()}?mode=ro", uri=True
|
|
4071
|
+
)
|
|
4072
|
+
for source, tables in _DEBUG_SOURCE_STATS_TABLES.items():
|
|
4073
|
+
for table, where in tables:
|
|
4074
|
+
try:
|
|
4075
|
+
row = stats_conn.execute(
|
|
4076
|
+
f"SELECT COUNT(*) FROM {table} WHERE {where}" # noqa: S608 -- fixed allowlist
|
|
4077
|
+
).fetchone()
|
|
4078
|
+
result[source]["tables"][table] = int(row[0])
|
|
4079
|
+
except sqlite3.Error:
|
|
4080
|
+
pass
|
|
4081
|
+
except sqlite3.Error:
|
|
4082
|
+
pass
|
|
4083
|
+
finally:
|
|
4084
|
+
if stats_conn is not None:
|
|
4085
|
+
stats_conn.close()
|
|
4086
|
+
return result
|
|
4087
|
+
|
|
4088
|
+
|
|
3495
4089
|
def _debug_cache_state(cache_conn) -> dict:
|
|
3496
4090
|
"""On-demand signature legs + pending-reingest flags + generation.
|
|
3497
4091
|
|
|
@@ -3528,8 +4122,8 @@ def _debug_cache_state(cache_conn) -> dict:
|
|
|
3528
4122
|
"max_entry_id": sc._max_id(cache_conn, "session_entries"),
|
|
3529
4123
|
"entry_mutation_seq": sc._entry_mutation_seq(cache_conn),
|
|
3530
4124
|
}
|
|
3531
|
-
except sqlite3.Error
|
|
3532
|
-
state["signature"] = {"
|
|
4125
|
+
except sqlite3.Error:
|
|
4126
|
+
state["signature"] = {"status": "unavailable"}
|
|
3533
4127
|
finally:
|
|
3534
4128
|
if stats_conn is not None:
|
|
3535
4129
|
stats_conn.close()
|
|
@@ -3587,6 +4181,7 @@ def _debug_tool_version() -> str:
|
|
|
3587
4181
|
_GET_ROUTES = (
|
|
3588
4182
|
("exact", "/api/data", "_serve_api_data", None, False),
|
|
3589
4183
|
("exact", "/api/events", "_serve_api_events", None, False),
|
|
4184
|
+
("prefix", "/api/source/", "_handle_get_source_detail", None, True),
|
|
3590
4185
|
("prefix", "/api/session/", "_handle_get_session_detail", None, True),
|
|
3591
4186
|
("prefix", "/api/project/", "_handle_get_project_detail", None, False),
|
|
3592
4187
|
("prefix", "/api/block/", "_handle_get_block_detail", None, True),
|
|
@@ -4052,15 +4647,21 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4052
4647
|
last = self._perf_gate().last_backend_perf()
|
|
4053
4648
|
dataset: dict = {}
|
|
4054
4649
|
cache_state: dict = {}
|
|
4650
|
+
try:
|
|
4651
|
+
source_bundle = self.snapshot_ref.get().source_bundle
|
|
4652
|
+
except Exception: # noqa: BLE001 -- diagnostics fail closed.
|
|
4653
|
+
source_bundle = None
|
|
4654
|
+
sources = _debug_source_counts(None, source_bundle)
|
|
4055
4655
|
try:
|
|
4056
4656
|
conn = open_cache_db()
|
|
4057
4657
|
try:
|
|
4058
4658
|
dataset = _debug_cache_table_counts(conn)
|
|
4059
4659
|
cache_state = _debug_cache_state(conn)
|
|
4660
|
+
sources = _debug_source_counts(conn, source_bundle)
|
|
4060
4661
|
finally:
|
|
4061
4662
|
conn.close()
|
|
4062
|
-
except Exception
|
|
4063
|
-
cache_state = {"
|
|
4663
|
+
except Exception: # noqa: BLE001 -- a diagnostic must not expose raw errors.
|
|
4664
|
+
cache_state = {"status": "unavailable"}
|
|
4064
4665
|
body = {
|
|
4065
4666
|
"schemaVersion": 1,
|
|
4066
4667
|
"version": _debug_tool_version(),
|
|
@@ -4068,6 +4669,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4068
4669
|
"dataset": dataset,
|
|
4069
4670
|
"phases": (last or {}).get("phases"),
|
|
4070
4671
|
"cache_state": cache_state,
|
|
4672
|
+
"sources": sources,
|
|
4071
4673
|
}
|
|
4072
4674
|
if body["phases"] is None:
|
|
4073
4675
|
body["note"] = "tracing_disabled"
|
|
@@ -5124,6 +5726,78 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5124
5726
|
self.end_headers()
|
|
5125
5727
|
self.wfile.write(body)
|
|
5126
5728
|
|
|
5729
|
+
def _handle_get_source_detail(self, path: str) -> None:
|
|
5730
|
+
"""Serve one source-qualified provider detail with read-only access."""
|
|
5731
|
+
import re
|
|
5732
|
+
import urllib.parse as _urlparse
|
|
5733
|
+
|
|
5734
|
+
raw = path[len("/api/source/"):]
|
|
5735
|
+
parts = raw.split("/", 2)
|
|
5736
|
+
if len(parts) != 3:
|
|
5737
|
+
self._respond_json(400, {
|
|
5738
|
+
"code": "source_capability_unavailable",
|
|
5739
|
+
"error": "source capability unavailable",
|
|
5740
|
+
})
|
|
5741
|
+
return
|
|
5742
|
+
source, resource, raw_key = parts
|
|
5743
|
+
if (
|
|
5744
|
+
source not in ("claude", "codex")
|
|
5745
|
+
or resource not in ("session", "project", "block")
|
|
5746
|
+
or not raw_key
|
|
5747
|
+
or re.search(r"%(?![0-9A-Fa-f]{2})", raw_key)
|
|
5748
|
+
):
|
|
5749
|
+
self._respond_json(400, {
|
|
5750
|
+
"code": "source_capability_unavailable",
|
|
5751
|
+
"error": "source capability unavailable",
|
|
5752
|
+
})
|
|
5753
|
+
return
|
|
5754
|
+
try:
|
|
5755
|
+
key = _urlparse.unquote_to_bytes(raw_key).decode("utf-8", "strict")
|
|
5756
|
+
except UnicodeDecodeError:
|
|
5757
|
+
self._respond_json(400, {
|
|
5758
|
+
"code": "source_capability_unavailable",
|
|
5759
|
+
"error": "source capability unavailable",
|
|
5760
|
+
})
|
|
5761
|
+
return
|
|
5762
|
+
if not key or "/" in key:
|
|
5763
|
+
self._respond_json(400, {
|
|
5764
|
+
"code": "source_capability_unavailable",
|
|
5765
|
+
"error": "source capability unavailable",
|
|
5766
|
+
})
|
|
5767
|
+
return
|
|
5768
|
+
try:
|
|
5769
|
+
snap = self.snapshot_ref.get()
|
|
5770
|
+
detail = build_source_detail(
|
|
5771
|
+
snapshot=snap,
|
|
5772
|
+
source=source,
|
|
5773
|
+
resource=resource,
|
|
5774
|
+
key=key,
|
|
5775
|
+
)
|
|
5776
|
+
except SourceResourceNotFound:
|
|
5777
|
+
self._respond_json(404, {
|
|
5778
|
+
"code": "source_resource_not_found",
|
|
5779
|
+
"error": "source resource not found",
|
|
5780
|
+
})
|
|
5781
|
+
return
|
|
5782
|
+
except SourceCapabilityUnavailable:
|
|
5783
|
+
self._respond_json(400, {
|
|
5784
|
+
"code": "source_capability_unavailable",
|
|
5785
|
+
"error": "source capability unavailable",
|
|
5786
|
+
})
|
|
5787
|
+
return
|
|
5788
|
+
except Exception as exc: # noqa: BLE001 — detailed diagnostics stay server-only.
|
|
5789
|
+
self.log_error("/api/source/%s/%s failed: %r", source, resource, exc)
|
|
5790
|
+
self._respond_json(400, {
|
|
5791
|
+
"code": "source_capability_unavailable",
|
|
5792
|
+
"error": "source capability unavailable",
|
|
5793
|
+
})
|
|
5794
|
+
return
|
|
5795
|
+
self._respond_json(200, {
|
|
5796
|
+
"source": source,
|
|
5797
|
+
"resource": resource,
|
|
5798
|
+
"data": detail,
|
|
5799
|
+
})
|
|
5800
|
+
|
|
5127
5801
|
|
|
5128
5802
|
# ---- conversation viewer (spec §6) — thin delegators to the F4 sibling ----
|
|
5129
5803
|
# Bodies live in bin/_cctally_dashboard_conversation.py as *_impl(handler, …)
|
|
@@ -5770,6 +6444,7 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
|
|
|
5770
6444
|
doctor_payload=doctor_payload,
|
|
5771
6445
|
envelope_precompute=envelope_precompute,
|
|
5772
6446
|
hydrating=True,
|
|
6447
|
+
source_bundle=tui._tui_hydrating_source_bundle(),
|
|
5773
6448
|
)
|
|
5774
6449
|
|
|
5775
6450
|
|
|
@@ -6062,4 +6737,3 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
6062
6737
|
http_thread.join(timeout=2)
|
|
6063
6738
|
print("dashboard: stopped", flush=True)
|
|
6064
6739
|
return 0
|
|
6065
|
-
|