cctally 1.97.0 → 1.98.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.
@@ -55,6 +55,8 @@ from _cctally_core import (
55
55
  )
56
56
  from _lib_dashboard_sources import (
57
57
  SOURCE_SCHEMA_VERSION,
58
+ canonical_alerted_at as _canonical_alerted_at,
59
+ canonical_alerted_at_sql as _canonical_alerted_at_sql,
58
60
  dashboard_resource_key as _dashboard_resource_key,
59
61
  )
60
62
  from _lib_display_tz import _compute_display_block, format_display_dt
@@ -361,6 +363,16 @@ def _alert_account_resolver(conn: sqlite3.Connection):
361
363
  return fields
362
364
 
363
365
 
366
+ # #556 S3 §2.3. Every per-axis mapper orders by the CANONICAL firing instant
367
+ # before its ``LIMIT``, because that limit decides which rows exist downstream
368
+ # and a row excluded there is unrecoverable. Two spellings of one instant
369
+ # compare equal here; a nonzero offset — which the ``all-combined`` fixture
370
+ # stores deliberately — orders by its true instant rather than by its
371
+ # local-time text.
372
+ _CANON_ALERTED_AT = _canonical_alerted_at_sql()
373
+ _CANON_ALERTED_AT_M = _canonical_alerted_at_sql("m.alerted_at")
374
+
375
+
364
376
  def _envelope_rows_weekly(
365
377
  conn, descriptor, limit, severity_for, account_fields,
366
378
  ) -> list[dict]:
