cctally 1.108.0 → 1.110.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 +147 -0
- package/README.md +6 -6
- package/bin/_cctally_account.py +4 -0
- package/bin/_cctally_alerts.py +6 -2
- package/bin/_cctally_cache.py +1652 -166
- package/bin/_cctally_config.py +3 -2
- package/bin/_cctally_core.py +594 -33
- package/bin/_cctally_dashboard.py +1585 -201
- package/bin/_cctally_dashboard_conversation.py +494 -48
- package/bin/_cctally_dashboard_envelope.py +167 -11
- package/bin/_cctally_dashboard_share.py +154 -36
- package/bin/_cctally_dashboard_sources.py +2615 -422
- package/bin/_cctally_db.py +678 -5
- package/bin/_cctally_diagnosis_sources.py +238 -26
- package/bin/_cctally_doctor.py +99 -18
- package/bin/_cctally_five_hour.py +1295 -40
- package/bin/_cctally_forecast.py +124 -28
- package/bin/_cctally_journal.py +408 -38
- package/bin/_cctally_milestone_history.py +150 -14
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_percent_breakdown.py +6 -1
- package/bin/_cctally_project.py +48 -76
- package/bin/_cctally_quota.py +39 -7
- package/bin/_cctally_quota_model.py +17 -2
- package/bin/_cctally_record.py +1647 -443
- package/bin/_cctally_rederive.py +899 -30
- package/bin/_cctally_refresh.py +2 -1
- package/bin/_cctally_share.py +24 -5
- package/bin/_cctally_source_analytics.py +1035 -78
- package/bin/_cctally_statusline.py +83 -19
- package/bin/_cctally_store.py +61 -4
- package/bin/_cctally_transcript.py +27 -2
- package/bin/_cctally_tui.py +264 -76
- package/bin/_cctally_weekrefs.py +28 -5
- package/bin/_lib_blocks.py +127 -20
- package/bin/_lib_cache_report.py +20 -5
- package/bin/_lib_codex_conversation_query.py +297 -64
- package/bin/_lib_codex_hooks.py +102 -0
- package/bin/_lib_codex_metadata.py +207 -0
- package/bin/_lib_conversation.py +119 -0
- package/bin/_lib_conversation_dispatch.py +31 -1
- package/bin/_lib_conversation_query.py +315 -51
- package/bin/_lib_conversation_retention.py +413 -25
- package/bin/_lib_dashboard_sources.py +267 -7
- package/bin/_lib_diagnosis.py +10 -0
- package/bin/_lib_diff_kernel.py +12 -1
- package/bin/_lib_doctor.py +230 -8
- package/bin/_lib_ingest_frontier.py +1366 -49
- package/bin/_lib_journal.py +19 -2
- package/bin/_lib_merge_gate.py +307 -0
- package/bin/_lib_pricing.py +588 -29
- package/bin/_lib_record.py +130 -12
- package/bin/_lib_rederive.py +903 -2
- package/bin/_lib_render.py +9 -3
- package/bin/_lib_retained_size.py +27 -2
- package/bin/_lib_segment_summary.py +66 -8
- package/bin/_lib_share_templates.py +28 -18
- package/bin/_lib_snapshot_cache.py +115 -1
- package/bin/_lib_source_retry.py +320 -0
- package/bin/_lib_statusline_candidates.py +400 -36
- package/bin/_lib_tick_stats.py +119 -0
- package/bin/_lib_view_models.py +256 -76
- package/bin/cctally +69 -0
- package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
- package/dashboard/static/assets/{DoctorModal-CWn3U4Wl.js → DoctorModal-DFY2iPDf.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
- package/dashboard/static/assets/ProjectsDrillPanel-DAv39uSo.js +1 -0
- package/dashboard/static/assets/SourceDetailModal-DSAWo2Fp.js +1 -0
- package/dashboard/static/assets/{UpdateModal-D3m8GG6V.js → UpdateModal-R_9dOVnh.js} +3 -3
- package/dashboard/static/assets/dashboardStream.shared-worker-DbG6Ef0T.js +1 -0
- package/dashboard/static/assets/index-COhxe3vU.css +1 -0
- package/dashboard/static/assets/index-DdU62sov.js +13 -0
- package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
- package/dashboard/static/assets/useKeymap-CW7Up0Ay.js +1 -0
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +4 -1
- package/dashboard/static/assets/ConversationsView-BOSaBRtu.js +0 -72
- package/dashboard/static/assets/ModalRoot-SR070V6c.js +0 -1
- package/dashboard/static/assets/ProjectsDrillPanel-QLI9i5mZ.js +0 -1
- package/dashboard/static/assets/SourceDetailModal-pv0wlFxl.js +0 -1
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +0 -1
- package/dashboard/static/assets/index-BHu4mxd8.css +0 -1
- package/dashboard/static/assets/index-CIWsbux3.js +0 -13
- package/dashboard/static/assets/outlineNavigation-CJvKqmLV.js +0 -9
- package/dashboard/static/assets/useKeymap-ffqsS5G0.js +0 -1
|
@@ -172,12 +172,16 @@ def _select_current_block_for_envelope(
|
|
|
172
172
|
*,
|
|
173
173
|
current_used_pct: "float | None",
|
|
174
174
|
now_utc: "dt.datetime",
|
|
175
|
+
account_key: "str | None" = None,
|
|
175
176
|
) -> "dict | None":
|
|
176
177
|
"""Select the current 5h block for the dashboard's current_week envelope.
|
|
177
178
|
|
|
178
179
|
Selection rule (spec §4.1): pick the row from ``five_hour_blocks`` whose
|
|
179
180
|
``five_hour_window_key`` matches the latest ``weekly_usage_snapshots``
|
|
180
|
-
row's ``five_hour_window_key``.
|
|
181
|
+
row's ``five_hour_window_key``. When ``account_key`` names the account
|
|
182
|
+
whose weekly percentage is displayed, all physical reads here use it;
|
|
183
|
+
a multi-account caller without that identity receives no block. Returns
|
|
184
|
+
None when no row matches —
|
|
181
185
|
binding to the latest snapshot's key (rather than highest
|
|
182
186
|
``block_start_at``) prevents the panel's "current 5h session" copy from
|
|
183
187
|
surfacing stale closed blocks.
|
|
@@ -211,7 +215,10 @@ def _select_current_block_for_envelope(
|
|
|
211
215
|
|
|
212
216
|
Delta semantics:
|
|
213
217
|
- Non-crossed block: ``current_used_pct - seven_day_pct_at_block_start``
|
|
214
|
-
(the natural "how much 7d% has changed during this 5h block" read)
|
|
218
|
+
(the natural "how much 7d% has changed during this 5h block" read), where
|
|
219
|
+
that axis is the CREDIT-AWARE read of the column rather than the stored
|
|
220
|
+
number (#834 S1, #835) — so a value an in-place weekly credit retired makes
|
|
221
|
+
both the published axis and the delta ``None``.
|
|
215
222
|
- Crossed block (``crossed_seven_day_reset == 1``): the block straddles
|
|
216
223
|
a weekly reset, so the natural delta would be dominated by the
|
|
217
224
|
reset itself (e.g. −94pp) rather than the user's actual burn-rate.
|
|
@@ -222,36 +229,100 @@ def _select_current_block_for_envelope(
|
|
|
222
229
|
- ``None`` only when ``current_used_pct`` is unknown OR the
|
|
223
230
|
block-start anchor is missing AND no post-reset anchor was found.
|
|
224
231
|
"""
|
|
232
|
+
# HELD-INCLUSIVE, deliberately (#769 S11, #824). This read asks which
|
|
233
|
+
# five-hour window is current, and a `weekly_observation_held` row's
|
|
234
|
+
# five-hour fields are the writing tick's own — it is the freshest
|
|
235
|
+
# statement of that key. Its `week_start_at` is a byte copy of the
|
|
236
|
+
# non-held row it was derived from, so scoping the post-reset lookup
|
|
237
|
+
# below by it is unaffected.
|
|
225
238
|
snap = conn.execute(
|
|
226
239
|
"""
|
|
227
240
|
SELECT five_hour_window_key, week_start_at
|
|
228
241
|
FROM weekly_usage_snapshots
|
|
229
242
|
WHERE captured_at_utc <= ?
|
|
243
|
+
AND (? IS NULL OR account_key = ?)
|
|
230
244
|
ORDER BY captured_at_utc DESC, id DESC
|
|
231
245
|
LIMIT 1
|
|
232
246
|
""",
|
|
233
|
-
(_iso_z(now_utc),),
|
|
247
|
+
(_iso_z(now_utc), account_key, account_key),
|
|
234
248
|
).fetchone()
|
|
235
249
|
if snap is None or snap["five_hour_window_key"] is None:
|
|
236
250
|
return None
|
|
237
251
|
|
|
252
|
+
# The account supplied by `_tui_build_current_week` is the owner of the
|
|
253
|
+
# displayed weekly percentage. On a decorated multi-account store the
|
|
254
|
+
# absence of that identity is not permission to select an arbitrary block.
|
|
255
|
+
import _cctally_account
|
|
256
|
+
if account_key is None and _cctally_account.provider_is_decorated(
|
|
257
|
+
conn, "claude"):
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
# #834 S1 (#836): `id` and `account_key` are selected so the live
|
|
261
|
+
# current-week milestone read can name ONE account's block. Block uniqueness
|
|
262
|
+
# is `(account_key, five_hour_window_key)`, and the milestone read used to
|
|
263
|
+
# filter on the window key alone, so one physical window's per-account blocks
|
|
264
|
+
# all showed every account's milestones. Both land on the returned dict under
|
|
265
|
+
# a leading underscore. `_five_hour_block_wire` strips the selectors and
|
|
266
|
+
# publishes the account identity only above #341's R8 decoration threshold.
|
|
267
|
+
#
|
|
268
|
+
# Account-qualified selection binds the block and its downstream milestone
|
|
269
|
+
# read to the displayed weekly account; the unqualified order remains the
|
|
270
|
+
# historical <=1-real-account fallback for published byte stability.
|
|
271
|
+
# #834 S1 (#835) Gate A R8: `first_observed_at_utc` and `five_hour_resets_at`
|
|
272
|
+
# are selected because the credit-aware read below needs them — they are the
|
|
273
|
+
# START axis's capture instant and its fallback. `seven_day_pct_at_block_end`
|
|
274
|
+
# is deliberately NOT selected, and round three's comment claiming the
|
|
275
|
+
# credit-aware read needs it was wrong: this route publishes the start axis
|
|
276
|
+
# alone, the helper's second return value is discarded, and the helper tests
|
|
277
|
+
# each axis's stored value before resolving anything, so an absent end value
|
|
278
|
+
# skips a second week resolution and a second band set for a number nobody
|
|
279
|
+
# reads. `last_observed_at_utc` stays, and NOT because the helper needs it:
|
|
280
|
+
# every column the helper reads goes through `block.get(...)`, so dropping
|
|
281
|
+
# this one would raise nothing and change no published field — the end
|
|
282
|
+
# instant it feeds is discarded with the end axis. It stays because the
|
|
283
|
+
# helper's contract is a block ROW, and pruning this SELECT to the subset the
|
|
284
|
+
# helper happens to read today would couple this route to the helper's
|
|
285
|
+
# internals. Round four's comment implied necessity instead.
|
|
238
286
|
block = conn.execute(
|
|
239
287
|
"""
|
|
240
|
-
SELECT
|
|
288
|
+
SELECT id, account_key,
|
|
289
|
+
five_hour_window_key, block_start_at, five_hour_resets_at,
|
|
290
|
+
first_observed_at_utc, last_observed_at_utc,
|
|
241
291
|
seven_day_pct_at_block_start,
|
|
242
292
|
crossed_seven_day_reset
|
|
243
|
-
|
|
293
|
+
FROM five_hour_blocks
|
|
244
294
|
WHERE five_hour_window_key = ?
|
|
295
|
+
AND (? IS NULL OR account_key = ?)
|
|
245
296
|
AND is_closed = 0
|
|
246
297
|
AND five_hour_resets_at > ?
|
|
298
|
+
ORDER BY id ASC
|
|
299
|
+
LIMIT 1
|
|
247
300
|
""",
|
|
248
|
-
(snap["five_hour_window_key"],
|
|
301
|
+
(snap["five_hour_window_key"], account_key, account_key,
|
|
302
|
+
_iso_z(now_utc)),
|
|
249
303
|
).fetchone()
|
|
250
304
|
if block is None:
|
|
251
305
|
return None
|
|
252
306
|
|
|
253
307
|
crossed = bool(block["crossed_seven_day_reset"])
|
|
254
|
-
|
|
308
|
+
# #834 S1 (#835) Gate A R8: THE THIRD CONSUMER of the two derived weekly axes
|
|
309
|
+
# goes through the one read-time filter, the same one `cmd_five_hour_blocks` and
|
|
310
|
+
# `cmd_five_hour_breakdown` use. Reading the column raw published the value an
|
|
311
|
+
# in-place weekly credit retired, and `seven_day_pct_delta_pp` then published
|
|
312
|
+
# the difference between the live post-credit percentage and that retired
|
|
313
|
+
# number, which is a burn nobody observed. Measured on a store credited at 63.0
|
|
314
|
+
# with `current_used_pct` at 20.0: `seven_day_pct_at_block_start: 63.0` and
|
|
315
|
+
# `seven_day_pct_delta_pp: -43.0`, while the helper answered `(None, None)` for
|
|
316
|
+
# the same row. `sqlite3.Row` has no `.get()`, so the row is converted.
|
|
317
|
+
#
|
|
318
|
+
# Neither field is rendered by any client today: both are declared in
|
|
319
|
+
# `dashboard/web/src/types/envelope.ts` and used in one vitest module,
|
|
320
|
+
# `seven_day_pct_delta_pp` does not occur in the built bundle, and the TUI reads
|
|
321
|
+
# only `credits` / `five_hour_window_key` / `_account_key` / `_block_id` off
|
|
322
|
+
# this dict. This is therefore a correction to a published MACHINE surface, made
|
|
323
|
+
# because the marginal cost is one call site.
|
|
324
|
+
p_start, _p_end = sys.modules["cctally"]._credit_aware_block_weekly_axes(
|
|
325
|
+
conn, dict(block), now_utc=now_utc)
|
|
255
326
|
|
|
256
327
|
# When the block crossed a weekly reset, recompute the delta against
|
|
257
328
|
# the first post-reset snapshot inside the block instead of the
|
|
@@ -263,11 +334,19 @@ def _select_current_block_for_envelope(
|
|
|
263
334
|
# the lookup to the current weekly window.
|
|
264
335
|
p_anchor = p_start
|
|
265
336
|
if crossed and snap["week_start_at"] is not None:
|
|
337
|
+
# `weekly_observation_held = 0` (#769 S11, #824): this is a weekly
|
|
338
|
+
# value selected by a CAPTURE-TIME window, which is exactly the shape a
|
|
339
|
+
# held row corrupts. A held row can be the first row captured after the
|
|
340
|
+
# reset while its weekly value was copied from a row captured BEFORE
|
|
341
|
+
# it, so the block's delta would be anchored on a pre-reset percentage
|
|
342
|
+
# and report a burn nobody observed.
|
|
266
343
|
post = conn.execute(
|
|
267
344
|
"""
|
|
268
345
|
SELECT weekly_percent
|
|
269
|
-
|
|
346
|
+
FROM weekly_usage_snapshots
|
|
270
347
|
WHERE week_start_at = ?
|
|
348
|
+
AND (? IS NULL OR account_key = ?)
|
|
349
|
+
AND weekly_observation_held = 0
|
|
271
350
|
AND unixepoch(captured_at_utc) >= unixepoch(?)
|
|
272
351
|
AND unixepoch(captured_at_utc) <= unixepoch(?)
|
|
273
352
|
ORDER BY captured_at_utc ASC, id ASC
|
|
@@ -275,6 +354,7 @@ def _select_current_block_for_envelope(
|
|
|
275
354
|
""",
|
|
276
355
|
(
|
|
277
356
|
snap["week_start_at"],
|
|
357
|
+
account_key, account_key,
|
|
278
358
|
block["block_start_at"],
|
|
279
359
|
_iso_z(now_utc),
|
|
280
360
|
),
|
|
@@ -298,9 +378,10 @@ def _select_current_block_for_envelope(
|
|
|
298
378
|
SELECT effective_reset_at_utc, prior_percent, post_percent
|
|
299
379
|
FROM five_hour_reset_events
|
|
300
380
|
WHERE five_hour_window_key = ?
|
|
381
|
+
AND (? IS NULL OR account_key = ?)
|
|
301
382
|
ORDER BY effective_reset_at_utc ASC
|
|
302
383
|
""",
|
|
303
|
-
(int(block["five_hour_window_key"]),),
|
|
384
|
+
(int(block["five_hour_window_key"]), account_key, account_key),
|
|
304
385
|
).fetchall()
|
|
305
386
|
credits = [
|
|
306
387
|
{
|
|
@@ -321,9 +402,34 @@ def _select_current_block_for_envelope(
|
|
|
321
402
|
"seven_day_pct_delta_pp": delta,
|
|
322
403
|
"crossed_seven_day_reset": crossed,
|
|
323
404
|
"credits": credits,
|
|
405
|
+
# #834 S1 (#836): INTERNAL selector fields, stripped by
|
|
406
|
+
# `_five_hour_block_wire` before publication. See the block SELECT above.
|
|
407
|
+
"_block_id": int(block["id"]),
|
|
408
|
+
"_account_key": block["account_key"],
|
|
409
|
+
"_account_decorated": _cctally_account.provider_is_decorated(
|
|
410
|
+
conn, "claude"),
|
|
324
411
|
}
|
|
325
412
|
|
|
326
413
|
|
|
414
|
+
#: The selector fields retained for downstream reads. A rowid is meaningless to
|
|
415
|
+
#: a client; the account key is published only when #341's R8 gate permits
|
|
416
|
+
#: account decoration (more than one real Claude account).
|
|
417
|
+
_INTERNAL_BLOCK_KEYS = ("_block_id", "_account_key", "_account_decorated")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _five_hour_block_wire(block: "dict | None") -> "dict | None":
|
|
421
|
+
"""Publish the current block with R8-gated account identity.
|
|
422
|
+
|
|
423
|
+
Single-account output keeps the historical six-key shape. The caller's
|
|
424
|
+
dict retains its selectors for the live milestone read."""
|
|
425
|
+
if not isinstance(block, dict):
|
|
426
|
+
return block
|
|
427
|
+
wire = {k: v for k, v in block.items() if k not in _INTERNAL_BLOCK_KEYS}
|
|
428
|
+
if block.get("_account_decorated"):
|
|
429
|
+
wire["account_key"] = block["_account_key"]
|
|
430
|
+
return wire
|
|
431
|
+
|
|
432
|
+
|
|
327
433
|
# === Alerts-envelope per-axis row-mappers (Task F) =========================
|
|
328
434
|
# Each mapper turns one axis's ``alerted_at IS NOT NULL`` milestone rows into
|
|
329
435
|
# the shared envelope-item dicts. The SQL is genuinely heterogeneous per axis
|
|
@@ -766,6 +872,11 @@ def _unavailable_source_wire() -> dict:
|
|
|
766
872
|
"last_success_at": None,
|
|
767
873
|
"capabilities": {},
|
|
768
874
|
"data": None,
|
|
875
|
+
# #834 S2 (#828, #829): present and null, not absent. Every source
|
|
876
|
+
# entry within one schema version carries the same keys, so a client
|
|
877
|
+
# never has to branch on key presence to tell a v12 payload's honest
|
|
878
|
+
# "no evidence" from an older server's payload that has no such field.
|
|
879
|
+
"metadata_health": None,
|
|
769
880
|
}
|
|
770
881
|
|
|
771
882
|
|
|
@@ -812,6 +923,15 @@ def _source_state_to_wire(state: object) -> dict:
|
|
|
812
923
|
for name, capability in capabilities.items()
|
|
813
924
|
},
|
|
814
925
|
"data": _source_wire_value(getattr(state, "data", None)),
|
|
926
|
+
# #834 S2 (#828, #829): published from the state's carrier and from
|
|
927
|
+
# nowhere else, so the value the detail routes decide from and the
|
|
928
|
+
# value the client normalizes are the same object. `None` on a
|
|
929
|
+
# provider that describes no Codex metadata — and on every payload an
|
|
930
|
+
# older server produced, which is why the client normalizes an absent
|
|
931
|
+
# object to unknown rather than to healthy.
|
|
932
|
+
"metadata_health": _source_wire_value(
|
|
933
|
+
getattr(state, "metadata_health", None)
|
|
934
|
+
),
|
|
815
935
|
}
|
|
816
936
|
|
|
817
937
|
|
|
@@ -1320,6 +1440,27 @@ def _basis_presentation(basis):
|
|
|
1320
1440
|
return out or None
|
|
1321
1441
|
|
|
1322
1442
|
|
|
1443
|
+
def _trend_week_start_at(w) -> "str | None":
|
|
1444
|
+
"""``TuiTrendRow.week_start_at`` as canonical UTC ISO, or None.
|
|
1445
|
+
|
|
1446
|
+
#750 S4 §3.1. The row already carries the instant as a required
|
|
1447
|
+
`dt.datetime`; the envelope simply omitted it, so the client had only
|
|
1448
|
+
`label` to key on and a credited week's cycles can render one label.
|
|
1449
|
+
`getattr` tolerates the minimal trend shapes (SimpleNamespace test rows,
|
|
1450
|
+
older fixtures) the neighbouring fields already tolerate.
|
|
1451
|
+
"""
|
|
1452
|
+
value = getattr(w, "week_start_at", None)
|
|
1453
|
+
if value is None:
|
|
1454
|
+
return None
|
|
1455
|
+
if isinstance(value, str):
|
|
1456
|
+
return value
|
|
1457
|
+
try:
|
|
1458
|
+
return value.astimezone(dt.timezone.utc).isoformat().replace(
|
|
1459
|
+
"+00:00", "Z")
|
|
1460
|
+
except (AttributeError, TypeError, ValueError):
|
|
1461
|
+
return None
|
|
1462
|
+
|
|
1463
|
+
|
|
1323
1464
|
def _forecast_quota_envelope(fc, cw, rate_change):
|
|
1324
1465
|
"""Spec §10's optional typed `forecast.quota` object.
|
|
1325
1466
|
|
|
@@ -1756,7 +1897,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1756
1897
|
}
|
|
1757
1898
|
|
|
1758
1899
|
def _blocks_row_to_dict(r: "BlocksPanelRow") -> dict:
|
|
1759
|
-
|
|
1900
|
+
row = {
|
|
1760
1901
|
"start_at": r.start_at,
|
|
1761
1902
|
"end_at": r.end_at,
|
|
1762
1903
|
"anchor": r.anchor,
|
|
@@ -1765,6 +1906,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1765
1906
|
"models": list(r.models),
|
|
1766
1907
|
"label": r.label,
|
|
1767
1908
|
}
|
|
1909
|
+
# Additive only for retained rows: the client treats absence as
|
|
1910
|
+
# computed, preserving the pre-existing computed-row wire shape.
|
|
1911
|
+
if r.facts_source == "retained":
|
|
1912
|
+
row["facts_source"] = "retained"
|
|
1913
|
+
return row
|
|
1768
1914
|
|
|
1769
1915
|
def _daily_row_to_dict(r: "DailyPanelRow") -> dict:
|
|
1770
1916
|
# #556 S2 §6.3a: one owner for the daily row wire shape. The All-only
|
|
@@ -2157,7 +2303,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
2157
2303
|
# `_tui_build_current_week`. `getattr` with default keeps
|
|
2158
2304
|
# legacy fixture modules that construct TuiCurrentWeek
|
|
2159
2305
|
# directly (without the new field) compatible.
|
|
2160
|
-
|
|
2306
|
+
# #834 S1 (#836): the ONE publication site, so
|
|
2307
|
+
# `_five_hour_block_wire` is where the internal selector fields
|
|
2308
|
+
# are dropped and the wire shape stays byte-unchanged.
|
|
2309
|
+
"five_hour_block": _five_hour_block_wire(
|
|
2310
|
+
getattr(cw, "five_hour_block", None)),
|
|
2161
2311
|
"milestones": [
|
|
2162
2312
|
{
|
|
2163
2313
|
"percent": m.percent,
|
|
@@ -2222,6 +2372,10 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
2222
2372
|
"dollar_per_pct": w.dollars_per_percent,
|
|
2223
2373
|
"delta": w.delta_dpp,
|
|
2224
2374
|
"is_current": bool(w.is_current),
|
|
2375
|
+
# #750 S4 §3.1: the segment instant. `label` is
|
|
2376
|
+
# year-free and a credited week's cycles can render
|
|
2377
|
+
# the same one, so it cannot be a React key.
|
|
2378
|
+
"week_start_at": _trend_week_start_at(w),
|
|
2225
2379
|
# #264 S3: additive weekly cost (already on the row via
|
|
2226
2380
|
# build_trend_view); rendered by the Trend modal's Cost
|
|
2227
2381
|
# column. ``getattr`` (like ``project_key`` /
|
|
@@ -2241,6 +2395,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
2241
2395
|
"dollar_per_pct": w.dollars_per_percent,
|
|
2242
2396
|
"delta": w.delta_dpp,
|
|
2243
2397
|
"is_current": bool(w.is_current),
|
|
2398
|
+
# #750 S4 §3.1: the segment instant (see weeks[]).
|
|
2399
|
+
"week_start_at": _trend_week_start_at(w),
|
|
2244
2400
|
# #264 S3: additive weekly cost (see weeks[] above; same
|
|
2245
2401
|
# getattr tolerance for minimal/older trend shapes).
|
|
2246
2402
|
"cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
|
|
@@ -49,7 +49,6 @@ from _cctally_core import open_db, parse_iso_datetime
|
|
|
49
49
|
from _cctally_config import save_config, _load_config_unlocked
|
|
50
50
|
from _lib_fmt import stable_sum
|
|
51
51
|
from _lib_pricing import _calculate_entry_cost, claude_usage_dict
|
|
52
|
-
from _lib_five_hour import _canonical_5h_window_key
|
|
53
52
|
from _lib_display_tz import _resolve_tz, resolve_display_tz_name
|
|
54
53
|
|
|
55
54
|
|
|
@@ -525,48 +524,154 @@ def _share_per_day_per_project_for_range(
|
|
|
525
524
|
return out
|
|
526
525
|
|
|
527
526
|
|
|
527
|
+
def _share_block_ownership_context(range_start, range_end):
|
|
528
|
+
"""The canonical five-hour windows competing over ``[range_start,
|
|
529
|
+
range_end]``, as ``(windows, owner_key_by_block_start_epoch)``.
|
|
530
|
+
|
|
531
|
+
#751a: a block cannot decide what it owns without knowing what its
|
|
532
|
+
neighbours own, so the caller widens this range past what it displays
|
|
533
|
+
purely in order to exclude a predecessor's entries.
|
|
534
|
+
"""
|
|
535
|
+
import _lib_blocks
|
|
536
|
+
|
|
537
|
+
_selected, _overrides, canonical_intervals = (
|
|
538
|
+
sys.modules["cctally"]._load_recorded_five_hour_windows(
|
|
539
|
+
range_start, range_end,
|
|
540
|
+
)
|
|
541
|
+
)
|
|
542
|
+
windows = []
|
|
543
|
+
owner_key_by_start: dict[int, object] = {}
|
|
544
|
+
for anchor, (block_start, reset) in canonical_intervals.items():
|
|
545
|
+
windows.append(_lib_blocks.OwnedWindow(
|
|
546
|
+
key=anchor, start=block_start, reset=reset,
|
|
547
|
+
))
|
|
548
|
+
owner_key_by_start[int(block_start.timestamp())] = anchor
|
|
549
|
+
return windows, owner_key_by_start
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _share_entry_cost(entry) -> float:
|
|
553
|
+
usage = claude_usage_dict( # #195 chokepoint
|
|
554
|
+
input_tokens=entry.input_tokens,
|
|
555
|
+
output_tokens=entry.output_tokens,
|
|
556
|
+
cache_creation_tokens=entry.cache_creation_tokens,
|
|
557
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
558
|
+
cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
|
|
559
|
+
speed=getattr(entry, "speed", None),
|
|
560
|
+
)
|
|
561
|
+
return _calculate_entry_cost(
|
|
562
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _share_per_block_per_project_by_ownership(starts, iso_by_start_epoch):
|
|
567
|
+
"""Per-block per-project costs over the entries each block OWNS.
|
|
568
|
+
|
|
569
|
+
The rollup-table path above is the canonical source; this runs only
|
|
570
|
+
when that table is empty or unreadable. It used to sweep each block's
|
|
571
|
+
raw ``[start, start + 5h]`` interval through
|
|
572
|
+
``_share_all_projects_for_range``, which is an independent
|
|
573
|
+
reselection: two windows overlapping after a reset shift each claimed
|
|
574
|
+
the overlap entries, and the artifact's per-block per-project
|
|
575
|
+
breakdown double-counted them (#751a review P2-1).
|
|
576
|
+
|
|
577
|
+
Ownership is resolved once, by the one rule in
|
|
578
|
+
``_lib_blocks.partition_entries_by_owner``. A block with no canonical
|
|
579
|
+
row is heuristic, so it reads the leftovers — the entries no exact
|
|
580
|
+
window contains — inside its own interval, which is the grouping
|
|
581
|
+
pass's own treatment of them.
|
|
582
|
+
"""
|
|
583
|
+
import _lib_blocks
|
|
584
|
+
|
|
585
|
+
five_hours = dt.timedelta(hours=5)
|
|
586
|
+
earliest, latest = min(starts), max(starts)
|
|
587
|
+
out: dict[str, dict[str, float]] = {
|
|
588
|
+
iso: {} for iso in iso_by_start_epoch.values()
|
|
589
|
+
}
|
|
590
|
+
try:
|
|
591
|
+
windows, owner_key_by_start = _share_block_ownership_context(
|
|
592
|
+
earliest - five_hours, latest + 2 * five_hours,
|
|
593
|
+
)
|
|
594
|
+
# late-binding: reach the dashboard module object so both the ns-patch
|
|
595
|
+
# (share tests) and a dashboard-object patch (rebuild-parity) are
|
|
596
|
+
# honored (#279 S5 F1 / spec §3).
|
|
597
|
+
entries = list(
|
|
598
|
+
sys.modules["_cctally_dashboard"].get_claude_session_entries(
|
|
599
|
+
earliest, latest + 2 * five_hours, skip_sync=True,
|
|
600
|
+
)
|
|
601
|
+
)
|
|
602
|
+
except Exception:
|
|
603
|
+
return out
|
|
604
|
+
owned, leftovers = _lib_blocks.partition_entries_by_owner(entries, windows)
|
|
605
|
+
for start in starts:
|
|
606
|
+
block_iso = iso_by_start_epoch[int(start.timestamp())]
|
|
607
|
+
owner_key = owner_key_by_start.get(int(start.timestamp()))
|
|
608
|
+
if owner_key is None:
|
|
609
|
+
mine = [
|
|
610
|
+
e for e in leftovers
|
|
611
|
+
if start <= e.timestamp < start + five_hours
|
|
612
|
+
]
|
|
613
|
+
else:
|
|
614
|
+
mine = owned.get(owner_key, [])
|
|
615
|
+
bucket = out[block_iso]
|
|
616
|
+
for entry in mine:
|
|
617
|
+
key = entry.project_path or "(unknown)"
|
|
618
|
+
bucket[key] = bucket.get(key, 0.0) + _share_entry_cost(entry)
|
|
619
|
+
return out
|
|
620
|
+
|
|
621
|
+
|
|
528
622
|
def _share_per_block_per_project(
|
|
529
623
|
recent_blocks: list[dict],
|
|
530
624
|
) -> dict[str, dict[str, float]]:
|
|
531
625
|
"""Aggregate per-block per-project costs from `five_hour_block_projects`.
|
|
532
626
|
|
|
533
627
|
Returns {block_start_at_iso: {project_path_or_'(unknown)': cost_usd}}.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
628
|
+
|
|
629
|
+
The rollup rows are reached through their parent's `block_start_at`
|
|
630
|
+
rather than through a window key derived here. `five_hour_block_projects.
|
|
631
|
+
five_hour_window_key` is canonicalized from the window's RESET, so a key
|
|
632
|
+
derived from the block's START missed every row by the block duration and
|
|
633
|
+
the lookup never resolved — which made the fallback the effective path
|
|
634
|
+
(#751a review P2-1). Resolving the parent by its own recorded start is a
|
|
635
|
+
lookup rather than a second derivation, so no third key shape is created
|
|
636
|
+
(CLAUDE.md "5-hour windows").
|
|
637
|
+
|
|
638
|
+
Fallback (rollup empty/unreadable): `_share_per_block_per_project_by_
|
|
639
|
+
ownership`, which prices the entries each block owns. Fires only during
|
|
640
|
+
the first tick after fresh install or before stats-migration
|
|
641
|
+
`002_five_hour_block_projects_backfill_v1` completes. Issue #33.
|
|
543
642
|
"""
|
|
544
643
|
if not recent_blocks:
|
|
545
644
|
return {}
|
|
546
645
|
out: dict[str, dict[str, float]] = {}
|
|
547
|
-
|
|
548
|
-
|
|
646
|
+
starts: list[dt.datetime] = []
|
|
647
|
+
iso_by_start_epoch: dict[int, str] = {}
|
|
549
648
|
for b in recent_blocks:
|
|
550
649
|
try:
|
|
551
650
|
ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
|
|
552
651
|
except (ValueError, KeyError):
|
|
553
652
|
continue
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
if not
|
|
653
|
+
ts = ts.astimezone(dt.timezone.utc)
|
|
654
|
+
starts.append(ts)
|
|
655
|
+
iso_by_start_epoch[int(ts.timestamp())] = b["start_at"]
|
|
656
|
+
if not starts:
|
|
558
657
|
return out
|
|
658
|
+
epochs = list(iso_by_start_epoch)
|
|
559
659
|
try:
|
|
560
660
|
conn = open_db()
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
661
|
+
try:
|
|
662
|
+
placeholders = ",".join("?" for _ in epochs)
|
|
663
|
+
rows = conn.execute(
|
|
664
|
+
f"SELECT unixepoch(b.block_start_at) AS start_epoch, "
|
|
665
|
+
f" p.project_path, p.cost_usd "
|
|
666
|
+
f" FROM five_hour_block_projects p "
|
|
667
|
+
f" JOIN five_hour_blocks b ON b.id = p.block_id "
|
|
668
|
+
f" WHERE unixepoch(b.block_start_at) IN ({placeholders})",
|
|
669
|
+
epochs,
|
|
670
|
+
).fetchall()
|
|
671
|
+
finally:
|
|
672
|
+
conn.close()
|
|
673
|
+
for start_epoch, project_path, cost in rows:
|
|
674
|
+
block_iso = iso_by_start_epoch.get(int(start_epoch))
|
|
570
675
|
if block_iso is None:
|
|
571
676
|
continue
|
|
572
677
|
proj = project_path or "(unknown)"
|
|
@@ -576,15 +681,7 @@ def _share_per_block_per_project(
|
|
|
576
681
|
return out
|
|
577
682
|
except (sqlite3.DatabaseError, OSError):
|
|
578
683
|
pass
|
|
579
|
-
|
|
580
|
-
for b in recent_blocks:
|
|
581
|
-
try:
|
|
582
|
-
ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
|
|
583
|
-
except (ValueError, KeyError):
|
|
584
|
-
continue
|
|
585
|
-
end = ts + dt.timedelta(hours=5)
|
|
586
|
-
out[b["start_at"]] = sys.modules["cctally"]._share_all_projects_for_range(ts, end)
|
|
587
|
-
return out
|
|
684
|
+
return _share_per_block_per_project_by_ownership(starts, iso_by_start_epoch)
|
|
588
685
|
|
|
589
686
|
|
|
590
687
|
def _build_share_panel_data(panel: str, options: dict,
|
|
@@ -1327,7 +1424,28 @@ def _build_projects_share_panel_data(options: dict,
|
|
|
1327
1424
|
week_end = cw_start + dt.timedelta(days=7)
|
|
1328
1425
|
now = _share_now_utc()
|
|
1329
1426
|
period_end = week_end if week_end <= now else now
|
|
1330
|
-
|
|
1427
|
+
# #750 S4 §4.7: the period start comes from the SELECTED INTERVAL
|
|
1428
|
+
# IDENTITIES, not from `cw_start - 7 * (effective_weeks - 1)`. A credited
|
|
1429
|
+
# week adds a billing cycle without adding seven calendar days, so the
|
|
1430
|
+
# seven-day arithmetic advertises a period WIDER than the data the
|
|
1431
|
+
# artifact aggregated the moment one is inside the window. The instants
|
|
1432
|
+
# come from `projects.trend.weeks[].week_start_at` (§2.2). The old
|
|
1433
|
+
# arithmetic remains the fallback for an envelope that predates the
|
|
1434
|
+
# field, where it is still the best available answer.
|
|
1435
|
+
_selected_starts = [
|
|
1436
|
+
w.get("week_start_at") for w in (trend.get("weeks") or [])
|
|
1437
|
+
if isinstance(w, dict) and w.get("week_start_at")
|
|
1438
|
+
][-effective_weeks:]
|
|
1439
|
+
period_start = None
|
|
1440
|
+
if _selected_starts:
|
|
1441
|
+
try:
|
|
1442
|
+
period_start = parse_iso_datetime(
|
|
1443
|
+
_selected_starts[0], "projects.trend.week_start_at",
|
|
1444
|
+
)
|
|
1445
|
+
except (TypeError, ValueError):
|
|
1446
|
+
period_start = None
|
|
1447
|
+
if period_start is None:
|
|
1448
|
+
period_start = cw_start - dt.timedelta(days=7 * (effective_weeks - 1))
|
|
1331
1449
|
|
|
1332
1450
|
return {
|
|
1333
1451
|
"rows": rows,
|
|
@@ -1533,7 +1651,7 @@ def _share_codex_state_for_period(data_snap, *, panel: str, options: dict):
|
|
|
1533
1651
|
),
|
|
1534
1652
|
data_version=(
|
|
1535
1653
|
f"share:codex:{panel}:{range_start.isoformat()}:"
|
|
1536
|
-
f"{now_override.isoformat()}:{semantics.
|
|
1654
|
+
f"{now_override.isoformat()}:{semantics.codex_identity}"
|
|
1537
1655
|
),
|
|
1538
1656
|
)
|
|
1539
1657
|
finally:
|