cctally 1.117.0 → 1.117.2
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 +16 -0
- package/bin/_cctally_dashboard.py +39 -10
- package/bin/_cctally_dashboard_envelope.py +10 -0
- package/bin/_cctally_five_hour.py +57 -4
- package/bin/_cctally_forecast.py +272 -0
- package/bin/_cctally_milestone_history.py +379 -91
- package/bin/_cctally_tui.py +362 -33
- package/bin/_lib_dashboard_sources.py +94 -8
- package/bin/_lib_quota_model.py +29 -11
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_tick_stats.py +10 -5
- package/bin/_lib_write_io.py +1 -1
- package/bin/cctally +4 -0
- package/dashboard/static/assets/{ConversationsView-Dcfn8T6f.js → ConversationsView-oqNsWzWV.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-BqmXbnhn.js → DoctorModal-B0DnU9d9.js} +1 -1
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B-HZkDnT.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-QhJkHB7i.js → SourceDetailModal-BleDpQqG.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CkXEIQcK.js → UpdateModal-CMqaAkSK.js} +1 -1
- package/dashboard/static/assets/{index-HKS1s56o.js → index-BjQV-0-D.js} +2 -2
- package/dashboard/static/assets/{outlineNavigation-BNGaNPCp.js → outlineNavigation-CO-VWBm7.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +1 -1
- package/dashboard/static/assets/ModalRoot-Ck3WJK2V.js +0 -1
|
@@ -29,7 +29,9 @@ import sqlite3
|
|
|
29
29
|
import sys
|
|
30
30
|
from dataclasses import replace
|
|
31
31
|
|
|
32
|
-
from _cctally_core import
|
|
32
|
+
from _cctally_core import (
|
|
33
|
+
_canonicalize_optional_iso, make_week_ref, parse_iso_datetime,
|
|
34
|
+
)
|
|
33
35
|
from _cctally_quota import assert_projection_readable, codex_quota_breakdown
|
|
34
36
|
from _lib_accounts import UNATTRIBUTED
|
|
35
37
|
from _lib_codex_pools import is_model_scoped_codex_quota
|
|
@@ -95,19 +97,35 @@ def _week_label(ref, tz) -> str:
|
|
|
95
97
|
return ref.week_start.strftime("%b %d")
|
|
96
98
|
|
|
97
99
|
|
|
98
|
-
def
|
|
100
|
+
def _account_predicate(account_key, column: str) -> "tuple[str, tuple]":
|
|
101
|
+
"""``" AND <column> = ?"`` and its parameter for a scoped read, or nothing.
|
|
102
|
+
|
|
103
|
+
#836 spec 5.5: an explicit account is STRICT equality everywhere, so the
|
|
104
|
+
``unattributed`` bucket never enters a real account's history and
|
|
105
|
+
``account_key="unattributed"`` selects that bucket alone. ``None`` is the
|
|
106
|
+
merged read, unchanged.
|
|
107
|
+
"""
|
|
108
|
+
if account_key is None:
|
|
109
|
+
return "", ()
|
|
110
|
+
return f" AND {column} = ?", (account_key,)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _count_blocks(conn: sqlite3.Connection, start_iso, end_iso, *,
|
|
114
|
+
account_key=None) -> int:
|
|
99
115
|
"""Count ``five_hour_blocks`` whose interval intersects ``[start, end)``.
|
|
100
116
|
|
|
101
117
|
Half-open intersection on epoch seconds via ``unixepoch()`` (mixed stored
|
|
102
118
|
offsets). Returns 0 when the week lacks canonical boundaries.
|
|
119
|
+
``account_key`` (#836) counts only that account's blocks.
|
|
103
120
|
"""
|
|
104
121
|
if not start_iso or not end_iso:
|
|
105
122
|
return 0
|
|
123
|
+
acct_sql, acct_params = _account_predicate(account_key, "account_key")
|
|
106
124
|
row = conn.execute(
|
|
107
125
|
"SELECT COUNT(*) FROM five_hour_blocks "
|
|
108
126
|
"WHERE unixepoch(block_start_at) < unixepoch(?) "
|
|
109
|
-
" AND unixepoch(five_hour_resets_at) > unixepoch(?)",
|
|
110
|
-
(end_iso, start_iso),
|
|
127
|
+
" AND unixepoch(five_hour_resets_at) > unixepoch(?)" + acct_sql,
|
|
128
|
+
(end_iso, start_iso) + acct_params,
|
|
111
129
|
).fetchone()
|
|
112
130
|
return int(row[0] or 0)
|
|
113
131
|
|
|
@@ -115,7 +133,8 @@ def _count_blocks(conn: sqlite3.Connection, start_iso, end_iso) -> int:
|
|
|
115
133
|
# ── Claude week index (spec §1a) ───────────────────────────────────────
|
|
116
134
|
|
|
117
135
|
|
|
118
|
-
def _navigable_claude_refs(conn: sqlite3.Connection
|
|
136
|
+
def _navigable_claude_refs(conn: sqlite3.Connection, *, account_key=None,
|
|
137
|
+
as_of_utc=None) -> list:
|
|
119
138
|
"""The navigable Claude week refs, newest-first, per spec §1a.
|
|
120
139
|
|
|
121
140
|
Navigable set = weeks with ≥1 ``weekly_usage_snapshots`` row ∪ weeks with
|
|
@@ -123,20 +142,30 @@ def _navigable_claude_refs(conn: sqlite3.Connection) -> list:
|
|
|
123
142
|
reset-defined segment remains independently navigable; a defensive
|
|
124
143
|
milestone-only week absent from ``get_recent_weeks`` is synthesized from
|
|
125
144
|
its milestone rows' stored boundaries.
|
|
145
|
+
|
|
146
|
+
``account_key`` (#836 spec 5.5) scopes every leg to one account's rows;
|
|
147
|
+
``as_of_utc`` admits only reset events effective at or before it. Both
|
|
148
|
+
``None`` is the merged, as-of-free enumeration, unchanged.
|
|
126
149
|
"""
|
|
127
150
|
c = _cctally()
|
|
128
|
-
|
|
151
|
+
# unbounded, newest-first
|
|
152
|
+
refs = (c.get_recent_weeks(conn, None) if account_key is None
|
|
153
|
+
else c.get_recent_weeks(conn, None, account_key=account_key))
|
|
129
154
|
|
|
155
|
+
acct_sql, acct_params = _account_predicate(account_key, "account_key")
|
|
156
|
+
acct_where = acct_sql.replace(" AND ", " WHERE ", 1)
|
|
130
157
|
usage_keys = {
|
|
131
158
|
r[0]
|
|
132
159
|
for r in conn.execute(
|
|
133
160
|
"SELECT DISTINCT week_start_date FROM weekly_usage_snapshots"
|
|
161
|
+
+ acct_where, acct_params,
|
|
134
162
|
).fetchall()
|
|
135
163
|
}
|
|
136
164
|
milestone_keys = {
|
|
137
165
|
r[0]
|
|
138
166
|
for r in conn.execute(
|
|
139
167
|
"SELECT DISTINCT week_start_date FROM percent_milestones"
|
|
168
|
+
+ acct_where, acct_params,
|
|
140
169
|
).fetchall()
|
|
141
170
|
}
|
|
142
171
|
navigable = usage_keys | milestone_keys
|
|
@@ -144,10 +173,11 @@ def _navigable_claude_refs(conn: sqlite3.Connection) -> list:
|
|
|
144
173
|
refs = [r for r in refs if r.key in navigable]
|
|
145
174
|
present = {r.key for r in refs}
|
|
146
175
|
for k in sorted(milestone_keys - present, reverse=True):
|
|
147
|
-
ref = _synthesize_milestone_only_ref(conn, k)
|
|
176
|
+
ref = _synthesize_milestone_only_ref(conn, k, account_key=account_key)
|
|
148
177
|
if ref is not None:
|
|
149
178
|
refs.append(ref)
|
|
150
|
-
return _derive_claude_reset_cycles(
|
|
179
|
+
return _derive_claude_reset_cycles(
|
|
180
|
+
conn, refs, account_key=account_key, as_of_utc=as_of_utc)
|
|
151
181
|
|
|
152
182
|
|
|
153
183
|
def _parse_optional_iso(value):
|
|
@@ -159,17 +189,20 @@ def _parse_optional_iso(value):
|
|
|
159
189
|
return None
|
|
160
190
|
|
|
161
191
|
|
|
162
|
-
def _claude_storage_boundaries(conn: sqlite3.Connection, key: str
|
|
163
|
-
|
|
192
|
+
def _claude_storage_boundaries(conn: sqlite3.Connection, key: str, *,
|
|
193
|
+
account_key=None):
|
|
194
|
+
"""Return the outer retained boundary for one storage date bucket
|
|
195
|
+
(``account_key``: that account's rows only, in both legs)."""
|
|
196
|
+
acct_sql, acct_params = _account_predicate(account_key, "account_key")
|
|
164
197
|
rows = conn.execute(
|
|
165
198
|
"SELECT week_start_at, week_end_at FROM weekly_usage_snapshots "
|
|
166
199
|
"WHERE week_start_date=? AND week_start_at IS NOT NULL "
|
|
167
|
-
"AND week_end_at IS NOT NULL "
|
|
200
|
+
"AND week_end_at IS NOT NULL" + acct_sql + " "
|
|
168
201
|
"UNION ALL "
|
|
169
202
|
"SELECT week_start_at, week_end_at FROM percent_milestones "
|
|
170
203
|
"WHERE week_start_date=? AND week_start_at IS NOT NULL "
|
|
171
|
-
"AND week_end_at IS NOT NULL",
|
|
172
|
-
(key, key),
|
|
204
|
+
"AND week_end_at IS NOT NULL" + acct_sql,
|
|
205
|
+
(key,) + acct_params + (key,) + acct_params,
|
|
173
206
|
).fetchall()
|
|
174
207
|
pairs = [
|
|
175
208
|
(start, end)
|
|
@@ -183,51 +216,90 @@ def _claude_storage_boundaries(conn: sqlite3.Connection, key: str):
|
|
|
183
216
|
return min(start for start, _end in pairs), max(end for _start, end in pairs)
|
|
184
217
|
|
|
185
218
|
|
|
186
|
-
def
|
|
187
|
-
"""
|
|
219
|
+
def _claude_reset_cuts_cycle(outer, effective) -> bool:
|
|
220
|
+
"""Whether a linked reset's effective instant starts a new cycle inside a
|
|
221
|
+
storage bucket whose outer retained boundary is ``outer`` ``(start, end)``.
|
|
222
|
+
|
|
223
|
+
The ONE rule for where a Claude reset-defined cycle begins (#836 review
|
|
224
|
+
F7): the history index cuts its buckets with it, and the decorated card's
|
|
225
|
+
cycle scope (`_claude_current_cycle_scope`) starts its cycle with it, so
|
|
226
|
+
the card and its current index entry cannot disagree about the start. In
|
|
227
|
+
particular an early re-anchor stamped at the capture second can precede
|
|
228
|
+
the new window's nearest-hour start; while both windows share one storage
|
|
229
|
+
key the reset instant still starts the cycle.
|
|
230
|
+
"""
|
|
231
|
+
return (outer is not None and effective is not None
|
|
232
|
+
and outer[0] < effective < outer[1])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _linked_reset_events(conn: sqlite3.Connection, key: str, *,
|
|
236
|
+
account_key=None, as_of_utc=None) -> list:
|
|
237
|
+
"""Reset rows linked to any retained boundary in a storage bucket.
|
|
238
|
+
|
|
239
|
+
#836 spec 5.5: the two ``EXISTS`` legs are one parenthesized group, so the
|
|
240
|
+
account and as-of predicates below bind the whole disjunction rather than
|
|
241
|
+
its second leg (``A OR B AND x`` is ``A OR (B AND x)``). ``account_key``
|
|
242
|
+
scopes the event and both legs' rows; ``as_of_utc`` admits only events
|
|
243
|
+
effective at or before it — the rule `_latest_reset_event_for_end` applies
|
|
244
|
+
for the card, so a reset that takes effect later cuts neither yet.
|
|
245
|
+
"""
|
|
246
|
+
acct_s, acct_p = _account_predicate(account_key, "s.account_key")
|
|
247
|
+
acct_m, _ = _account_predicate(account_key, "m.account_key")
|
|
248
|
+
acct_e, _ = _account_predicate(account_key, "e.account_key")
|
|
249
|
+
as_of_sql = ""
|
|
250
|
+
as_of_params: tuple = ()
|
|
251
|
+
if as_of_utc is not None:
|
|
252
|
+
as_of_sql = (" AND unixepoch(e.effective_reset_at_utc) "
|
|
253
|
+
"<= unixepoch(?)")
|
|
254
|
+
as_of_params = (as_of_utc,)
|
|
188
255
|
return conn.execute(
|
|
189
256
|
"SELECT DISTINCT e.id, e.effective_reset_at_utc "
|
|
190
257
|
"FROM week_reset_events e "
|
|
191
|
-
"WHERE EXISTS ("
|
|
258
|
+
"WHERE (EXISTS ("
|
|
192
259
|
" SELECT 1 FROM weekly_usage_snapshots s "
|
|
193
|
-
" WHERE s.week_start_date=? AND ("
|
|
260
|
+
" WHERE s.week_start_date=?" + acct_s + " AND ("
|
|
194
261
|
" unixepoch(s.week_end_at)=unixepoch(e.old_week_end_at) OR "
|
|
195
262
|
" unixepoch(s.week_end_at)=unixepoch(e.new_week_end_at)"
|
|
196
263
|
" )"
|
|
197
264
|
") OR EXISTS ("
|
|
198
265
|
" SELECT 1 FROM percent_milestones m "
|
|
199
|
-
" WHERE m.week_start_date=? AND ("
|
|
266
|
+
" WHERE m.week_start_date=?" + acct_m + " AND ("
|
|
200
267
|
" unixepoch(m.week_end_at)=unixepoch(e.old_week_end_at) OR "
|
|
201
268
|
" unixepoch(m.week_end_at)=unixepoch(e.new_week_end_at)"
|
|
202
269
|
" )"
|
|
203
|
-
") "
|
|
270
|
+
"))" + acct_e + as_of_sql + " "
|
|
204
271
|
"ORDER BY unixepoch(e.effective_reset_at_utc), e.id",
|
|
205
|
-
(key, key),
|
|
272
|
+
(key,) + acct_p + (key,) + acct_p + acct_p + as_of_params,
|
|
206
273
|
).fetchall()
|
|
207
274
|
|
|
208
275
|
|
|
209
|
-
def _derive_claude_reset_cycles(conn: sqlite3.Connection, refs: list
|
|
276
|
+
def _derive_claude_reset_cycles(conn: sqlite3.Connection, refs: list, *,
|
|
277
|
+
account_key=None, as_of_utc=None) -> list:
|
|
210
278
|
"""Expand storage buckets into provider reset-defined cycles.
|
|
211
279
|
|
|
212
280
|
``get_recent_weeks`` can split an in-place credit, but an early re-anchor
|
|
213
281
|
whose old and new boundaries both remain under one ``week_start_date``
|
|
214
282
|
only exposes the pre-reset half. The retained snapshot boundary set plus
|
|
215
283
|
``week_reset_events`` is the authoritative reset ledger for both shapes.
|
|
284
|
+
|
|
285
|
+
``account_key`` / ``as_of_utc`` (#836): that account's boundaries and
|
|
286
|
+
resets, cut only by events already effective at ``as_of_utc``.
|
|
216
287
|
"""
|
|
217
288
|
out: list = []
|
|
218
289
|
by_key: dict = {}
|
|
219
290
|
for ref in refs:
|
|
220
291
|
by_key.setdefault(ref.key, ref)
|
|
221
292
|
for key, template in by_key.items():
|
|
222
|
-
outer = _claude_storage_boundaries(conn, key)
|
|
293
|
+
outer = _claude_storage_boundaries(conn, key, account_key=account_key)
|
|
223
294
|
if outer is None:
|
|
224
295
|
out.append(template)
|
|
225
296
|
continue
|
|
226
297
|
start, end = outer
|
|
227
298
|
cuts = []
|
|
228
|
-
for event in _linked_reset_events(
|
|
299
|
+
for event in _linked_reset_events(
|
|
300
|
+
conn, key, account_key=account_key, as_of_utc=as_of_utc):
|
|
229
301
|
effective = _parse_optional_iso(event["effective_reset_at_utc"])
|
|
230
|
-
if
|
|
302
|
+
if _claude_reset_cuts_cycle(outer, effective):
|
|
231
303
|
cuts.append(effective)
|
|
232
304
|
boundaries = [start, *sorted(set(cuts)), end]
|
|
233
305
|
cycles = []
|
|
@@ -242,18 +314,21 @@ def _derive_claude_reset_cycles(conn: sqlite3.Connection, refs: list) -> list:
|
|
|
242
314
|
return _cctally()._apply_overlap_clamp_to_weekrefs(out)
|
|
243
315
|
|
|
244
316
|
|
|
245
|
-
def _synthesize_milestone_only_ref(conn: sqlite3.Connection, key: str
|
|
317
|
+
def _synthesize_milestone_only_ref(conn: sqlite3.Connection, key: str, *,
|
|
318
|
+
account_key=None):
|
|
246
319
|
"""Defensive ref for a milestone-only week (no usage snapshot).
|
|
247
320
|
|
|
248
321
|
Boundaries fall back to the milestone rows' stored
|
|
249
322
|
``week_start_at``/``week_end_at``/``week_end_date`` (spec §1a). Returns
|
|
250
|
-
``None`` if a valid ref can't be built.
|
|
323
|
+
``None`` if a valid ref can't be built. ``account_key`` (#836): from that
|
|
324
|
+
account's milestone rows only.
|
|
251
325
|
"""
|
|
326
|
+
acct_sql, acct_params = _account_predicate(account_key, "account_key")
|
|
252
327
|
row = conn.execute(
|
|
253
328
|
"SELECT week_end_date, week_start_at, week_end_at FROM percent_milestones "
|
|
254
|
-
"WHERE week_start_date = ? "
|
|
329
|
+
"WHERE week_start_date = ?" + acct_sql + " "
|
|
255
330
|
"ORDER BY (week_start_at IS NULL), captured_at_utc ASC LIMIT 1",
|
|
256
|
-
(key,),
|
|
331
|
+
(key,) + acct_params,
|
|
257
332
|
).fetchone()
|
|
258
333
|
if row is None:
|
|
259
334
|
return None
|
|
@@ -289,58 +364,82 @@ def _claude_cycle_key(ref) -> str:
|
|
|
289
364
|
)
|
|
290
365
|
|
|
291
366
|
|
|
292
|
-
def _claude_cycle_rows(conn: sqlite3.Connection, ref
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
367
|
+
def _claude_cycle_rows(conn: sqlite3.Connection, ref, *, account_key=None,
|
|
368
|
+
cohort_id=None) -> list:
|
|
369
|
+
"""The cycle's weekly milestone cohort.
|
|
370
|
+
|
|
371
|
+
``account_key`` (#836 spec 5.5) scopes the cohort event, both of its
|
|
372
|
+
supporting subqueries and the milestone read to one account. ``cohort_id``
|
|
373
|
+
names the cohort outright: a scoped index passes the card's active reset
|
|
374
|
+
for its CURRENT cycle, so the index, the detail and the card read one
|
|
375
|
+
segment (spec 5.2 step 2) rather than re-deriving it from the cut instant.
|
|
376
|
+
"""
|
|
377
|
+
acct_s, acct_p = _account_predicate(account_key, "s.account_key")
|
|
378
|
+
acct_m, _ = _account_predicate(account_key, "m.account_key")
|
|
379
|
+
acct_e, _ = _account_predicate(account_key, "e.account_key")
|
|
380
|
+
acct_r, _ = _account_predicate(account_key, "account_key")
|
|
381
|
+
if cohort_id is None:
|
|
382
|
+
cohort_id = 0
|
|
383
|
+
if ref.week_start_at:
|
|
384
|
+
row = conn.execute(
|
|
385
|
+
"SELECT e.id FROM week_reset_events e "
|
|
386
|
+
"WHERE unixepoch(e.effective_reset_at_utc)=unixepoch(?)"
|
|
387
|
+
+ acct_e + " "
|
|
388
|
+
"AND (EXISTS ("
|
|
389
|
+
" SELECT 1 FROM weekly_usage_snapshots s "
|
|
390
|
+
" WHERE s.week_start_date=?" + acct_s + " AND ("
|
|
391
|
+
" unixepoch(s.week_end_at)=unixepoch(e.old_week_end_at) OR "
|
|
392
|
+
" unixepoch(s.week_end_at)=unixepoch(e.new_week_end_at)"
|
|
393
|
+
" )"
|
|
394
|
+
") OR EXISTS ("
|
|
395
|
+
" SELECT 1 FROM percent_milestones m "
|
|
396
|
+
" WHERE m.week_start_date=?" + acct_m + " AND ("
|
|
397
|
+
" unixepoch(m.week_end_at)=unixepoch(e.old_week_end_at) OR "
|
|
398
|
+
" unixepoch(m.week_end_at)=unixepoch(e.new_week_end_at)"
|
|
399
|
+
" )"
|
|
400
|
+
")) ORDER BY e.id DESC LIMIT 1",
|
|
401
|
+
(ref.week_start_at,) + acct_p + (ref.key,) + acct_p
|
|
402
|
+
+ (ref.key,) + acct_p,
|
|
403
|
+
).fetchone()
|
|
404
|
+
if row is not None:
|
|
405
|
+
cohort_id = int(row[0])
|
|
315
406
|
return conn.execute(
|
|
316
407
|
"SELECT * FROM percent_milestones WHERE week_start_date=? "
|
|
317
|
-
"AND reset_event_id=? "
|
|
408
|
+
"AND reset_event_id=?" + acct_r + " "
|
|
318
409
|
"ORDER BY unixepoch(captured_at_utc) ASC, percent_threshold ASC",
|
|
319
|
-
(ref.key, cohort_id),
|
|
410
|
+
(ref.key, cohort_id) + acct_p,
|
|
320
411
|
).fetchall()
|
|
321
412
|
|
|
322
413
|
|
|
323
|
-
def _five_hour_detail_revision(conn, start_iso, end_iso
|
|
414
|
+
def _five_hour_detail_revision(conn, start_iso, end_iso, *,
|
|
415
|
+
account_key=None) -> tuple[int, ...]:
|
|
324
416
|
"""Append-only 5h child revision for one cycle's intersecting blocks.
|
|
325
417
|
|
|
326
418
|
The detail cache uses the index stamp. Weekly crossing counts do not move
|
|
327
419
|
when another account records a 5h credit or crossing in an existing block.
|
|
328
420
|
These child tables insert new ids for those events; count and max id also
|
|
329
421
|
notice deletions and replacement rows without hashing an unbounded ledger.
|
|
422
|
+
|
|
423
|
+
#836 spec 5.4: ``TOTAL(m.usage_snapshot_id)`` moves when a snapshot DELETE
|
|
424
|
+
rewrites a retained crossing's join to the ``0`` sentinel, which changes
|
|
425
|
+
no count and no max id. ``account_key`` (spec 5.5) reads only that
|
|
426
|
+
account's blocks, so another account's changes never move a scoped stamp.
|
|
330
427
|
"""
|
|
331
428
|
if not start_iso or not end_iso:
|
|
332
|
-
return (0, 0, 0, 0, 0, 0)
|
|
429
|
+
return (0, 0, 0, 0, 0, 0, 0)
|
|
430
|
+
acct_sql, acct_params = _account_predicate(account_key, "b.account_key")
|
|
333
431
|
interval = (
|
|
334
432
|
"unixepoch(b.block_start_at) < unixepoch(?) "
|
|
335
|
-
"AND unixepoch(b.five_hour_resets_at) > unixepoch(?)"
|
|
433
|
+
"AND unixepoch(b.five_hour_resets_at) > unixepoch(?)" + acct_sql
|
|
336
434
|
)
|
|
337
|
-
bounds = (end_iso, start_iso)
|
|
435
|
+
bounds = (end_iso, start_iso) + acct_params
|
|
338
436
|
blocks = conn.execute(
|
|
339
437
|
"SELECT COUNT(*), MAX(b.id) FROM five_hour_blocks b "
|
|
340
438
|
f"WHERE {interval}", bounds,
|
|
341
439
|
).fetchone()
|
|
342
440
|
milestones = conn.execute(
|
|
343
|
-
"SELECT COUNT(*), MAX(m.id)
|
|
441
|
+
"SELECT COUNT(*), MAX(m.id), TOTAL(m.usage_snapshot_id) "
|
|
442
|
+
"FROM five_hour_blocks b "
|
|
344
443
|
"JOIN five_hour_milestones m ON m.block_id = b.id "
|
|
345
444
|
f"WHERE {interval}", bounds,
|
|
346
445
|
).fetchone()
|
|
@@ -355,20 +454,87 @@ def _five_hour_detail_revision(conn, start_iso, end_iso) -> tuple[int, ...]:
|
|
|
355
454
|
for value in row)
|
|
356
455
|
|
|
357
456
|
|
|
358
|
-
def
|
|
359
|
-
|
|
457
|
+
def _unavailable_join_rows(conn, *, account_key=None) -> list:
|
|
458
|
+
"""Every 5h milestone whose effective weekly join is unavailable, with
|
|
459
|
+
its block's bounds as SQLite epochs, ascending by id (#836 spec 5.4).
|
|
460
|
+
|
|
461
|
+
Read ONCE per index build and bucketed per cycle by
|
|
462
|
+
`_unavailable_join_component`. The bounds are wrapped in ``unixepoch()``,
|
|
463
|
+
which no index serves, so a per-cycle query scanned every milestone once
|
|
464
|
+
per index entry. The epochs are SQLite's own, so the bucketing compares
|
|
465
|
+
exactly what the per-cycle predicate compared; an unparseable bound is
|
|
466
|
+
NULL and never matches. ``account_key`` is the same strict scope
|
|
467
|
+
(`_account_predicate`).
|
|
468
|
+
"""
|
|
469
|
+
from _cctally_five_hour import weekly_join_unavailable_sql
|
|
470
|
+
acct_sql, acct_params = _account_predicate(account_key, "b.account_key")
|
|
471
|
+
return [(int(r[0]), r[1], r[2]) for r in conn.execute(
|
|
472
|
+
"SELECT m.id, unixepoch(b.block_start_at), "
|
|
473
|
+
"unixepoch(b.five_hour_resets_at) FROM five_hour_blocks b "
|
|
474
|
+
"JOIN five_hour_milestones m ON m.block_id = b.id "
|
|
475
|
+
"LEFT JOIN weekly_usage_snapshots s ON s.id = m.usage_snapshot_id "
|
|
476
|
+
"WHERE " + weekly_join_unavailable_sql(conn, "s") + acct_sql
|
|
477
|
+
+ " ORDER BY m.id",
|
|
478
|
+
acct_params,
|
|
479
|
+
).fetchall()]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _unavailable_join_component(conn, start_iso, end_iso,
|
|
483
|
+
unavailable_rows) -> tuple:
|
|
484
|
+
"""The detail stamp's unavailable-join component (#836 spec 5.4).
|
|
485
|
+
|
|
486
|
+
A digest of the sorted ids of the cycle's 5h milestones whose effective
|
|
487
|
+
weekly join is unavailable — the joined snapshot absent, the ``0``
|
|
488
|
+
sentinel, or suppression-retired (`weekly_join_unavailable_sql`, the same
|
|
489
|
+
predicate as the dashboard's retired-join signature leg). Availability is
|
|
490
|
+
exactly what the fold's weekly rejection changes, and a set rather than a
|
|
491
|
+
count, so a restoration of one crossing beside the retirement of another
|
|
492
|
+
still moves the stamp. ``()`` when the set is empty, so a store with no
|
|
493
|
+
unavailable join keeps its stamp bytes.
|
|
494
|
+
|
|
495
|
+
``unavailable_rows`` is the build's `_unavailable_join_rows`; a crossing
|
|
496
|
+
belongs to the cycle when its block intersects ``[start, end)`` — block
|
|
497
|
+
start before the cycle's end, block reset after the cycle's start — with
|
|
498
|
+
the cycle bounds converted by SQLite's ``unixepoch()`` as well.
|
|
499
|
+
"""
|
|
500
|
+
if not start_iso or not end_iso or not unavailable_rows:
|
|
501
|
+
return ()
|
|
502
|
+
start_epoch, end_epoch = conn.execute(
|
|
503
|
+
"SELECT unixepoch(?), unixepoch(?)", (start_iso, end_iso)).fetchone()
|
|
504
|
+
if start_epoch is None or end_epoch is None:
|
|
505
|
+
return ()
|
|
506
|
+
ids = [mid for mid, block_start, block_resets in unavailable_rows
|
|
507
|
+
if block_start is not None and block_resets is not None
|
|
508
|
+
and block_start < end_epoch and block_resets > start_epoch]
|
|
509
|
+
if not ids:
|
|
510
|
+
return ()
|
|
511
|
+
return ("u" + _mh.compute_detail_stamp(*ids),)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _index_entry(conn, ref, current_cycle_key, tz, *, unavailable_rows,
|
|
515
|
+
decorated=False, account_key=None, cohort_id=None) -> dict:
|
|
516
|
+
rows = _claude_cycle_rows(
|
|
517
|
+
conn, ref, account_key=account_key, cohort_id=cohort_id)
|
|
360
518
|
milestone_count = len(rows)
|
|
361
519
|
segment_count = 1 if rows else 0
|
|
362
520
|
max_captured = max((r["captured_at_utc"] for r in rows), default=None)
|
|
363
521
|
start_z = _to_iso_z(ref.week_start_at)
|
|
364
522
|
end_z = _to_iso_z(ref.week_end_at)
|
|
365
|
-
block_count = _count_blocks(conn, start_z, end_z)
|
|
523
|
+
block_count = _count_blocks(conn, start_z, end_z, account_key=account_key)
|
|
366
524
|
key = _claude_cycle_key(ref)
|
|
367
525
|
# R8: leave the <=1-real-account stamp byte-identical. On a decorated
|
|
368
526
|
# provider the fetched block list contains multiple account-owned rows,
|
|
369
527
|
# so its cache identity also needs their child-ledger revision.
|
|
370
528
|
block_revision = (
|
|
371
|
-
_five_hour_detail_revision(conn, start_z, end_z
|
|
529
|
+
_five_hour_detail_revision(conn, start_z, end_z, account_key=account_key)
|
|
530
|
+
if decorated else ())
|
|
531
|
+
# #836 spec 5.4/5.5: every store's stamp names which crossings have an
|
|
532
|
+
# unavailable weekly join (appended only when there is one), and a scoped
|
|
533
|
+
# stamp names its account, because one opaque cycle key is shared by every
|
|
534
|
+
# account whose subscription week has the same boundaries.
|
|
535
|
+
unavailable = _unavailable_join_component(
|
|
536
|
+
conn, start_z, end_z, unavailable_rows)
|
|
537
|
+
scope_part = () if account_key is None else ("a" + account_key,)
|
|
372
538
|
|
|
373
539
|
# #750 S4 §5.3. A CURRENT single-segment cycle neither fetches its detail
|
|
374
540
|
# nor uses one that arrives, so a gap in the LIVE cycle would stay
|
|
@@ -397,33 +563,120 @@ def _index_entry(conn, ref, current_cycle_key, tz, *, decorated=False) -> dict:
|
|
|
397
563
|
"has_observation_gap": has_gap,
|
|
398
564
|
"detail_stamp": _mh.compute_detail_stamp(
|
|
399
565
|
key, milestone_count, block_count, segment_count, max_captured,
|
|
400
|
-
*block_revision,
|
|
566
|
+
*block_revision, *unavailable, *scope_part,
|
|
401
567
|
),
|
|
402
568
|
}
|
|
403
569
|
|
|
404
570
|
|
|
405
|
-
def
|
|
571
|
+
def _same_instant(a, b) -> bool:
|
|
572
|
+
"""Two stored boundary texts name the same instant (parsed, never by
|
|
573
|
+
spelling). Both absent is a match; one absent is not."""
|
|
574
|
+
if not a and not b:
|
|
575
|
+
return True
|
|
576
|
+
pa, pb = _parse_optional_iso(a), _parse_optional_iso(b)
|
|
577
|
+
return pa is not None and pb is not None and pa == pb
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _canonical_end(value):
|
|
581
|
+
try:
|
|
582
|
+
return _canonicalize_optional_iso(value, "claude.cycle.end")
|
|
583
|
+
except (TypeError, ValueError):
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _scoped_current_ref(refs: list, cycle_scope):
|
|
588
|
+
"""The ref in FULL interval agreement with the card's cycle (#836 spec
|
|
589
|
+
5.5): the same storage key, the same effective start and the same
|
|
590
|
+
canonical end, parsed as instants; on the date-only leg, the ref with that
|
|
591
|
+
storage key and absent bounds. A start-only match is not enough and no ref
|
|
592
|
+
is current merely by position. ``None`` when nothing agrees."""
|
|
593
|
+
if cycle_scope is None:
|
|
594
|
+
return None
|
|
595
|
+
for ref in refs:
|
|
596
|
+
if ref.key != cycle_scope.week_start_date:
|
|
597
|
+
continue
|
|
598
|
+
if cycle_scope.week_start_at is None:
|
|
599
|
+
if not ref.week_start_at and not ref.week_end_at:
|
|
600
|
+
return ref
|
|
601
|
+
continue
|
|
602
|
+
if (_same_instant(ref.week_start_at, cycle_scope.effective_start_at)
|
|
603
|
+
and _same_instant(_canonical_end(ref.week_end_at),
|
|
604
|
+
cycle_scope.week_end_at)):
|
|
605
|
+
return ref
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _scoped_refs(conn, account_key, cycle_scope, now_utc=None):
|
|
610
|
+
"""The scoped enumeration and its current ref (#836 spec 5.5).
|
|
611
|
+
|
|
612
|
+
Derived at the scope's as-of instant, so a retained reset that takes
|
|
613
|
+
effect later cuts neither the card nor the index yet. Without a scope the
|
|
614
|
+
as-of is ``now_utc`` (the build's own instant), and no ref is current.
|
|
615
|
+
"""
|
|
616
|
+
if cycle_scope is not None:
|
|
617
|
+
as_of = cycle_scope.as_of_utc
|
|
618
|
+
elif now_utc is not None:
|
|
619
|
+
as_of = _iso_z(now_utc)
|
|
620
|
+
else:
|
|
621
|
+
as_of = None
|
|
622
|
+
refs = _navigable_claude_refs(
|
|
623
|
+
conn, account_key=account_key, as_of_utc=as_of)
|
|
624
|
+
scope = (cycle_scope if cycle_scope is not None
|
|
625
|
+
and cycle_scope.account_key == account_key else None)
|
|
626
|
+
return refs, _scoped_current_ref(refs, scope), scope
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def build_claude_week_index(conn: sqlite3.Connection, *, account_key=None,
|
|
630
|
+
cycle_scope=None, now_utc=None) -> list:
|
|
406
631
|
"""Newest-first navigable Claude week index (spec §1a, §3).
|
|
407
632
|
|
|
408
633
|
One entry per effective reset-defined cycle. Multiple cycles may share one
|
|
409
634
|
storage ``week_start_date`` but always have distinct opaque keys.
|
|
635
|
+
|
|
636
|
+
``account_key`` (#836 spec 5.5, U1) builds ONE account's history: its own
|
|
637
|
+
cycles, cohorts, block counts and stamps, derived at ``cycle_scope``'s
|
|
638
|
+
as-of; the current entry is the one in full interval agreement with that
|
|
639
|
+
scope (the card's cycle), and its cohort is the scope's active reset.
|
|
640
|
+
``None`` keeps the merged contract byte-for-byte apart from the
|
|
641
|
+
unavailable-join stamp component.
|
|
410
642
|
"""
|
|
411
|
-
refs = _navigable_claude_refs(conn)
|
|
412
|
-
current_key = _current_claude_week_key(conn)
|
|
413
|
-
current_refs = [r for r in refs if r.key == current_key]
|
|
414
|
-
current_ref = max(
|
|
415
|
-
current_refs,
|
|
416
|
-
key=lambda r: r.week_start_at or "",
|
|
417
|
-
default=None,
|
|
418
|
-
)
|
|
419
|
-
current_cycle_key = _claude_cycle_key(current_ref) if current_ref else None
|
|
420
|
-
tz = _resolve_display_tz()
|
|
421
643
|
import _cctally_account
|
|
422
644
|
decorated = _cctally_account.provider_is_decorated(conn, "claude")
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
645
|
+
tz = _resolve_display_tz()
|
|
646
|
+
if account_key is None:
|
|
647
|
+
refs = _navigable_claude_refs(conn)
|
|
648
|
+
current_key = _current_claude_week_key(conn)
|
|
649
|
+
current_refs = [r for r in refs if r.key == current_key]
|
|
650
|
+
current_ref = max(
|
|
651
|
+
current_refs,
|
|
652
|
+
key=lambda r: r.week_start_at or "",
|
|
653
|
+
default=None,
|
|
654
|
+
)
|
|
655
|
+
current_cycle_key = (
|
|
656
|
+
_claude_cycle_key(current_ref) if current_ref else None)
|
|
657
|
+
unavailable_rows = _unavailable_join_rows(conn)
|
|
658
|
+
entries = [
|
|
659
|
+
_index_entry(conn, ref, current_cycle_key, tz,
|
|
660
|
+
unavailable_rows=unavailable_rows, decorated=decorated)
|
|
661
|
+
for ref in refs
|
|
662
|
+
]
|
|
663
|
+
else:
|
|
664
|
+
refs, current_ref, scope = _scoped_refs(
|
|
665
|
+
conn, account_key, cycle_scope, now_utc)
|
|
666
|
+
current_cycle_key = (
|
|
667
|
+
_claude_cycle_key(current_ref) if current_ref else None)
|
|
668
|
+
unavailable_rows = _unavailable_join_rows(
|
|
669
|
+
conn, account_key=account_key)
|
|
670
|
+
entries = [
|
|
671
|
+
_index_entry(
|
|
672
|
+
conn, ref, current_cycle_key, tz,
|
|
673
|
+
unavailable_rows=unavailable_rows, decorated=decorated,
|
|
674
|
+
account_key=account_key,
|
|
675
|
+
cohort_id=(scope.reset_event_id
|
|
676
|
+
if ref is current_ref else None),
|
|
677
|
+
)
|
|
678
|
+
for ref in refs
|
|
679
|
+
]
|
|
427
680
|
entries.sort(key=lambda e: e["start_at_utc"] or "", reverse=True)
|
|
428
681
|
return entries
|
|
429
682
|
|
|
@@ -512,12 +765,15 @@ def _load_block_credits(
|
|
|
512
765
|
]
|
|
513
766
|
|
|
514
767
|
|
|
515
|
-
def _build_blocks(conn: sqlite3.Connection, start_iso, end_iso
|
|
768
|
+
def _build_blocks(conn: sqlite3.Connection, start_iso, end_iso, *,
|
|
769
|
+
account_key=None) -> list:
|
|
516
770
|
"""``five_hour_blocks`` intersecting ``[start, end)``, ascending by start;
|
|
517
771
|
each carries its 5h milestone stream + credit rows (spec §1b). A block
|
|
518
|
-
straddling a week boundary appears in every week it intersects.
|
|
772
|
+
straddling a week boundary appears in every week it intersects.
|
|
773
|
+
``account_key`` (#836 spec 5.5): only that account's blocks."""
|
|
519
774
|
if not start_iso or not end_iso:
|
|
520
775
|
return []
|
|
776
|
+
acct_sql, acct_params = _account_predicate(account_key, "account_key")
|
|
521
777
|
c = _cctally()
|
|
522
778
|
import _cctally_account
|
|
523
779
|
decorated = _cctally_account.provider_is_decorated(conn, "claude")
|
|
@@ -533,9 +789,9 @@ def _build_blocks(conn: sqlite3.Connection, start_iso, end_iso) -> list:
|
|
|
533
789
|
" crossed_seven_day_reset, is_closed "
|
|
534
790
|
"FROM five_hour_blocks "
|
|
535
791
|
"WHERE unixepoch(block_start_at) < unixepoch(?) "
|
|
536
|
-
" AND unixepoch(five_hour_resets_at) > unixepoch(?) "
|
|
792
|
+
" AND unixepoch(five_hour_resets_at) > unixepoch(?)" + acct_sql + " "
|
|
537
793
|
"ORDER BY unixepoch(block_start_at) ASC, five_hour_window_key ASC, id ASC",
|
|
538
|
-
(end_iso, start_iso),
|
|
794
|
+
(end_iso, start_iso) + acct_params,
|
|
539
795
|
).fetchall()
|
|
540
796
|
out: list = []
|
|
541
797
|
for b in rows:
|
|
@@ -564,14 +820,45 @@ def _build_blocks(conn: sqlite3.Connection, start_iso, end_iso) -> list:
|
|
|
564
820
|
return out
|
|
565
821
|
|
|
566
822
|
|
|
567
|
-
def build_claude_week_detail(conn: sqlite3.Connection, key: str
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
823
|
+
def build_claude_week_detail(conn: sqlite3.Connection, key: str, *,
|
|
824
|
+
account_key=None, now_utc=None) -> "dict | None":
|
|
825
|
+
"""Complete payload for one reset-defined Claude cycle.
|
|
826
|
+
|
|
827
|
+
``account_key`` (#836 spec 5.5/5.6, U1) serves that account's cycle from
|
|
828
|
+
its own scoped index: the account's current cycle scope is resolved at
|
|
829
|
+
``now_utc`` (the route's as-of), exactly as the dashboard resolves it for
|
|
830
|
+
the card, so the current key's bounds here equal the card's. A key absent
|
|
831
|
+
from the account's scoped index is ``None``; it is never retried merged.
|
|
832
|
+
"""
|
|
833
|
+
if account_key is None:
|
|
834
|
+
refs = _navigable_claude_refs(conn)
|
|
835
|
+
ref = next((candidate for candidate in refs
|
|
836
|
+
if _claude_cycle_key(candidate) == key), None)
|
|
837
|
+
if ref is None:
|
|
838
|
+
return None
|
|
839
|
+
entry = next(e for e in build_claude_week_index(conn) if e["key"] == key)
|
|
840
|
+
cohort_id = None
|
|
841
|
+
else:
|
|
842
|
+
if now_utc is None:
|
|
843
|
+
now_utc = dt.datetime.now(UTC)
|
|
844
|
+
cycle_scope = _cctally()._claude_current_cycle_scope(
|
|
845
|
+
conn, now_utc, account_key=account_key)
|
|
846
|
+
refs, current_ref, scope = _scoped_refs(
|
|
847
|
+
conn, account_key, cycle_scope, now_utc)
|
|
848
|
+
ref = next((candidate for candidate in refs
|
|
849
|
+
if _claude_cycle_key(candidate) == key), None)
|
|
850
|
+
if ref is None:
|
|
851
|
+
return None
|
|
852
|
+
entry = next(
|
|
853
|
+
(e for e in build_claude_week_index(
|
|
854
|
+
conn, account_key=account_key, cycle_scope=scope,
|
|
855
|
+
now_utc=now_utc)
|
|
856
|
+
if e["key"] == key), None)
|
|
857
|
+
if entry is None:
|
|
858
|
+
return None
|
|
859
|
+
cohort_id = scope.reset_event_id if ref is current_ref else None
|
|
860
|
+
rows = _claude_cycle_rows(
|
|
861
|
+
conn, ref, account_key=account_key, cohort_id=cohort_id)
|
|
575
862
|
# Same absent-boundary case `_claude_cycle_key` handles, and this call runs
|
|
576
863
|
# unconditionally, ahead of the `if rows` guard below.
|
|
577
864
|
segment_key = dashboard_resource_key(
|
|
@@ -589,7 +876,8 @@ def build_claude_week_detail(conn: sqlite3.Connection, key: str) -> "dict | None
|
|
|
589
876
|
]}]
|
|
590
877
|
if rows else [])
|
|
591
878
|
gap_runs = _shape_observation_gap_runs(disclosure, rows) if rows else []
|
|
592
|
-
blocks = _build_blocks(conn, entry["start_at_utc"], entry["end_at_utc"]
|
|
879
|
+
blocks = _build_blocks(conn, entry["start_at_utc"], entry["end_at_utc"],
|
|
880
|
+
account_key=account_key)
|
|
593
881
|
|
|
594
882
|
return {
|
|
595
883
|
"source": "claude",
|