@@ -377,7 +389,7 @@ def _envelope_rows_weekly(
377
389
  alerted_at, cumulative_cost_usd, reset_event_id, account_key
378
390
  FROM {descriptor.milestone_table}
379
391
  WHERE alerted_at IS NOT NULL
380
- ORDER BY alerted_at DESC
392
+ ORDER BY {_CANON_ALERTED_AT} DESC
381
393
  LIMIT ?
382
394
  """,
383
395
  (limit,),
@@ -431,7 +443,7 @@ def _envelope_rows_five_hour(
431
443
  ON b.five_hour_window_key = m.five_hour_window_key
432
444
  AND b.account_key = m.account_key
433
445
  WHERE m.alerted_at IS NOT NULL
434
- ORDER BY m.alerted_at DESC
446
+ ORDER BY {_CANON_ALERTED_AT_M} DESC
435
447
  LIMIT ?
436
448
  """,
437
449
  (limit,),
@@ -506,7 +518,7 @@ def _envelope_rows_budget_family(
506
518
  budget_usd, spent_usd, consumption_pct, account_key
507
519
  FROM {descriptor.milestone_table}
508
520
  WHERE vendor = ? AND alerted_at IS NOT NULL
509
- ORDER BY alerted_at DESC
521
+ ORDER BY {_CANON_ALERTED_AT} DESC
510
522
  LIMIT ?
511
523
  """,
512
524
  (default_noun, vendor, limit),
@@ -572,7 +584,7 @@ def _envelope_rows_projected(
572
584
  denominator, crossed_at_utc, alerted_at, account_key
573
585
  FROM {descriptor.milestone_table}
574
586
  WHERE alerted_at IS NOT NULL
575
- ORDER BY alerted_at DESC
587
+ ORDER BY {_CANON_ALERTED_AT} DESC
576
588
  LIMIT ?
577
589
  """,
578
590
  (limit,),
@@ -629,7 +641,7 @@ def _envelope_rows_project_budget(
629
641
  consumption_pct, crossed_at_utc, alerted_at, account_key
630
642
  FROM {descriptor.milestone_table}
631
643
  WHERE alerted_at IS NOT NULL
632
- ORDER BY alerted_at DESC
644
+ ORDER BY {_CANON_ALERTED_AT} DESC
633
645
  LIMIT ?
634
646
  """,
635
647
  (limit,),
@@ -858,11 +870,29 @@ def _build_alerts_envelope_array(
858
870
  ))
859
871
 
860
872
  # Python's list.sort is stable. When two alerts share the same
861
- # `alerted_at` ISO string (rare; multiple axes firing within the same
862
- # millisecond), the union order (weekly, then 5h, then budget, then
873
+ # `alerted_at` instant (rare; multiple axes firing within the same
874
+ # second), the union order (weekly, then 5h, then budget, then
863
875
  # projected) determines the tiebreaker — no extra deterministic key is
864
876
  # added because the spec doesn't require one.
865
- out.sort(key=lambda a: a["alerted_at"], reverse=True)
877
+ #
878
+ # #556 S3 §2.3: the slice below is a truncation, so this re-sort compares
879
+ # instants rather than spellings. Each axis reaches here already ordered by
880
+ # its own canonical SQL expression, but the axes are merged raw, so two
881
+ # differently-spelled instants meet for the first time right here.
882
+ def _order_key(alert):
883
+ # A raise here empties the whole legacy array AND the Claude projection
884
+ # derived from it, so the diagnostic must name the offending row the way
885
+ # `_combined_alert_rows` does — otherwise the operator sees an empty
886
+ # panel and a message that identifies nothing.
887
+ try:
888
+ return _canonical_alerted_at(alert["alerted_at"])
889
+ except (KeyError, ValueError) as exc:
890
+ identity = alert.get("key") or alert.get("id")
891
+ raise ValueError(
892
+ f"alert row {identity!r} (axis {alert.get('axis')!r}): {exc}"
893
+ ) from exc
894
+
895
+ out.sort(key=_order_key, reverse=True)
866
896
  return out[:limit]
867
897
 
868
898
 
@@ -1270,21 +1300,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1270
1300
  }
1271
1301
 
1272
1302
  def _daily_row_to_dict(r: "DailyPanelRow") -> dict:
1273
- return {
1274
- "date": r.date,
1275
- "label": r.label,
1276
- "cost_usd": r.cost_usd,
1277
- "is_today": r.is_today,
1278
- "intensity_bucket": r.intensity_bucket,
1279
- "models": list(r.models),
1280
- # ---- v2.3 additions ----
1281
- "input_tokens": r.input_tokens,
1282
- "output_tokens": r.output_tokens,
1283
- "cache_creation_tokens": r.cache_creation_tokens,
1284
- "cache_read_tokens": r.cache_read_tokens,
1285
- "total_tokens": r.total_tokens,
1286
- "cache_hit_pct": r.cache_hit_pct,
1287
- }
1303
+ # #556 S2 §6.3a: one owner for the daily row wire shape. The All-only
1304
+ # `periods.daily_aggregate.rows` sibling publishes the same shape, and
1305
+ # the client reads one shape whichever sibling produced it, so the
1306
+ # dict is built in `_cctally_dashboard` and this renderer delegates.
1307
+ return sys.modules["_cctally_dashboard"].daily_panel_row_to_wire(r)
1288
1308
 
1289
1309
  # Spec §2.7: empty state is `weekly.rows === []`, not `weekly === null`.
1290
1310
  # Always emit a `{rows: [...]}` envelope (possibly empty) so the panel
@@ -37,6 +37,8 @@ from _lib_dashboard_sources import (
37
37
  SourceDashboardState,
38
38
  SourceDashboardWarning,
39
39
  assess_codex_projection_coherence,
40
+ canonical_alerted_at,
41
+ canonical_alerted_at_sql,
40
42
  dashboard_resource_key,
41
43
  )
42
44
  from _lib_quota import (
@@ -860,6 +862,33 @@ _RESOURCE_ROWS = {
860
862
  "block": ("quota", "blocks"),
861
863
  }
862
864
 
865
+ # #556 S2: additional collections a resource may ALSO be routed through.
866
+ #
867
+ # The primary above stays the capability gate — its absence is still
868
+ # `SourceCapabilityUnavailable`. These are searched only after the primary
869
+ # misses, and their own absence is an ordinary not-found rather than a
870
+ # capability failure, because a provider legitimately need not publish them (a
871
+ # Codex source has no aggregate sibling, and a Claude source whose bounded fold
872
+ # failed publishes none either).
873
+ #
874
+ # Projects needs one because `projects.rows` is the current SUBSCRIPTION WEEK
875
+ # while `projects.aggregate.rows` is folded over the thirty-day shared range.
876
+ # Without this the aggregate ranking would publish rows the drill-down route
877
+ # answers 404 for — in the committed `all-combined` fixture, four of six.
878
+ _RESOURCE_EXTRA_ROWS: Mapping[str, tuple[tuple[str, ...], ...]] = MappingProxyType({
879
+ "project": (("projects", "aggregate", "rows"),),
880
+ })
881
+
882
+
883
+ def _rows_at(data: Mapping, path: "tuple[str, ...]") -> "list | tuple":
884
+ """Read one optional nested rows collection, or an empty tuple."""
885
+ node: object = data
886
+ for step in path:
887
+ if not isinstance(node, Mapping):
888
+ return ()
889
+ node = node.get(step)
890
+ return node if isinstance(node, (list, tuple)) else ()
891
+
863
892
 
864
893
  def _public_copy(value: object) -> object:
865
894
  """Detach a bounded source row from its immutable published state."""
@@ -910,6 +939,21 @@ def source_detail_lookup(
910
939
  row for row in rows
911
940
  if isinstance(row, Mapping) and row.get("key") == key
912
941
  ]
942
+ if not key_matches:
943
+ # Only after the primary collection misses, so every key that resolves
944
+ # today keeps resolving to the same row. The account-ownership branch
945
+ # below does NOT see an unchanged candidate set — a key that resolves
946
+ # only here reaches it as a row the primary collection never carried.
947
+ # That leaks nothing, because an aggregate row carries no
948
+ # `account_key` and the ownership check refuses a row it cannot
949
+ # attribute, but the set is genuinely wider than it was.
950
+ for path in _RESOURCE_EXTRA_ROWS.get(resource, ()):
951
+ key_matches = [
952
+ row for row in _rows_at(data, path)
953
+ if isinstance(row, Mapping) and row.get("key") == key
954
+ ]
955
+ if key_matches:
956
+ break
913
957
  if not key_matches:
914
958
  raise SourceResourceNotFound()
915
959
  if account is not None:
@@ -2430,6 +2474,12 @@ def refresh_codex_source_clock(
2430
2474
  # combined figure fail closed on an idle tick that changed nothing else.
2431
2475
  account_scope=state.account_scope,
2432
2476
  private_session_labels=state.private_session_labels,
2477
+ # #556 S2 §3.6: the aggregate carrier travels with the rows it
2478
+ # describes. This clock refreshes presentation axes only and publishes
2479
+ # the SAME rows, so it must carry the range that bounded them —
2480
+ # dropping it here would withhold the aggregate on any idle tick whose
2481
+ # clock moved.
2482
+ aggregate_scope=state.aggregate_scope,
2433
2483
  )
2434
2484
  return state if refreshed_state == state else refreshed_state
2435
2485
 
@@ -2475,39 +2525,55 @@ def _alerts_wire(
2475
2525
  "accountLabel": label,
2476
2526
  }
2477
2527
 
2528
+ # #556 S3 §2.1/§2.4: the firing instant is what this wire filters on, what
2529
+ # the panel orders by and what it prints, so it is what every leg selects,
2530
+ # orders by and publishes. Two legs previously ordered, truncated and
2531
+ # published the CROSSING instant instead, which is a different moment: a
2532
+ # row that fired most recently but crossed longest ago was dropped at the
2533
+ # LIMIT and never reached the panel at all. `created_at` stays as an
2534
+ # equal-valued compatibility alias for a client reading a pre-v7 envelope.
2535
+ canon = canonical_alerted_at_sql()
2536
+
2537
+ def _instants(raw: object) -> dict[str, str]:
2538
+ value = canonical_alerted_at(raw)
2539
+ return {"alerted_at": value, "created_at": value}
2540
+
2478
2541
  try:
2479
- for period, threshold, consumption_pct, crossed_at, account_key in stats_conn.execute(
2480
- "SELECT period, threshold, consumption_pct, crossed_at_utc, account_key "
2542
+ for period, threshold, consumption_pct, crossed_at, alerted_at, account_key in stats_conn.execute(
2543
+ "SELECT period, threshold, consumption_pct, crossed_at_utc, alerted_at, account_key "
2481
2544
  "FROM budget_milestones WHERE vendor='codex' AND alerted_at IS NOT NULL "
2482
- "ORDER BY crossed_at_utc DESC, threshold DESC LIMIT ?",
2545
+ f"ORDER BY {canon} DESC, threshold DESC LIMIT ?",
2483
2546
  (SOURCE_HISTORY_LIMIT,),
2484
2547
  ):
2485
2548
  rows.append({
2549
+ # The resource key keeps the crossing instant it has always
2550
+ # carried: it is an opaque identity, and re-keying every
2551
+ # historical Codex alert row is not this session's change.
2486
2552
  "key": dashboard_resource_key("alert", "codex", "codex_budget", period, threshold, crossed_at),
2487
2553
  "source": "codex",
2488
2554
  "axis": "codex_budget", "period": period, "threshold": threshold,
2489
- "value": consumption_pct, "created_at": crossed_at,
2555
+ "value": consumption_pct, **_instants(alerted_at),
2490
2556
  **_account(account_key),
2491
2557
  })
2492
- for period, threshold, projected_value, crossed_at, account_key in stats_conn.execute(
2493
- "SELECT period, threshold, projected_value, crossed_at_utc, account_key "
2558
+ for period, threshold, projected_value, crossed_at, alerted_at, account_key in stats_conn.execute(
2559
+ "SELECT period, threshold, projected_value, crossed_at_utc, alerted_at, account_key "
2494
2560
  "FROM projected_milestones WHERE metric='codex_budget_usd' AND alerted_at IS NOT NULL "
2495
- "ORDER BY crossed_at_utc DESC, threshold DESC LIMIT ?",
2561
+ f"ORDER BY {canon} DESC, threshold DESC LIMIT ?",
2496
2562
  (SOURCE_HISTORY_LIMIT,),
2497
2563
  ):
2498
2564
  rows.append({
2499
2565
  "key": dashboard_resource_key("alert", "codex", "projected", period, threshold, crossed_at),
2500
2566
  "source": "codex",
2501
2567
  "axis": "projected", "period": period, "threshold": threshold,
2502
- "value": projected_value, "created_at": crossed_at,
2568
+ "value": projected_value, **_instants(alerted_at),
2503
2569
  **_account(account_key),
2504
2570
  })
2505
2571
  for (root_key, logical_key, observed_slot, window_minutes, resets_at,
2506
- threshold, severity, created_at, account_key) in stats_conn.execute(
2572
+ threshold, severity, created_at, alerted_at, account_key) in stats_conn.execute(
2507
2573
  "SELECT source_root_key, logical_limit_key, observed_slot, window_minutes, resets_at_utc, "
2508
- "threshold, severity, created_at_utc, account_key FROM quota_threshold_events "
2574
+ "threshold, severity, created_at_utc, alerted_at, account_key FROM quota_threshold_events "
2509
2575
  "WHERE source='codex' AND disposition='alerted' AND orphaned_at IS NULL "
2510
- "ORDER BY created_at_utc DESC, source_root_key, logical_limit_key, observed_slot, threshold "
2576
+ f"ORDER BY {canon} DESC, source_root_key, logical_limit_key, observed_slot, threshold "
2511
2577
  "LIMIT ?",
2512
2578
  (SOURCE_HISTORY_LIMIT,),
2513
2579
  ):
@@ -2518,14 +2584,14 @@ def _alerts_wire(
2518
2584
  ),
2519
2585
  "source": "codex",
2520
2586
  "axis": "quota", "threshold": threshold, "severity": severity,
2521
- "created_at": created_at,
2587
+ **_instants(alerted_at),
2522
2588
  **_account(account_key),
2523
2589
  })
2524
2590
  except sqlite3.Error:
2525
2591
  return ()
2526
2592
  return tuple(sorted(
2527
2593
  rows,
2528
- key=lambda item: str(item.get("created_at") or ""),
2594
+ key=lambda item: canonical_alerted_at(item["alerted_at"]),
2529
2595
  reverse=True,
2530
2596
  )[:SOURCE_HISTORY_LIMIT])
2531
2597