cctally 1.9.0 → 1.10.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 +24 -0
- package/bin/_cctally_dashboard.py +1095 -0
- package/bin/_cctally_tui.py +228 -10
- package/bin/_lib_share_templates.py +190 -0
- package/bin/_lib_view_models.py +20 -0
- package/bin/cctally +9 -0
- package/dashboard/static/assets/index-DUKjFlG8.js +18 -0
- package/dashboard/static/assets/index-Dp14ELVt.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-Dv5Dzag5.css +0 -1
- package/dashboard/static/assets/index-cWE5HB8O.js +0 -18
|
@@ -1026,6 +1026,7 @@ def _build_share_panel_data(panel: str, options: dict,
|
|
|
1026
1026
|
if panel == "blocks": return _build_blocks_share_panel_data(options, snap)
|
|
1027
1027
|
if panel == "sessions": return _build_sessions_share_panel_data(options, snap)
|
|
1028
1028
|
if panel == "current-week": return _build_current_week_share_panel_data(options, snap)
|
|
1029
|
+
if panel == "projects": return _build_projects_share_panel_data(options, snap)
|
|
1029
1030
|
raise ValueError(f"unknown share panel: {panel!r}")
|
|
1030
1031
|
|
|
1031
1032
|
|
|
@@ -1526,6 +1527,151 @@ def _build_sessions_share_panel_data(options: dict,
|
|
|
1526
1527
|
return {"sessions": sessions}
|
|
1527
1528
|
|
|
1528
1529
|
|
|
1530
|
+
def _build_projects_share_panel_data(options: dict,
|
|
1531
|
+
snap: "DataSnapshot | None") -> dict:
|
|
1532
|
+
"""Projects panel_data — per-project rollup over a selectable window.
|
|
1533
|
+
|
|
1534
|
+
Reuses ``DataSnapshot.projects_envelope`` already populated by the
|
|
1535
|
+
sync thread, so the share artifact matches what the dashboard panel
|
|
1536
|
+
is showing. ``options.windowWeeks`` (spec §5.4 + §7.3) selects the
|
|
1537
|
+
aggregation window:
|
|
1538
|
+
|
|
1539
|
+
- ``windowWeeks=1`` (default): current_week only (PANEL share flow).
|
|
1540
|
+
- ``windowWeeks ∈ {4, 8, 12}``: sum across the trend window
|
|
1541
|
+
(MODAL share flow — supplies its active window pill).
|
|
1542
|
+
|
|
1543
|
+
Output shape (consumed by `_build_projects_recap` / `_visual` /
|
|
1544
|
+
`_detail` builders below — see bin/_lib_share_templates.py):
|
|
1545
|
+
|
|
1546
|
+
{
|
|
1547
|
+
"rows": [
|
|
1548
|
+
{
|
|
1549
|
+
"key": "<disambiguated display_key>",
|
|
1550
|
+
"bucket_path": "<absolute path>",
|
|
1551
|
+
"cost_usd": <float>,
|
|
1552
|
+
"attributed_pct": <float | None>,
|
|
1553
|
+
"sessions_count": <int>,
|
|
1554
|
+
},
|
|
1555
|
+
... # desc by cost
|
|
1556
|
+
],
|
|
1557
|
+
"total_cost_usd": <float>,
|
|
1558
|
+
"period_start": <dt.datetime UTC>,
|
|
1559
|
+
"period_end": <dt.datetime UTC>,
|
|
1560
|
+
"window_weeks": <int>,
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
The Privacy invariant per spec §7.4 lives at the share-render gate
|
|
1564
|
+
(`_lib_share._scrub`), NOT here. This panel_data carries REAL
|
|
1565
|
+
display_keys + bucket_paths; downstream `_scrub` rewrites them
|
|
1566
|
+
when ``reveal_projects=false``.
|
|
1567
|
+
"""
|
|
1568
|
+
env: dict = getattr(snap, "projects_envelope", None) or {} if snap else {}
|
|
1569
|
+
if not env:
|
|
1570
|
+
# First-tick / sub-build failure → render a minimal "no data"
|
|
1571
|
+
# shape. _build_project_snapshot already handles empty rows
|
|
1572
|
+
# downstream via "no data" title.
|
|
1573
|
+
now = _share_now_utc()
|
|
1574
|
+
return {
|
|
1575
|
+
"rows": [],
|
|
1576
|
+
"total_cost_usd": 0.0,
|
|
1577
|
+
"period_start": now - dt.timedelta(days=7),
|
|
1578
|
+
"period_end": now,
|
|
1579
|
+
"window_weeks": 1,
|
|
1580
|
+
}
|
|
1581
|
+
weeks_back_raw = options.get("windowWeeks", 1)
|
|
1582
|
+
try:
|
|
1583
|
+
weeks_back = int(weeks_back_raw)
|
|
1584
|
+
except (TypeError, ValueError):
|
|
1585
|
+
weeks_back = 1
|
|
1586
|
+
if weeks_back not in {1, 4, 8, 12}:
|
|
1587
|
+
weeks_back = 1
|
|
1588
|
+
cw = env.get("current_week", {}) or {}
|
|
1589
|
+
trend = env.get("trend", {}) or {}
|
|
1590
|
+
|
|
1591
|
+
# `effective_weeks` is the actual number of weeks of data the artifact
|
|
1592
|
+
# represents. For the 1-week (panel) path it's always 1. For multi-week
|
|
1593
|
+
# (modal) the trend envelope may carry fewer weeks than requested on
|
|
1594
|
+
# thin-history dashboards (fresh installs, post-rebuild), so clamp to
|
|
1595
|
+
# whatever history exists — otherwise the share artifact would label
|
|
1596
|
+
# itself "Last 12 weeks" and render a 12-week date range while only
|
|
1597
|
+
# (say) 3 weeks of rows were aggregated. The period bounds and the
|
|
1598
|
+
# `window_weeks` returned downstream both ride on `effective_weeks`.
|
|
1599
|
+
rows: list[dict]
|
|
1600
|
+
if weeks_back == 1:
|
|
1601
|
+
effective_weeks = 1
|
|
1602
|
+
rows = [
|
|
1603
|
+
{
|
|
1604
|
+
"key": r["key"],
|
|
1605
|
+
"bucket_path": r["bucket_path"],
|
|
1606
|
+
"cost_usd": float(r["cost_usd"]),
|
|
1607
|
+
"attributed_pct": r.get("attributed_pct"),
|
|
1608
|
+
"sessions_count": int(r.get("sessions_count", 0) or 0),
|
|
1609
|
+
}
|
|
1610
|
+
for r in (cw.get("rows") or [])
|
|
1611
|
+
]
|
|
1612
|
+
total_cost = float(cw.get("total_cost_usd", 0.0) or 0.0)
|
|
1613
|
+
else:
|
|
1614
|
+
# Multi-week: sum across the trailing `weeks_back` slices of
|
|
1615
|
+
# trend.projects[i].weekly_cost. attributed_pct sums each
|
|
1616
|
+
# project's weekly_pct (None when no week has a snapshot).
|
|
1617
|
+
n_weeks = len(trend.get("weeks") or [])
|
|
1618
|
+
# The trend window is already clamped to <= 12; we take the
|
|
1619
|
+
# trailing `weeks_back` slices.
|
|
1620
|
+
take = min(weeks_back, n_weeks)
|
|
1621
|
+
# On a brand-new dashboard with zero trend weeks, fall back to a
|
|
1622
|
+
# single-week (current_week) period so the artifact's labelling
|
|
1623
|
+
# still names a real range instead of "Last 0 weeks".
|
|
1624
|
+
effective_weeks = max(1, take)
|
|
1625
|
+
rows = []
|
|
1626
|
+
running_total = 0.0
|
|
1627
|
+
for tp in trend.get("projects") or []:
|
|
1628
|
+
wc = (tp.get("weekly_cost") or [])[-take:]
|
|
1629
|
+
wp = (tp.get("weekly_pct") or [])[-take:]
|
|
1630
|
+
ws = (tp.get("sessions_per_week") or [])[-take:]
|
|
1631
|
+
cost = float(sum(wc))
|
|
1632
|
+
running_total += cost
|
|
1633
|
+
valid_pct = [float(p) for p in wp if p is not None]
|
|
1634
|
+
attributed = sum(valid_pct) if valid_pct else None
|
|
1635
|
+
# Sum per-week distinct session counts. Slight over-count when a
|
|
1636
|
+
# single session spans a week boundary; the envelope's per-week
|
|
1637
|
+
# bucketing has no session-id sets to union, so this is the
|
|
1638
|
+
# cheapest reasonable approximation and matches the modal's
|
|
1639
|
+
# client-side derivation (envelope.ts → ProjectsModal.tsx).
|
|
1640
|
+
rows.append({
|
|
1641
|
+
"key": tp["key"],
|
|
1642
|
+
"bucket_path": tp["bucket_path"],
|
|
1643
|
+
"cost_usd": cost,
|
|
1644
|
+
"attributed_pct": attributed,
|
|
1645
|
+
"sessions_count": int(sum(ws)),
|
|
1646
|
+
})
|
|
1647
|
+
rows.sort(key=lambda r: (-r["cost_usd"], r["key"]))
|
|
1648
|
+
total_cost = running_total
|
|
1649
|
+
|
|
1650
|
+
# Compute window bounds from the *effective* span — see the
|
|
1651
|
+
# `effective_weeks` note above. The rows in this panel_data are
|
|
1652
|
+
# week-to-date (current_week.rows are aggregated through "now"; the
|
|
1653
|
+
# multi-week branch sums weekly_cost slices, with the trailing slice
|
|
1654
|
+
# also week-to-date), so clip `period_end` to min(reset_at, now).
|
|
1655
|
+
# Without the clip a mid-week export advertises a future reset date
|
|
1656
|
+
# in the rendered period/frontmatter and disagrees with the live
|
|
1657
|
+
# dashboard's "spent this week" KPI, which is symmetrically clipped
|
|
1658
|
+
# by `_build_current_week_share_panel_data`'s use of `now`.
|
|
1659
|
+
cw_start_iso = cw.get("week_start_at") or _share_now_utc_iso()
|
|
1660
|
+
cw_start = parse_iso_datetime(cw_start_iso, "projects.cw_start")
|
|
1661
|
+
week_end = cw_start + dt.timedelta(days=7)
|
|
1662
|
+
now = _share_now_utc()
|
|
1663
|
+
period_end = week_end if week_end <= now else now
|
|
1664
|
+
period_start = cw_start - dt.timedelta(days=7 * (effective_weeks - 1))
|
|
1665
|
+
|
|
1666
|
+
return {
|
|
1667
|
+
"rows": rows,
|
|
1668
|
+
"total_cost_usd": total_cost,
|
|
1669
|
+
"period_start": period_start,
|
|
1670
|
+
"period_end": period_end,
|
|
1671
|
+
"window_weeks": effective_weeks,
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
|
|
1529
1675
|
|
|
1530
1676
|
|
|
1531
1677
|
# === Dashboard server core: _SnapshotRef + SSEHub + envelope builders =====
|
|
@@ -2310,6 +2456,888 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
|
|
|
2310
2456
|
return rows
|
|
2311
2457
|
|
|
2312
2458
|
|
|
2459
|
+
# --- Projects panel / modal (spec 2026-05-19-projects-panel-design.md) ------
|
|
2460
|
+
#
|
|
2461
|
+
# Per-tick projects envelope builder. Runs on the sync thread that
|
|
2462
|
+
# populates ``DataSnapshot``; the dashboard's pure ``snapshot_to_envelope``
|
|
2463
|
+
# reads it back unchanged and assigns to ``envelope["projects"]`` so the
|
|
2464
|
+
# serializer stays DB-free.
|
|
2465
|
+
#
|
|
2466
|
+
# See spec §5.2 (envelope shape), §6.2 (signatures), §6.4 (memoization),
|
|
2467
|
+
# §9.2 (R-PROJ1..R-PROJ5 reconcile invariants).
|
|
2468
|
+
#
|
|
2469
|
+
# Identity:
|
|
2470
|
+
# - ``key`` = disambiguated ``ProjectKey.display_key`` via
|
|
2471
|
+
# ``_lib_render._project_disambiguate_labels``.
|
|
2472
|
+
# Stable within a single envelope (a `foo` collision
|
|
2473
|
+
# resolves to `foo (parent_dir)`).
|
|
2474
|
+
# - ``bucket_path`` = canonical equality key (``ProjectKey.bucket_path``)
|
|
2475
|
+
# — the absolute on-disk path. Privacy-sensitive;
|
|
2476
|
+
# _lib_share._scrub strips it on the share path.
|
|
2477
|
+
|
|
2478
|
+
# Per-tick memo (spec §6.4 + memory: *Pre-probe before sync_cache*).
|
|
2479
|
+
# Keyed on (max(session_entries.id), current_week.week_start_at,
|
|
2480
|
+
# weeks_back); single entry, in-process only. Bounded per-tick cost on
|
|
2481
|
+
# large caches: subsequent calls within the same sync tick hit the
|
|
2482
|
+
# memo and skip the aggregation walk.
|
|
2483
|
+
_PROJECTS_ENV_MEMO: dict = {"key": None, "value": None}
|
|
2484
|
+
|
|
2485
|
+
|
|
2486
|
+
def _projects_reset_memo() -> None:
|
|
2487
|
+
"""Clear the projects envelope memo. Used by unit tests that want to
|
|
2488
|
+
measure the inner aggregation cost in isolation."""
|
|
2489
|
+
_PROJECTS_ENV_MEMO["key"] = None
|
|
2490
|
+
_PROJECTS_ENV_MEMO["value"] = None
|
|
2491
|
+
|
|
2492
|
+
|
|
2493
|
+
def _projects_week_start_monday_utc(ts: "dt.datetime") -> "dt.datetime":
|
|
2494
|
+
"""Anchor ``ts`` to its containing ISO-Monday 00:00 UTC subscription
|
|
2495
|
+
week start. Fallback shape used when no snapshot anchor is available
|
|
2496
|
+
— mirrors ``cmd_project``'s Monday fallback (bin/cctally:4711)."""
|
|
2497
|
+
base = ts.astimezone(dt.timezone.utc)
|
|
2498
|
+
return (base - dt.timedelta(days=base.weekday())).replace(
|
|
2499
|
+
hour=0, minute=0, second=0, microsecond=0,
|
|
2500
|
+
)
|
|
2501
|
+
|
|
2502
|
+
|
|
2503
|
+
def _projects_week_label(week_start: "dt.datetime") -> str:
|
|
2504
|
+
"""Render a `wk Mon DD` label for the trend chart x-axis.
|
|
2505
|
+
|
|
2506
|
+
Per spec §5.2's `weeks[].week_label` example (`"wk Apr 22"`).
|
|
2507
|
+
UTC-anchored so JSON output is tz-agnostic.
|
|
2508
|
+
"""
|
|
2509
|
+
return f"wk {week_start.strftime('%b %d')}"
|
|
2510
|
+
|
|
2511
|
+
|
|
2512
|
+
def _projects_iter_session_entries(conn: "sqlite3.Connection",
|
|
2513
|
+
*,
|
|
2514
|
+
since: "dt.datetime",
|
|
2515
|
+
until: "dt.datetime"):
|
|
2516
|
+
"""Read ``session_entries`` joined with ``session_files`` over
|
|
2517
|
+
[since, until]. Yields rows directly off the passed conn — no
|
|
2518
|
+
cache.db monkeypatch, no production ``get_claude_session_entries``
|
|
2519
|
+
pipeline. The fixture DBs co-locate both schemas in one file; the
|
|
2520
|
+
production wiring opens both DBs and ATTACHes cache.db as a schema
|
|
2521
|
+
on the stats conn (see ``_run_dashboard_sync_tick``).
|
|
2522
|
+
"""
|
|
2523
|
+
since_iso = since.astimezone(dt.timezone.utc).strftime(
|
|
2524
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
2525
|
+
)
|
|
2526
|
+
until_iso = until.astimezone(dt.timezone.utc).strftime(
|
|
2527
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
2528
|
+
)
|
|
2529
|
+
cur = conn.execute(
|
|
2530
|
+
"SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
|
|
2531
|
+
" e.output_tokens, e.cache_create_tokens, e.cache_read_tokens, "
|
|
2532
|
+
" e.cost_usd_raw, e.source_path, "
|
|
2533
|
+
" sf.session_id, sf.project_path "
|
|
2534
|
+
"FROM session_entries e "
|
|
2535
|
+
"LEFT JOIN session_files sf ON sf.path = e.source_path "
|
|
2536
|
+
"WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
|
|
2537
|
+
"ORDER BY e.timestamp_utc ASC, e.id ASC",
|
|
2538
|
+
(since_iso, until_iso),
|
|
2539
|
+
)
|
|
2540
|
+
for row in cur:
|
|
2541
|
+
yield row
|
|
2542
|
+
|
|
2543
|
+
|
|
2544
|
+
def _build_projects_envelope(
|
|
2545
|
+
conn: "sqlite3.Connection",
|
|
2546
|
+
*,
|
|
2547
|
+
now_utc: "dt.datetime",
|
|
2548
|
+
current_week: "Any | None" = None,
|
|
2549
|
+
weeks_back: int = 12,
|
|
2550
|
+
) -> dict:
|
|
2551
|
+
"""Build the ``projects.{current_week, trend}`` envelope block.
|
|
2552
|
+
|
|
2553
|
+
Reuses ``cmd_project``'s identity model — per-(``ProjectKey``, week)
|
|
2554
|
+
rollup over ``session_entries`` with display-key disambiguation via
|
|
2555
|
+
``_project_disambiguate_labels`` — but emits the simpler envelope
|
|
2556
|
+
shape from spec §5.2 (no per-model breakdowns, no first/last seen
|
|
2557
|
+
per session, no per-row $/1%; just cost / attributed_pct / sessions).
|
|
2558
|
+
|
|
2559
|
+
Week boundaries follow ``cmd_project``'s Monday-anchored UTC
|
|
2560
|
+
fallback (``bin/cctally:4711``); ``weekly_usage_snapshots`` rows are
|
|
2561
|
+
matched by ``week_start_date`` (date-only) for ``attributed_pct``.
|
|
2562
|
+
|
|
2563
|
+
``current_week`` is passed through opaquely — if non-None and
|
|
2564
|
+
carrying a ``.week_start_at`` UTC datetime, that boundary supplants
|
|
2565
|
+
the Monday fallback for the current week's bucket. None (the
|
|
2566
|
+
default) preserves the fallback.
|
|
2567
|
+
|
|
2568
|
+
Determinism: same conn + same ``now_utc`` ⇒ byte-identical JSON
|
|
2569
|
+
(R-PROJ5 invariant). Per-tick memoized on
|
|
2570
|
+
``(max(session_entries.id), cw_week_start, weeks_back)``.
|
|
2571
|
+
"""
|
|
2572
|
+
c = _cctally()
|
|
2573
|
+
|
|
2574
|
+
# ---- Pre-probe gate / memoization (spec §6.4) -----------------------
|
|
2575
|
+
# `attributed_pct` and trend `total_pct` are functions of
|
|
2576
|
+
# `weekly_usage_snapshots.weekly_percent`, which the throttled OAuth
|
|
2577
|
+
# refresh path can advance between session_entries writes. Probe
|
|
2578
|
+
# `MAX(weekly_usage_snapshots.id)` so the memo invalidates on that
|
|
2579
|
+
# surface too (mirrors the operational-error guard the attribution
|
|
2580
|
+
# SELECT uses below).
|
|
2581
|
+
cur = conn.execute("SELECT COALESCE(MAX(id), 0) FROM session_entries")
|
|
2582
|
+
max_id = cur.fetchone()[0]
|
|
2583
|
+
try:
|
|
2584
|
+
cur = conn.execute(
|
|
2585
|
+
"SELECT COALESCE(MAX(id), 0) FROM weekly_usage_snapshots"
|
|
2586
|
+
)
|
|
2587
|
+
max_wus_id = cur.fetchone()[0]
|
|
2588
|
+
except sqlite3.OperationalError:
|
|
2589
|
+
max_wus_id = 0
|
|
2590
|
+
cw_key: "dt.datetime | None" = None
|
|
2591
|
+
if current_week is not None:
|
|
2592
|
+
cw_key = getattr(current_week, "week_start_at", None)
|
|
2593
|
+
memo_key = (max_id, max_wus_id, cw_key, weeks_back)
|
|
2594
|
+
cached = _PROJECTS_ENV_MEMO.get("value")
|
|
2595
|
+
if cached is not None and _PROJECTS_ENV_MEMO.get("key") == memo_key:
|
|
2596
|
+
return cached
|
|
2597
|
+
|
|
2598
|
+
# ---- Week-start anchor (current subscription week) ------------------
|
|
2599
|
+
# ``TuiCurrentWeek.week_start_at`` is NOT a valid Monday lookup key
|
|
2600
|
+
# after ``_apply_midweek_reset_override`` — it is shifted to the
|
|
2601
|
+
# in-week reset instant (e.g. Friday 13:00 UTC) while the bucket
|
|
2602
|
+
# aggregator below snaps every entry to its containing ISO-Monday
|
|
2603
|
+
# via ``_week_for``. Using ``cw_key`` directly as the bucket-lookup
|
|
2604
|
+
# key strands all current-week activity in an empty bucket and emits
|
|
2605
|
+
# ``rows: []`` with ``total_cost_usd: 0.0``. Snap to the canonical
|
|
2606
|
+
# Monday-UTC week anchor here so the lookup keys align — same
|
|
2607
|
+
# invariant the weekly handling notes call out for
|
|
2608
|
+
# ``weekly_usage_snapshots``/``percent_milestones`` cross-table
|
|
2609
|
+
# joins. Regression: ``tests/fixtures/dashboard/reset-week/`` +
|
|
2610
|
+
# ``test_current_week_rows_populated_after_midweek_reset``.
|
|
2611
|
+
if cw_key is not None:
|
|
2612
|
+
cw_start = _projects_week_start_monday_utc(cw_key)
|
|
2613
|
+
else:
|
|
2614
|
+
cw_start = _projects_week_start_monday_utc(now_utc)
|
|
2615
|
+
|
|
2616
|
+
# Build a list of canonical Monday-anchored week starts ending with
|
|
2617
|
+
# cw_start, oldest → newest, of length ``weeks_back``. Clamping to
|
|
2618
|
+
# actual history happens after the entry walk reveals what weeks
|
|
2619
|
+
# have any activity.
|
|
2620
|
+
weeks_full = [
|
|
2621
|
+
cw_start - dt.timedelta(days=7 * (weeks_back - 1 - i))
|
|
2622
|
+
for i in range(weeks_back)
|
|
2623
|
+
]
|
|
2624
|
+
cw_end = cw_start + dt.timedelta(days=7)
|
|
2625
|
+
since_dt = weeks_full[0]
|
|
2626
|
+
until_dt = cw_end # exclusive end; SQL is `>= since AND <= until`
|
|
2627
|
+
|
|
2628
|
+
# ---- Bucket entries per (ProjectKey, week_start) --------------------
|
|
2629
|
+
# `_resolve_project_key` is the production resolver; we use git-root
|
|
2630
|
+
# mode (default for `cmd_project --group` absent) — matches the
|
|
2631
|
+
# CLI's default.
|
|
2632
|
+
_resolve_project_key = c._resolve_project_key
|
|
2633
|
+
resolver_cache: dict = {}
|
|
2634
|
+
|
|
2635
|
+
# buckets[(bucket_path, week_start)] -> {key, cost, sessions, ...}
|
|
2636
|
+
buckets: dict[tuple[str, dt.datetime], dict] = {}
|
|
2637
|
+
# Track total cost per week across ALL entries (attribution denominator).
|
|
2638
|
+
total_cost_by_week: dict[dt.datetime, float] = {}
|
|
2639
|
+
# Track first-seen ProjectKey instance per bucket_path so we can pass
|
|
2640
|
+
# the dict to `_project_disambiguate_labels` (which keys on `key.bucket_path`
|
|
2641
|
+
# equality via ProjectKey.__eq__).
|
|
2642
|
+
key_by_bucket: dict[str, Any] = {}
|
|
2643
|
+
|
|
2644
|
+
def _week_for(ts: dt.datetime) -> "dt.datetime | None":
|
|
2645
|
+
wstart = _projects_week_start_monday_utc(ts)
|
|
2646
|
+
if wstart < weeks_full[0] or wstart > weeks_full[-1]:
|
|
2647
|
+
return None
|
|
2648
|
+
return wstart
|
|
2649
|
+
|
|
2650
|
+
# Orphan handling: `_projects_iter_session_entries` LEFT JOINs
|
|
2651
|
+
# `session_files` so entries whose source_path has no
|
|
2652
|
+
# `session_files` row return `project_path = NULL`. Below,
|
|
2653
|
+
# `_resolve_project_key(None, ...)` maps that to the
|
|
2654
|
+
# `(unknown)` bucket — same identity the drill-down's explicit
|
|
2655
|
+
# orphan scan in `_project_detail_for_window` (see the
|
|
2656
|
+
# ``if unknown_bucket:`` branch around the
|
|
2657
|
+
# `orphan_cur` SELECT) collects via a NULL-side LEFT JOIN. The
|
|
2658
|
+
# two paths converge on the same `(unknown)` source_path set.
|
|
2659
|
+
for row in _projects_iter_session_entries(
|
|
2660
|
+
conn, since=since_dt, until=until_dt,
|
|
2661
|
+
):
|
|
2662
|
+
(entry_id, ts_iso, model, input_tok, output_tok,
|
|
2663
|
+
cache_create, cache_read, cost_raw, source_path,
|
|
2664
|
+
session_id, project_path) = row
|
|
2665
|
+
if model == "<synthetic>":
|
|
2666
|
+
continue
|
|
2667
|
+
# Parse timestamp; assume Z / +00:00 — production iterators do
|
|
2668
|
+
# the same via `parse_iso_datetime`.
|
|
2669
|
+
ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
|
|
2670
|
+
wstart = _week_for(ts)
|
|
2671
|
+
if wstart is None:
|
|
2672
|
+
continue
|
|
2673
|
+
|
|
2674
|
+
# Entry cost via the shared pricing chokepoint.
|
|
2675
|
+
entry_cost = _calculate_entry_cost(
|
|
2676
|
+
model,
|
|
2677
|
+
{
|
|
2678
|
+
"input_tokens": input_tok or 0,
|
|
2679
|
+
"output_tokens": output_tok or 0,
|
|
2680
|
+
"cache_creation_input_tokens": cache_create or 0,
|
|
2681
|
+
"cache_read_input_tokens": cache_read or 0,
|
|
2682
|
+
},
|
|
2683
|
+
mode="auto",
|
|
2684
|
+
cost_usd=cost_raw,
|
|
2685
|
+
)
|
|
2686
|
+
|
|
2687
|
+
# Project-key identity (`git_root` mode = production default).
|
|
2688
|
+
pkey = _resolve_project_key(project_path, "git-root", resolver_cache)
|
|
2689
|
+
bkey = (pkey.bucket_path, wstart)
|
|
2690
|
+
b = buckets.get(bkey)
|
|
2691
|
+
if b is None:
|
|
2692
|
+
b = {
|
|
2693
|
+
"key": pkey,
|
|
2694
|
+
"cost_usd": 0.0,
|
|
2695
|
+
"sessions": set(),
|
|
2696
|
+
"first_seen": ts,
|
|
2697
|
+
"last_seen": ts,
|
|
2698
|
+
}
|
|
2699
|
+
buckets[bkey] = b
|
|
2700
|
+
b["cost_usd"] += entry_cost
|
|
2701
|
+
if session_id:
|
|
2702
|
+
b["sessions"].add(session_id)
|
|
2703
|
+
elif source_path:
|
|
2704
|
+
# Fallback: treat one source_path as one session when
|
|
2705
|
+
# session_files.session_id is NULL (lazy population).
|
|
2706
|
+
b["sessions"].add(source_path)
|
|
2707
|
+
if ts < b["first_seen"]:
|
|
2708
|
+
b["first_seen"] = ts
|
|
2709
|
+
if ts > b["last_seen"]:
|
|
2710
|
+
b["last_seen"] = ts
|
|
2711
|
+
total_cost_by_week[wstart] = (
|
|
2712
|
+
total_cost_by_week.get(wstart, 0.0) + entry_cost
|
|
2713
|
+
)
|
|
2714
|
+
# Remember first-seen ProjectKey for each bucket_path so the
|
|
2715
|
+
# disambiguator pass below sees consistent ProjectKey instances.
|
|
2716
|
+
if pkey.bucket_path not in key_by_bucket:
|
|
2717
|
+
key_by_bucket[pkey.bucket_path] = pkey
|
|
2718
|
+
|
|
2719
|
+
# ---- Load weekly_usage_snapshots for attribution --------------------
|
|
2720
|
+
# weekly_percent keyed by week_start (UTC datetime, Monday). We
|
|
2721
|
+
# match on `week_start_date` (date-only) since that's the canonical
|
|
2722
|
+
# cross-table key per the CLAUDE.md weekly handling notes.
|
|
2723
|
+
#
|
|
2724
|
+
# We use the LATEST snapshot per week_start_date — NOT MAX — because
|
|
2725
|
+
# the "weekly_percent is monotonic within a week" invariant breaks
|
|
2726
|
+
# on weeks that receive an in-place credit (see CLAUDE.md "In-place
|
|
2727
|
+
# 5h credit" / `week_reset_events` notes). MAX would lock attribution
|
|
2728
|
+
# to the pre-credit high-water mark even after Anthropic credits the
|
|
2729
|
+
# week back down, overstating Used % on the Projects panel/modal
|
|
2730
|
+
# forever. The "latest row" pattern matches `_select_last_known_snapshot`
|
|
2731
|
+
# (bin/cctally:1162-1168) and the doctor credited-week check
|
|
2732
|
+
# (bin/cctally:8706-8714).
|
|
2733
|
+
#
|
|
2734
|
+
# Portable per-key-latest pattern: read rows ordered by capture-
|
|
2735
|
+
# ascending and let later rows overwrite. The final value per key
|
|
2736
|
+
# is the most-recent snapshot.
|
|
2737
|
+
weekly_pct_by_week: dict[dt.datetime, float] = {}
|
|
2738
|
+
try:
|
|
2739
|
+
cur = conn.execute(
|
|
2740
|
+
"SELECT week_start_date, weekly_percent "
|
|
2741
|
+
"FROM weekly_usage_snapshots "
|
|
2742
|
+
"ORDER BY captured_at_utc ASC, id ASC"
|
|
2743
|
+
)
|
|
2744
|
+
rows = cur.fetchall()
|
|
2745
|
+
except sqlite3.OperationalError:
|
|
2746
|
+
# No weekly_usage_snapshots table — leaves attributed_pct = None
|
|
2747
|
+
# throughout (acceptable per spec §2.7).
|
|
2748
|
+
rows = []
|
|
2749
|
+
for week_date_str, weekly_pct in rows:
|
|
2750
|
+
try:
|
|
2751
|
+
wd = dt.date.fromisoformat(week_date_str)
|
|
2752
|
+
except (TypeError, ValueError):
|
|
2753
|
+
continue
|
|
2754
|
+
# Snap the date to UTC Monday 00:00 (matches the bucketing key).
|
|
2755
|
+
wstart = dt.datetime.combine(
|
|
2756
|
+
wd, dt.time(0, 0, 0), tzinfo=dt.timezone.utc,
|
|
2757
|
+
)
|
|
2758
|
+
# Snap to Monday (snapshot rows that captured a non-Monday week
|
|
2759
|
+
# boundary still align to the same canonical bucket as the entry
|
|
2760
|
+
# walk, since the bucketing is Monday-anchored).
|
|
2761
|
+
wstart = _projects_week_start_monday_utc(wstart)
|
|
2762
|
+
if weekly_pct is not None:
|
|
2763
|
+
weekly_pct_by_week[wstart] = float(weekly_pct)
|
|
2764
|
+
|
|
2765
|
+
# ---- Disambiguate display_keys across the union of projects --------
|
|
2766
|
+
# `_project_disambiguate_labels` expects a list of dicts each with a
|
|
2767
|
+
# `key` field that's a ProjectKey. Sort by bucket_path for stable
|
|
2768
|
+
# indexing.
|
|
2769
|
+
bucket_paths_sorted = sorted(key_by_bucket.keys())
|
|
2770
|
+
disambig_rows = [
|
|
2771
|
+
{"key": key_by_bucket[bp]} for bp in bucket_paths_sorted
|
|
2772
|
+
]
|
|
2773
|
+
augmented_by_idx = c._project_disambiguate_labels(disambig_rows)
|
|
2774
|
+
display_key_by_bucket: dict[str, str] = {}
|
|
2775
|
+
for idx, bp in enumerate(bucket_paths_sorted):
|
|
2776
|
+
pkey = key_by_bucket[bp]
|
|
2777
|
+
display_key_by_bucket[bp] = augmented_by_idx.get(
|
|
2778
|
+
idx, pkey.display_key,
|
|
2779
|
+
)
|
|
2780
|
+
|
|
2781
|
+
# ---- Determine actual weeks emitted (clamp to history) -------------
|
|
2782
|
+
weeks_with_activity = sorted(
|
|
2783
|
+
ws for ws in {ws for (_bp, ws) in buckets.keys()}
|
|
2784
|
+
)
|
|
2785
|
+
if weeks_with_activity:
|
|
2786
|
+
# Window = inclusive [oldest_active_week, cw_start]. Always emits
|
|
2787
|
+
# cw_start (panel + trend share the same current_week column).
|
|
2788
|
+
oldest = min(weeks_with_activity[0], cw_start)
|
|
2789
|
+
trend_weeks = []
|
|
2790
|
+
w = oldest
|
|
2791
|
+
while w <= cw_start:
|
|
2792
|
+
trend_weeks.append(w)
|
|
2793
|
+
w += dt.timedelta(days=7)
|
|
2794
|
+
else:
|
|
2795
|
+
trend_weeks = [cw_start]
|
|
2796
|
+
|
|
2797
|
+
# ---- current_week.rows ---------------------------------------------
|
|
2798
|
+
cw_rows = []
|
|
2799
|
+
cw_pct = weekly_pct_by_week.get(cw_start)
|
|
2800
|
+
cw_total_cost = total_cost_by_week.get(cw_start, 0.0)
|
|
2801
|
+
for bp in bucket_paths_sorted:
|
|
2802
|
+
b = buckets.get((bp, cw_start))
|
|
2803
|
+
if b is None:
|
|
2804
|
+
continue
|
|
2805
|
+
if cw_pct is not None and cw_total_cost > 0:
|
|
2806
|
+
attributed = (b["cost_usd"] / cw_total_cost) * cw_pct
|
|
2807
|
+
else:
|
|
2808
|
+
attributed = None
|
|
2809
|
+
cw_rows.append({
|
|
2810
|
+
"key": display_key_by_bucket[bp],
|
|
2811
|
+
"bucket_path": bp,
|
|
2812
|
+
# NOTE: NOT rounded — `round(..., 6)` introduces ~1e-6 error
|
|
2813
|
+
# that breaks the R-PROJ1/R-PROJ2 1e-9 reconcile tolerance.
|
|
2814
|
+
# The JSON serializer emits up to 17 significant digits;
|
|
2815
|
+
# network bandwidth is negligible (KB-scale payloads).
|
|
2816
|
+
"cost_usd": b["cost_usd"],
|
|
2817
|
+
"attributed_pct": attributed,
|
|
2818
|
+
"sessions_count": len(b["sessions"]),
|
|
2819
|
+
})
|
|
2820
|
+
# Desc by cost (ties broken by key for byte-stability across runs).
|
|
2821
|
+
cw_rows.sort(key=lambda r: (-r["cost_usd"], r["key"]))
|
|
2822
|
+
|
|
2823
|
+
cw_block = {
|
|
2824
|
+
"week_label": _projects_week_label(cw_start),
|
|
2825
|
+
"week_start_date": cw_start.date().isoformat(),
|
|
2826
|
+
"week_start_at": _iso_z(cw_start),
|
|
2827
|
+
"total_cost_usd": cw_total_cost,
|
|
2828
|
+
"rows": cw_rows,
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
# ---- trend.weeks[] + trend.projects[] ------------------------------
|
|
2832
|
+
trend_weeks_blocks = []
|
|
2833
|
+
for w in trend_weeks:
|
|
2834
|
+
wpct = weekly_pct_by_week.get(w)
|
|
2835
|
+
trend_weeks_blocks.append({
|
|
2836
|
+
"week_start_date": w.date().isoformat(),
|
|
2837
|
+
"week_label": _projects_week_label(w),
|
|
2838
|
+
"total_cost_usd": total_cost_by_week.get(w, 0.0),
|
|
2839
|
+
"total_pct": wpct,
|
|
2840
|
+
})
|
|
2841
|
+
|
|
2842
|
+
trend_projects = []
|
|
2843
|
+
for bp in bucket_paths_sorted:
|
|
2844
|
+
weekly_cost: list[float] = []
|
|
2845
|
+
weekly_pct_arr: list[float | None] = []
|
|
2846
|
+
sessions_per_week: list[int] = []
|
|
2847
|
+
first_seen_per_week: list[str | None] = []
|
|
2848
|
+
last_seen_per_week: list[str | None] = []
|
|
2849
|
+
for w in trend_weeks:
|
|
2850
|
+
b = buckets.get((bp, w))
|
|
2851
|
+
if b is None:
|
|
2852
|
+
weekly_cost.append(0.0)
|
|
2853
|
+
weekly_pct_arr.append(None)
|
|
2854
|
+
sessions_per_week.append(0)
|
|
2855
|
+
first_seen_per_week.append(None)
|
|
2856
|
+
last_seen_per_week.append(None)
|
|
2857
|
+
continue
|
|
2858
|
+
week_total = total_cost_by_week.get(w, 0.0)
|
|
2859
|
+
week_pct = weekly_pct_by_week.get(w)
|
|
2860
|
+
if week_pct is not None and week_total > 0:
|
|
2861
|
+
attributed = (b["cost_usd"] / week_total) * week_pct
|
|
2862
|
+
else:
|
|
2863
|
+
attributed = None
|
|
2864
|
+
weekly_cost.append(b["cost_usd"])
|
|
2865
|
+
weekly_pct_arr.append(attributed)
|
|
2866
|
+
sessions_per_week.append(len(b["sessions"]))
|
|
2867
|
+
first_seen_per_week.append(_iso_z(b["first_seen"]))
|
|
2868
|
+
last_seen_per_week.append(_iso_z(b["last_seen"]))
|
|
2869
|
+
# Skip projects with zero total cost across the entire window
|
|
2870
|
+
# (the bucket-loop only enters projects that have at least one
|
|
2871
|
+
# entry, so this is mainly a safety check).
|
|
2872
|
+
if all(c == 0.0 for c in weekly_cost):
|
|
2873
|
+
continue
|
|
2874
|
+
trend_projects.append({
|
|
2875
|
+
"key": display_key_by_bucket[bp],
|
|
2876
|
+
"bucket_path": bp,
|
|
2877
|
+
"weekly_cost": weekly_cost,
|
|
2878
|
+
"weekly_pct": weekly_pct_arr,
|
|
2879
|
+
"sessions_per_week": sessions_per_week,
|
|
2880
|
+
"first_seen_per_week": first_seen_per_week,
|
|
2881
|
+
"last_seen_per_week": last_seen_per_week,
|
|
2882
|
+
})
|
|
2883
|
+
# Stable sort: desc by total window cost, ties broken by key.
|
|
2884
|
+
trend_projects.sort(
|
|
2885
|
+
key=lambda p: (-sum(p["weekly_cost"]), p["key"]),
|
|
2886
|
+
)
|
|
2887
|
+
|
|
2888
|
+
trend_block = {
|
|
2889
|
+
"window_weeks": len(trend_weeks),
|
|
2890
|
+
"weeks": trend_weeks_blocks,
|
|
2891
|
+
"projects": trend_projects,
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
result = {
|
|
2895
|
+
"current_week": cw_block,
|
|
2896
|
+
"trend": trend_block,
|
|
2897
|
+
}
|
|
2898
|
+
_PROJECTS_ENV_MEMO["key"] = memo_key
|
|
2899
|
+
_PROJECTS_ENV_MEMO["value"] = result
|
|
2900
|
+
return result
|
|
2901
|
+
|
|
2902
|
+
|
|
2903
|
+
def _project_detail_for_window(
|
|
2904
|
+
conn: "sqlite3.Connection",
|
|
2905
|
+
*,
|
|
2906
|
+
project_key: str,
|
|
2907
|
+
weeks_back: int,
|
|
2908
|
+
now_utc: "dt.datetime",
|
|
2909
|
+
current_week: "Any | None" = None,
|
|
2910
|
+
projects_envelope: "dict | None" = None,
|
|
2911
|
+
) -> "dict | None":
|
|
2912
|
+
"""Build the drill payload for ``GET /api/project/<key>?weeks=N``
|
|
2913
|
+
(spec §5.3).
|
|
2914
|
+
|
|
2915
|
+
Resolves ``project_key`` against the same disambiguated display
|
|
2916
|
+
keys the envelope emits. Returns ``None`` on miss → caller maps to
|
|
2917
|
+
HTTP 404. Top-N sessions by ``last_activity`` desc (cap=5 per spec
|
|
2918
|
+
§5.3); models list is desc by cost. ``window_attributed_pct`` is
|
|
2919
|
+
the across-window sum of ``(project_cost_in_week / week_total) *
|
|
2920
|
+
week_pct`` — ``None`` when no contributing week has a snapshot.
|
|
2921
|
+
|
|
2922
|
+
Identity invariant (CLAUDE.md spec §9.2 R-PROJ3 + Codex F6):
|
|
2923
|
+
``project_key`` is matched against the DISAMBIGUATED display_key
|
|
2924
|
+
(`foo (repos)` etc.), NOT a substring filter — the CLI
|
|
2925
|
+
``--project <pattern>`` form does NOT reliably round-trip
|
|
2926
|
+
disambiguated keys. The reconcile harness asserts this by
|
|
2927
|
+
``bucket_path`` identity, not pattern.
|
|
2928
|
+
|
|
2929
|
+
Performance — two layered optimizations make this O(project-rows-
|
|
2930
|
+
in-window) instead of O(all-rows-in-window):
|
|
2931
|
+
|
|
2932
|
+
1. ``projects_envelope`` (HTTP path passes ``snap.projects_envelope``):
|
|
2933
|
+
reuse the sync thread's already-built envelope for the
|
|
2934
|
+
``project_key`` → ``bucket_path`` resolution and the trend-pct
|
|
2935
|
+
lookup, instead of rebuilding. On an active dashboard, the
|
|
2936
|
+
per-process memo on ``_build_projects_envelope`` invalidates
|
|
2937
|
+
every time ``session_entries.id`` advances — between the sync
|
|
2938
|
+
tick and the user's click, that's almost always — so the memo
|
|
2939
|
+
saves nothing on the HTTP path. Plumbing the snapshot's
|
|
2940
|
+
envelope through skips ~1-2s of redundant work per drill.
|
|
2941
|
+
|
|
2942
|
+
2. SQL-side bucket filter: resolve ``bucket_path`` → set of
|
|
2943
|
+
``session_files.path`` once (cheap — ``session_files`` is
|
|
2944
|
+
~8k rows vs ``session_entries`` 150k+), stage into a TEMP TABLE,
|
|
2945
|
+
and INNER JOIN the entries walk so the engine only touches this
|
|
2946
|
+
project's rows. Eliminates the Python-side
|
|
2947
|
+
``if pkey.bucket_path != bucket_path: continue`` filter that
|
|
2948
|
+
previously discarded ~99% of rows after paying for parse +
|
|
2949
|
+
resolve + cost-compute on each.
|
|
2950
|
+
"""
|
|
2951
|
+
c = _cctally()
|
|
2952
|
+
|
|
2953
|
+
# Resolve the projects envelope: prefer the snapshot-provided one
|
|
2954
|
+
# (sync thread already built it for this tick) over rebuilding
|
|
2955
|
+
# locally. ``None`` keeps the legacy behavior for callers that
|
|
2956
|
+
# don't have a snapshot (tests, the reconcile harness).
|
|
2957
|
+
if projects_envelope is not None:
|
|
2958
|
+
env = projects_envelope
|
|
2959
|
+
else:
|
|
2960
|
+
env = _build_projects_envelope(
|
|
2961
|
+
conn,
|
|
2962
|
+
now_utc=now_utc,
|
|
2963
|
+
current_week=current_week,
|
|
2964
|
+
weeks_back=weeks_back,
|
|
2965
|
+
)
|
|
2966
|
+
|
|
2967
|
+
# Resolve project_key → bucket_path via the envelope's identity map.
|
|
2968
|
+
bucket_path: "str | None" = None
|
|
2969
|
+
matching_trend: "dict | None" = None
|
|
2970
|
+
for tp in env["trend"]["projects"]:
|
|
2971
|
+
if tp["key"] == project_key:
|
|
2972
|
+
bucket_path = tp["bucket_path"]
|
|
2973
|
+
matching_trend = tp
|
|
2974
|
+
break
|
|
2975
|
+
if bucket_path is None:
|
|
2976
|
+
# The project may have shown up in current_week (panel only)
|
|
2977
|
+
# but had 0 cost across the window — fall back to that.
|
|
2978
|
+
for r in env["current_week"]["rows"]:
|
|
2979
|
+
if r["key"] == project_key:
|
|
2980
|
+
bucket_path = r["bucket_path"]
|
|
2981
|
+
break
|
|
2982
|
+
if bucket_path is None:
|
|
2983
|
+
return None
|
|
2984
|
+
|
|
2985
|
+
# ---- Window bounds (Monday-anchored UTC fallback, like the builder) -
|
|
2986
|
+
cw_start = parse_iso_datetime(
|
|
2987
|
+
env["current_week"]["week_start_at"],
|
|
2988
|
+
"projects.current_week.week_start_at",
|
|
2989
|
+
)
|
|
2990
|
+
since_dt = cw_start - dt.timedelta(days=7 * (weeks_back - 1))
|
|
2991
|
+
until_dt = cw_start + dt.timedelta(days=7)
|
|
2992
|
+
|
|
2993
|
+
# ---- Build bucket → source_paths map for SQL-side scoping ----------
|
|
2994
|
+
# Walk session_files (~8k rows) once instead of session_entries
|
|
2995
|
+
# (~150k+ rows). _resolve_project_key gets called at most ~distinct-
|
|
2996
|
+
# project_paths times (~127 on real DBs) instead of once per entry,
|
|
2997
|
+
# and most of that is hashmap lookups via resolver_cache.
|
|
2998
|
+
resolver_cache: dict = {}
|
|
2999
|
+
_resolve_project_key_fn = c._resolve_project_key
|
|
3000
|
+
unknown_bucket = bucket_path == "(unknown)"
|
|
3001
|
+
bucket_source_paths: set[str] = set()
|
|
3002
|
+
sf_cur = conn.execute(
|
|
3003
|
+
"SELECT path, project_path FROM session_files"
|
|
3004
|
+
)
|
|
3005
|
+
for sf_path, sf_project_path in sf_cur:
|
|
3006
|
+
pkey = _resolve_project_key_fn(
|
|
3007
|
+
sf_project_path, "git-root", resolver_cache,
|
|
3008
|
+
)
|
|
3009
|
+
if pkey.bucket_path == bucket_path:
|
|
3010
|
+
bucket_source_paths.add(sf_path)
|
|
3011
|
+
if unknown_bucket:
|
|
3012
|
+
# session_entries rows whose source_path has no session_files
|
|
3013
|
+
# row at all LEFT-JOIN to NULL project_path → resolve to
|
|
3014
|
+
# ``(unknown)`` via _resolve_project_key. session_files-only
|
|
3015
|
+
# scan misses those.
|
|
3016
|
+
#
|
|
3017
|
+
# This explicit orphan scan is the drill-down's mirror of the
|
|
3018
|
+
# envelope's implicit orphan path: the envelope walk in
|
|
3019
|
+
# `_build_projects_envelope` (see the `_projects_iter_session_entries`
|
|
3020
|
+
# loop above) routes the same orphan source_paths through the
|
|
3021
|
+
# LEFT-JOIN→NULL→`_resolve_project_key(None, ...)` chain into the
|
|
3022
|
+
# ``(unknown)`` bucket. Both surfaces converge on the same
|
|
3023
|
+
# source_path set so envelope row counts/costs and drill row
|
|
3024
|
+
# counts/costs reconcile.
|
|
3025
|
+
orphan_cur = conn.execute(
|
|
3026
|
+
"SELECT DISTINCT e.source_path "
|
|
3027
|
+
"FROM session_entries e "
|
|
3028
|
+
"LEFT JOIN session_files sf ON sf.path = e.source_path "
|
|
3029
|
+
"WHERE sf.path IS NULL AND e.source_path IS NOT NULL"
|
|
3030
|
+
)
|
|
3031
|
+
for (sp,) in orphan_cur:
|
|
3032
|
+
bucket_source_paths.add(sp)
|
|
3033
|
+
|
|
3034
|
+
if not bucket_source_paths:
|
|
3035
|
+
# Project visible in envelope but no contributing source_paths
|
|
3036
|
+
# for this conn (rare edge — e.g. an envelope built off a
|
|
3037
|
+
# different conn). Emit an empty drill so the 404 path stays
|
|
3038
|
+
# reserved for "key unknown."
|
|
3039
|
+
return {
|
|
3040
|
+
"key": project_key,
|
|
3041
|
+
"bucket_path": bucket_path,
|
|
3042
|
+
"window_weeks": weeks_back,
|
|
3043
|
+
"window_cost_usd": 0.0,
|
|
3044
|
+
"window_attributed_pct": None,
|
|
3045
|
+
"models": [],
|
|
3046
|
+
"sessions": [],
|
|
3047
|
+
"models_total": 0,
|
|
3048
|
+
"sessions_total": 0,
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
# Stage bucket source_paths into a TEMP TABLE so the entries walk
|
|
3052
|
+
# can INNER JOIN an indexed lookup. ``IN (?, ?, ?, ...)`` would
|
|
3053
|
+
# collide with SQLite's parameter cap (~999 bindings) on heavy
|
|
3054
|
+
# multi-cwd projects. DROP-then-CREATE makes the function
|
|
3055
|
+
# re-entrant on a reused conn (the HTTP handler closes the conn
|
|
3056
|
+
# per request, but tests share conns across calls).
|
|
3057
|
+
conn.execute("DROP TABLE IF EXISTS temp._drill_paths")
|
|
3058
|
+
conn.execute(
|
|
3059
|
+
"CREATE TEMP TABLE _drill_paths(path TEXT PRIMARY KEY)"
|
|
3060
|
+
)
|
|
3061
|
+
conn.executemany(
|
|
3062
|
+
"INSERT OR IGNORE INTO _drill_paths(path) VALUES (?)",
|
|
3063
|
+
[(p,) for p in bucket_source_paths],
|
|
3064
|
+
)
|
|
3065
|
+
|
|
3066
|
+
since_iso = since_dt.astimezone(dt.timezone.utc).strftime(
|
|
3067
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
3068
|
+
)
|
|
3069
|
+
until_iso = until_dt.astimezone(dt.timezone.utc).strftime(
|
|
3070
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
3071
|
+
)
|
|
3072
|
+
|
|
3073
|
+
# ---- Walk session_entries (project-scoped) once -------------------
|
|
3074
|
+
# INNER JOIN to _drill_paths drops every row whose source_path
|
|
3075
|
+
# doesn't belong to this bucket. The Python-side filter that
|
|
3076
|
+
# previously discarded ~99% of rows post-resolve is gone.
|
|
3077
|
+
entries_cur = conn.execute(
|
|
3078
|
+
"SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
|
|
3079
|
+
" e.output_tokens, e.cache_create_tokens, "
|
|
3080
|
+
" e.cache_read_tokens, e.cost_usd_raw, e.source_path, "
|
|
3081
|
+
" sf.session_id, sf.project_path "
|
|
3082
|
+
"FROM session_entries e "
|
|
3083
|
+
"INNER JOIN _drill_paths dp ON dp.path = e.source_path "
|
|
3084
|
+
"LEFT JOIN session_files sf ON sf.path = e.source_path "
|
|
3085
|
+
"WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
|
|
3086
|
+
"ORDER BY e.timestamp_utc ASC, e.id ASC",
|
|
3087
|
+
(since_iso, until_iso),
|
|
3088
|
+
)
|
|
3089
|
+
|
|
3090
|
+
# Per-model rollup: {model -> {cost_usd, sessions, in, out, cache_*}}
|
|
3091
|
+
models: dict[str, dict] = {}
|
|
3092
|
+
# Per-session rollup: {session_id -> {cost_usd, last_activity,
|
|
3093
|
+
# started_at, primary_model}}
|
|
3094
|
+
sessions: dict[str, dict] = {}
|
|
3095
|
+
# Aggregate scalars across the window.
|
|
3096
|
+
window_cost = 0.0
|
|
3097
|
+
window_input_t = 0
|
|
3098
|
+
window_output_t = 0
|
|
3099
|
+
|
|
3100
|
+
for row in entries_cur:
|
|
3101
|
+
(entry_id, ts_iso, model, input_tok, output_tok,
|
|
3102
|
+
cache_create, cache_read, cost_raw, source_path,
|
|
3103
|
+
session_id, project_path) = row
|
|
3104
|
+
if model == "<synthetic>":
|
|
3105
|
+
continue
|
|
3106
|
+
# No need to call _resolve_project_key here — the INNER JOIN
|
|
3107
|
+
# on _drill_paths already restricted the result set to entries
|
|
3108
|
+
# whose source_path belongs to this bucket.
|
|
3109
|
+
ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
|
|
3110
|
+
entry_cost = _calculate_entry_cost(
|
|
3111
|
+
model,
|
|
3112
|
+
{
|
|
3113
|
+
"input_tokens": input_tok or 0,
|
|
3114
|
+
"output_tokens": output_tok or 0,
|
|
3115
|
+
"cache_creation_input_tokens": cache_create or 0,
|
|
3116
|
+
"cache_read_input_tokens": cache_read or 0,
|
|
3117
|
+
},
|
|
3118
|
+
mode="auto",
|
|
3119
|
+
cost_usd=cost_raw,
|
|
3120
|
+
)
|
|
3121
|
+
window_cost += entry_cost
|
|
3122
|
+
window_input_t += int(input_tok or 0)
|
|
3123
|
+
window_output_t += int(output_tok or 0)
|
|
3124
|
+
|
|
3125
|
+
# Per-model rollup.
|
|
3126
|
+
m = models.get(model)
|
|
3127
|
+
if m is None:
|
|
3128
|
+
m = {
|
|
3129
|
+
"model": model,
|
|
3130
|
+
"cost_usd": 0.0,
|
|
3131
|
+
"sessions": set(),
|
|
3132
|
+
"tokens_input": 0,
|
|
3133
|
+
"tokens_output": 0,
|
|
3134
|
+
}
|
|
3135
|
+
models[model] = m
|
|
3136
|
+
m["cost_usd"] += entry_cost
|
|
3137
|
+
m["tokens_input"] += int(input_tok or 0)
|
|
3138
|
+
m["tokens_output"] += int(output_tok or 0)
|
|
3139
|
+
if session_id:
|
|
3140
|
+
m["sessions"].add(session_id)
|
|
3141
|
+
elif source_path:
|
|
3142
|
+
m["sessions"].add(source_path)
|
|
3143
|
+
|
|
3144
|
+
# Per-session rollup.
|
|
3145
|
+
sid = session_id or source_path
|
|
3146
|
+
if not sid:
|
|
3147
|
+
continue
|
|
3148
|
+
s = sessions.get(sid)
|
|
3149
|
+
if s is None:
|
|
3150
|
+
s = {
|
|
3151
|
+
"session_id": sid,
|
|
3152
|
+
"started_at": ts,
|
|
3153
|
+
"last_activity_at": ts,
|
|
3154
|
+
"primary_model": model,
|
|
3155
|
+
"cost_usd": 0.0,
|
|
3156
|
+
}
|
|
3157
|
+
sessions[sid] = s
|
|
3158
|
+
else:
|
|
3159
|
+
if ts < s["started_at"]:
|
|
3160
|
+
s["started_at"] = ts
|
|
3161
|
+
if ts > s["last_activity_at"]:
|
|
3162
|
+
s["last_activity_at"] = ts
|
|
3163
|
+
s["cost_usd"] += entry_cost
|
|
3164
|
+
|
|
3165
|
+
# Models desc by cost (ties broken by model name for stability).
|
|
3166
|
+
models_list = sorted(
|
|
3167
|
+
models.values(),
|
|
3168
|
+
key=lambda m: (-m["cost_usd"], m["model"]),
|
|
3169
|
+
)
|
|
3170
|
+
models_out = []
|
|
3171
|
+
for m in models_list:
|
|
3172
|
+
models_out.append({
|
|
3173
|
+
"model": m["model"],
|
|
3174
|
+
"cost_usd": m["cost_usd"],
|
|
3175
|
+
"sessions_count": len(m["sessions"]),
|
|
3176
|
+
"tokens_input": m["tokens_input"],
|
|
3177
|
+
"tokens_output": m["tokens_output"],
|
|
3178
|
+
})
|
|
3179
|
+
|
|
3180
|
+
# Sessions: top-5 by last_activity_at desc (per spec §5.3).
|
|
3181
|
+
sessions_sorted = sorted(
|
|
3182
|
+
sessions.values(),
|
|
3183
|
+
key=lambda s: s["last_activity_at"],
|
|
3184
|
+
reverse=True,
|
|
3185
|
+
)
|
|
3186
|
+
sessions_out = []
|
|
3187
|
+
for s in sessions_sorted[:5]:
|
|
3188
|
+
sessions_out.append({
|
|
3189
|
+
"session_id": s["session_id"],
|
|
3190
|
+
"started_at": _iso_z(s["started_at"]),
|
|
3191
|
+
"last_activity_at": _iso_z(s["last_activity_at"]),
|
|
3192
|
+
"primary_model": s["primary_model"],
|
|
3193
|
+
"cost_usd": s["cost_usd"],
|
|
3194
|
+
})
|
|
3195
|
+
|
|
3196
|
+
# window_attributed_pct: prefer the trend projection (already
|
|
3197
|
+
# computed correctly). Sum across weeks within the requested
|
|
3198
|
+
# ``weeks_back`` window; None when all contributing weeks lack
|
|
3199
|
+
# snapshots.
|
|
3200
|
+
#
|
|
3201
|
+
# IMPORTANT: when the HTTP path reuses ``snap.projects_envelope``
|
|
3202
|
+
# (built by the sync thread with ``weeks_back=12``),
|
|
3203
|
+
# ``matching_trend["weekly_pct"]`` is always a 12-element array even
|
|
3204
|
+
# when the drill is requested with ``?weeks=1|4|8``. Slice to the
|
|
3205
|
+
# trailing ``weeks_back`` entries — same pattern as
|
|
3206
|
+
# ``snapshot_to_share_envelope`` at line 1629 — so the answer doesn't
|
|
3207
|
+
# depend on whether the envelope was rebuilt or reused.
|
|
3208
|
+
win_pct: "float | None" = None
|
|
3209
|
+
if matching_trend is not None:
|
|
3210
|
+
weekly_pct_arr = matching_trend.get("weekly_pct") or []
|
|
3211
|
+
n = len(weekly_pct_arr)
|
|
3212
|
+
take = min(weeks_back, n) if n > 0 else 0
|
|
3213
|
+
sliced = weekly_pct_arr[-take:] if take > 0 else []
|
|
3214
|
+
wp = [p for p in sliced if p is not None]
|
|
3215
|
+
if wp:
|
|
3216
|
+
win_pct = sum(wp)
|
|
3217
|
+
|
|
3218
|
+
# Best-effort cleanup of the per-call TEMP TABLE so a reused conn
|
|
3219
|
+
# doesn't carry path state into the next drill (tests share conns;
|
|
3220
|
+
# production HTTP closes the conn so this is a no-op there).
|
|
3221
|
+
try:
|
|
3222
|
+
conn.execute("DROP TABLE IF EXISTS temp._drill_paths")
|
|
3223
|
+
except sqlite3.Error:
|
|
3224
|
+
pass
|
|
3225
|
+
|
|
3226
|
+
return {
|
|
3227
|
+
"key": project_key,
|
|
3228
|
+
"bucket_path": bucket_path,
|
|
3229
|
+
"window_weeks": weeks_back,
|
|
3230
|
+
"window_cost_usd": window_cost,
|
|
3231
|
+
"window_attributed_pct": win_pct,
|
|
3232
|
+
"models": models_out,
|
|
3233
|
+
"sessions": sessions_out,
|
|
3234
|
+
"models_total": len(models_out),
|
|
3235
|
+
"sessions_total": len(sessions),
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
|
|
3239
|
+
# Test-surface impl. The HTTP handler `_handle_get_project_detail`
|
|
3240
|
+
# delegates here so unit tests can exercise the path parser + dispatch
|
|
3241
|
+
# logic without spinning up a real server. ``handler`` is anything that
|
|
3242
|
+
# exposes ``self.path`` (the URL path + query), ``send_response``,
|
|
3243
|
+
# ``send_header``, ``end_headers``, ``send_error``, and ``wfile.write``
|
|
3244
|
+
# — that's the BaseHTTPRequestHandler surface plus the
|
|
3245
|
+
# ``test_dashboard_project_endpoint._FakeHandler`` stand-in.
|
|
3246
|
+
def _handle_get_project_detail_impl(handler, *,
|
|
3247
|
+
conn: "sqlite3.Connection") -> None:
|
|
3248
|
+
"""Shared impl for ``GET /api/project/<key>?weeks=N``.
|
|
3249
|
+
|
|
3250
|
+
Parses the percent-decoded key + the ``weeks`` query param,
|
|
3251
|
+
validates ``weeks ∈ {1, 4, 8, 12}``, then delegates to
|
|
3252
|
+
``_project_detail_for_window``. 400 on missing/invalid weeks,
|
|
3253
|
+
404 on unknown key, 200 with the detail JSON otherwise.
|
|
3254
|
+
"""
|
|
3255
|
+
import urllib.parse as _urlparse
|
|
3256
|
+
raw_path = handler.path
|
|
3257
|
+
path_only, sep, query_str = raw_path.partition("?")
|
|
3258
|
+
# Strip the route prefix; what remains is the percent-encoded key.
|
|
3259
|
+
raw_key = path_only[len("/api/project/"):]
|
|
3260
|
+
project_key = _urlparse.unquote(raw_key)
|
|
3261
|
+
# Parse `weeks` from the query string.
|
|
3262
|
+
query = _urlparse.parse_qs(query_str)
|
|
3263
|
+
weeks_vals = query.get("weeks", [None])
|
|
3264
|
+
weeks_raw = weeks_vals[0] if weeks_vals else None
|
|
3265
|
+
try:
|
|
3266
|
+
weeks = int(weeks_raw) if weeks_raw is not None else None
|
|
3267
|
+
except (TypeError, ValueError):
|
|
3268
|
+
weeks = None
|
|
3269
|
+
if weeks not in {1, 4, 8, 12}:
|
|
3270
|
+
body = json.dumps({"error": "invalid weeks param"}).encode("utf-8")
|
|
3271
|
+
handler.send_response(400)
|
|
3272
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
3273
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
3274
|
+
handler.end_headers()
|
|
3275
|
+
handler.wfile.write(body)
|
|
3276
|
+
return
|
|
3277
|
+
|
|
3278
|
+
# ``now_utc`` should mirror the current snapshot's generated_at so
|
|
3279
|
+
# the endpoint stays consistent with the panel rows the user just
|
|
3280
|
+
# clicked through. Pull it from the snapshot when available;
|
|
3281
|
+
# otherwise fall back to _command_as_of (CCTALLY_AS_OF honored).
|
|
3282
|
+
snap = None
|
|
3283
|
+
try:
|
|
3284
|
+
snap_ref = getattr(handler, "snapshot_ref", None)
|
|
3285
|
+
if snap_ref is not None:
|
|
3286
|
+
snap = snap_ref.get()
|
|
3287
|
+
except Exception:
|
|
3288
|
+
snap = None
|
|
3289
|
+
if snap is not None and getattr(snap, "generated_at", None) is not None:
|
|
3290
|
+
now_utc = snap.generated_at
|
|
3291
|
+
else:
|
|
3292
|
+
now_utc = _command_as_of()
|
|
3293
|
+
current_week = getattr(snap, "current_week", None) if snap else None
|
|
3294
|
+
# Reuse the sync-thread-built envelope when available. The per-process
|
|
3295
|
+
# memo invalidates on every session_entries.id advance, so on an
|
|
3296
|
+
# active dashboard the drill would otherwise rebuild the envelope
|
|
3297
|
+
# from scratch on each click (~1-2s wasted). Plumbing it through
|
|
3298
|
+
# skips that work; tests don't set ``projects_envelope`` on the
|
|
3299
|
+
# fake snapshot and fall back to the legacy build path.
|
|
3300
|
+
projects_envelope = getattr(snap, "projects_envelope", None) if snap else None
|
|
3301
|
+
|
|
3302
|
+
try:
|
|
3303
|
+
detail = _project_detail_for_window(
|
|
3304
|
+
conn,
|
|
3305
|
+
project_key=project_key,
|
|
3306
|
+
weeks_back=weeks,
|
|
3307
|
+
now_utc=now_utc,
|
|
3308
|
+
current_week=current_week,
|
|
3309
|
+
projects_envelope=projects_envelope,
|
|
3310
|
+
)
|
|
3311
|
+
except Exception as exc:
|
|
3312
|
+
handler.log_error("/api/project failed: %r", exc)
|
|
3313
|
+
body = json.dumps(
|
|
3314
|
+
{"error": "project detail failed"}
|
|
3315
|
+
).encode("utf-8")
|
|
3316
|
+
handler.send_response(500)
|
|
3317
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
3318
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
3319
|
+
handler.end_headers()
|
|
3320
|
+
handler.wfile.write(body)
|
|
3321
|
+
return
|
|
3322
|
+
if detail is None:
|
|
3323
|
+
body = json.dumps(
|
|
3324
|
+
{"error": "project not found", "key": project_key},
|
|
3325
|
+
).encode("utf-8")
|
|
3326
|
+
handler.send_response(404)
|
|
3327
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
3328
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
3329
|
+
handler.end_headers()
|
|
3330
|
+
handler.wfile.write(body)
|
|
3331
|
+
return
|
|
3332
|
+
body = json.dumps(detail, ensure_ascii=False).encode("utf-8")
|
|
3333
|
+
handler.send_response(200)
|
|
3334
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
3335
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
3336
|
+
handler.send_header("Cache-Control", "no-cache")
|
|
3337
|
+
handler.end_headers()
|
|
3338
|
+
handler.wfile.write(body)
|
|
3339
|
+
|
|
3340
|
+
|
|
2313
3341
|
def _empty_dashboard_snapshot() -> "DataSnapshot":
|
|
2314
3342
|
"""A minimal DataSnapshot used at startup before the first sync
|
|
2315
3343
|
completes. All panels render placeholders; the sync thread replaces
|
|
@@ -3188,12 +4216,26 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
3188
4216
|
"duration_min": int(round(s.duration_minutes)) if s.duration_minutes else 0,
|
|
3189
4217
|
"model": s.model_primary,
|
|
3190
4218
|
"project": s.project_label,
|
|
4219
|
+
# Projects-panel cross-nav identity (spec §4.1).
|
|
4220
|
+
# Disambiguated display_key matching the projects
|
|
4221
|
+
# envelope's `current_week.rows[].key`; ``None`` when
|
|
4222
|
+
# the projects envelope sub-build failed, the row's
|
|
4223
|
+
# project_path is missing, or the lookup didn't
|
|
4224
|
+
# resolve (the React cell falls back to plain text).
|
|
4225
|
+
"project_key": getattr(s, "project_key", None),
|
|
3191
4226
|
"cost_usd": round(s.cost_usd, 4) if s.cost_usd is not None else None,
|
|
3192
4227
|
}
|
|
3193
4228
|
for s in snap.sessions
|
|
3194
4229
|
],
|
|
3195
4230
|
},
|
|
3196
4231
|
|
|
4232
|
+
# Projects panel + modal envelope block (spec §5.2).
|
|
4233
|
+
# Populated on the sync thread; the serializer reads it back
|
|
4234
|
+
# unchanged so it stays a pure renderer (no DB I/O). ``None``
|
|
4235
|
+
# on first tick before sync completes; the client renders the
|
|
4236
|
+
# panel-empty state in that case.
|
|
4237
|
+
"projects": getattr(snap, "projects_envelope", None),
|
|
4238
|
+
|
|
3197
4239
|
# threshold-actions T5: see prelude above for rationale.
|
|
3198
4240
|
"alerts": alerts_array,
|
|
3199
4241
|
"alerts_settings": alerts_settings,
|
|
@@ -3361,6 +4403,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
3361
4403
|
self._serve_api_events()
|
|
3362
4404
|
elif path.startswith("/api/session/"):
|
|
3363
4405
|
self._handle_get_session_detail(path)
|
|
4406
|
+
elif path.startswith("/api/project/"):
|
|
4407
|
+
self._handle_get_project_detail()
|
|
3364
4408
|
elif path.startswith("/api/block/"):
|
|
3365
4409
|
self._handle_get_block_detail(path)
|
|
3366
4410
|
elif path == "/api/update/status":
|
|
@@ -4870,6 +5914,57 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4870
5914
|
self.end_headers()
|
|
4871
5915
|
self.wfile.write(body)
|
|
4872
5916
|
|
|
5917
|
+
def _handle_get_project_detail(self) -> None:
|
|
5918
|
+
"""Return ProjectDetail JSON for ``GET /api/project/<key>?weeks=N``
|
|
5919
|
+
(spec §5.3 / §6.5).
|
|
5920
|
+
|
|
5921
|
+
Opens the stats DB and ATTACHes cache.db so the shared builder
|
|
5922
|
+
sees both schemas off one conn (same contract the sync thread
|
|
5923
|
+
uses). Loopback bind + Origin parity is the entire auth
|
|
5924
|
+
surface — no CSRF needed for GETs.
|
|
5925
|
+
"""
|
|
5926
|
+
try:
|
|
5927
|
+
conn = open_db()
|
|
5928
|
+
except Exception as exc:
|
|
5929
|
+
self.log_error("/api/project open_db failed: %r", exc)
|
|
5930
|
+
self.send_error(500, "project detail failed")
|
|
5931
|
+
return
|
|
5932
|
+
try:
|
|
5933
|
+
try:
|
|
5934
|
+
c = _cctally()
|
|
5935
|
+
conn.execute(
|
|
5936
|
+
"ATTACH DATABASE ? AS cache_db",
|
|
5937
|
+
(str(c.CACHE_DB_PATH),),
|
|
5938
|
+
)
|
|
5939
|
+
conn.execute(
|
|
5940
|
+
"CREATE TEMP VIEW IF NOT EXISTS session_entries AS "
|
|
5941
|
+
"SELECT * FROM cache_db.session_entries"
|
|
5942
|
+
)
|
|
5943
|
+
conn.execute(
|
|
5944
|
+
"CREATE TEMP VIEW IF NOT EXISTS session_files AS "
|
|
5945
|
+
"SELECT * FROM cache_db.session_files"
|
|
5946
|
+
)
|
|
5947
|
+
except Exception as exc:
|
|
5948
|
+
# ATTACH/CREATE failure → fall back to the stats conn
|
|
5949
|
+
# alone; the builder will see no session_entries and
|
|
5950
|
+
# return None on key match. We still want to 500 here
|
|
5951
|
+
# because the dashboard contract is "should have both".
|
|
5952
|
+
self.log_error("/api/project ATTACH failed: %r", exc)
|
|
5953
|
+
self.send_error(500, "project detail failed")
|
|
5954
|
+
return
|
|
5955
|
+
_handle_get_project_detail_impl(self, conn=conn)
|
|
5956
|
+
finally:
|
|
5957
|
+
try:
|
|
5958
|
+
conn.execute("DROP VIEW IF EXISTS session_entries")
|
|
5959
|
+
conn.execute("DROP VIEW IF EXISTS session_files")
|
|
5960
|
+
except Exception:
|
|
5961
|
+
pass
|
|
5962
|
+
try:
|
|
5963
|
+
conn.execute("DETACH DATABASE cache_db")
|
|
5964
|
+
except Exception:
|
|
5965
|
+
pass
|
|
5966
|
+
conn.close()
|
|
5967
|
+
|
|
4873
5968
|
def _handle_get_block_detail(self, path: str) -> None:
|
|
4874
5969
|
"""Return BlockDetail JSON for the block whose start_time equals
|
|
4875
5970
|
the URL-encoded ISO-8601 UTC datetime in the path tail.
|