cctally 1.80.3 → 1.81.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 +29 -0
- package/bin/_cctally_account.py +397 -0
- package/bin/_cctally_alerts.py +58 -2
- package/bin/_cctally_cache.py +696 -166
- package/bin/_cctally_cache_report.py +21 -3
- package/bin/_cctally_config.py +119 -8
- package/bin/_cctally_core.py +343 -48
- package/bin/_cctally_dashboard.py +32 -7
- package/bin/_cctally_dashboard_conversation.py +6 -2
- package/bin/_cctally_dashboard_envelope.py +49 -0
- package/bin/_cctally_dashboard_share.py +78 -1
- package/bin/_cctally_dashboard_sources.py +459 -59
- package/bin/_cctally_db.py +659 -152
- package/bin/_cctally_diff.py +9 -0
- package/bin/_cctally_doctor.py +201 -26
- package/bin/_cctally_five_hour.py +110 -69
- package/bin/_cctally_forecast.py +112 -43
- package/bin/_cctally_journal.py +591 -80
- package/bin/_cctally_milestones.py +180 -67
- package/bin/_cctally_parser.py +87 -0
- package/bin/_cctally_percent_breakdown.py +24 -15
- package/bin/_cctally_project.py +12 -1
- package/bin/_cctally_quota.py +150 -39
- package/bin/_cctally_record.py +491 -141
- package/bin/_cctally_reporting.py +66 -14
- package/bin/_cctally_setup.py +9 -1
- package/bin/_cctally_source_analytics.py +56 -7
- package/bin/_cctally_statusline.py +55 -8
- package/bin/_cctally_store.py +20 -8
- package/bin/_cctally_sync_week.py +30 -10
- package/bin/_cctally_tui.py +99 -18
- package/bin/_cctally_weekrefs.py +38 -15
- package/bin/_lib_accounts.py +325 -0
- package/bin/_lib_alerts_payload.py +25 -4
- package/bin/_lib_cache_writer_lock.py +77 -0
- package/bin/_lib_codex_hooks.py +40 -1
- package/bin/_lib_diff_kernel.py +28 -14
- package/bin/_lib_doctor.py +160 -0
- package/bin/_lib_journal.py +65 -2
- package/bin/_lib_quota.py +81 -4
- package/bin/_lib_render.py +19 -5
- package/bin/_lib_share.py +25 -0
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_subscription_weeks.py +16 -2
- package/bin/_lib_view_models.py +18 -9
- package/bin/cctally +43 -4
- package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
- package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-B3y14l1o.js +0 -92
- package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
|
@@ -45,19 +45,23 @@ def _cctally():
|
|
|
45
45
|
# === moved verbatim from bin/cctally (Regions R1–R2) ===
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def _emit_daily_view_table_or_json(view, args):
|
|
48
|
+
def _emit_daily_view_table_or_json(view, args, *, extra=None):
|
|
49
49
|
"""Order + emit a DailyView as the flat daily table or {daily} JSON.
|
|
50
50
|
|
|
51
51
|
Shared by cmd_daily's default path and its -p-only (filter, no grouping)
|
|
52
52
|
path so the two cannot drift. Body is exactly the default path's order +
|
|
53
53
|
emit tail; callers keep their own --format share gate upstream of this.
|
|
54
|
+
|
|
55
|
+
``extra`` (#341 R8): JSON decoration merged into the emitted payload under an
|
|
56
|
+
explicit ``--account`` invocation; ``None``/empty keeps the render byte-stable.
|
|
54
57
|
"""
|
|
55
58
|
c = _cctally()
|
|
56
59
|
days = list(reversed(view.aggregated))
|
|
57
60
|
if args.order == "desc":
|
|
58
61
|
days = list(reversed(days))
|
|
59
62
|
if args.json:
|
|
60
|
-
print(c._bucket_to_json(days, list_key="daily", date_key="date"
|
|
63
|
+
print(c._bucket_to_json(days, list_key="daily", date_key="date",
|
|
64
|
+
extra=extra))
|
|
61
65
|
return
|
|
62
66
|
print(c._render_bucket_table(
|
|
63
67
|
days,
|
|
@@ -91,6 +95,15 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
91
95
|
return range
|
|
92
96
|
range_start, range_end = range
|
|
93
97
|
|
|
98
|
+
# #341 --account: resolve the render filter (provider=claude; the stamped-
|
|
99
|
+
# entry family fails closed with exit 3 when the entry cache is unavailable,
|
|
100
|
+
# since the direct-JSONL fallback carries no identity). None = merged
|
|
101
|
+
# (byte-identical). Threaded into every entry read below.
|
|
102
|
+
acct_key, acct_exit = c.resolve_account_filter(
|
|
103
|
+
args, "claude", needs_cache=True)
|
|
104
|
+
if acct_exit is not None:
|
|
105
|
+
return acct_exit
|
|
106
|
+
|
|
94
107
|
# ── Project-axis path (issue #86 Session E / T1.11) ────────────────────
|
|
95
108
|
# Gated by -i/--instances or -p/--project; the default path below is
|
|
96
109
|
# untouched/byte-stable. Mirrors cmd_project's I/O-layer git-root
|
|
@@ -99,7 +112,8 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
99
112
|
project_patterns = [p.lower() for p in (getattr(args, "project", None) or [])]
|
|
100
113
|
|
|
101
114
|
if getattr(args, "instances", False) or project_patterns:
|
|
102
|
-
joined = list(c.get_claude_session_entries(
|
|
115
|
+
joined = list(c.get_claude_session_entries(
|
|
116
|
+
range_start, range_end, account_key=acct_key))
|
|
103
117
|
resolver_cache: dict = {}
|
|
104
118
|
keyed: list = [] # [(ProjectKey, UsageEntry)] — for -i grouping
|
|
105
119
|
filtered_uentries: list = [] # UsageEntry — for -p-only / --format / debug
|
|
@@ -176,7 +190,9 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
176
190
|
json_groups.append((json_label, ordered))
|
|
177
191
|
table_groups.append((table_label, ordered))
|
|
178
192
|
if args.json:
|
|
179
|
-
print(c._bucket_by_project_to_json(
|
|
193
|
+
print(c._bucket_by_project_to_json(
|
|
194
|
+
json_groups, date_key="date",
|
|
195
|
+
extra=c.account_json_fields(acct_key))) # #341 R8
|
|
180
196
|
return 0
|
|
181
197
|
print(c._render_bucket_table(
|
|
182
198
|
[], first_col_name="Date", title_suffix="Daily",
|
|
@@ -190,12 +206,13 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
190
206
|
# -p only (no -i): filter-only → normal date-aggregated daily output.
|
|
191
207
|
view = c.build_daily_view(filtered_uentries, now_utc=_command_as_of(),
|
|
192
208
|
display_tz=tz, mode=args.mode)
|
|
193
|
-
_emit_daily_view_table_or_json(
|
|
209
|
+
_emit_daily_view_table_or_json(
|
|
210
|
+
view, args, extra=c.account_json_fields(acct_key)) # #341 R8
|
|
194
211
|
return 0
|
|
195
212
|
|
|
196
213
|
# ── Default path (UNCHANGED) ───────────────────────────────────────────
|
|
197
214
|
# Collect entries.
|
|
198
|
-
all_entries = c.get_entries(range_start, range_end)
|
|
215
|
+
all_entries = c.get_entries(range_start, range_end, account_key=acct_key)
|
|
199
216
|
|
|
200
217
|
c._emit_debug_samples_if_set(
|
|
201
218
|
args, all_entries, command_label="daily",
|
|
@@ -243,7 +260,8 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
243
260
|
# Order + emit the flat daily table / {daily} JSON. Extracted into
|
|
244
261
|
# `_emit_daily_view_table_or_json` so this default path and the
|
|
245
262
|
# -p-only (filter, no grouping) path above stay byte-identical.
|
|
246
|
-
_emit_daily_view_table_or_json(
|
|
263
|
+
_emit_daily_view_table_or_json(
|
|
264
|
+
view, args, extra=c.account_json_fields(acct_key)) # #341 R8
|
|
247
265
|
return 0
|
|
248
266
|
|
|
249
267
|
|
|
@@ -265,7 +283,12 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
265
283
|
return range
|
|
266
284
|
range_start, range_end = range
|
|
267
285
|
|
|
268
|
-
|
|
286
|
+
acct_key, acct_exit = c.resolve_account_filter(
|
|
287
|
+
args, "claude", needs_cache=True) # #341 --account (see cmd_daily)
|
|
288
|
+
if acct_exit is not None:
|
|
289
|
+
return acct_exit
|
|
290
|
+
|
|
291
|
+
all_entries = c.get_entries(range_start, range_end, account_key=acct_key)
|
|
269
292
|
|
|
270
293
|
c._emit_debug_samples_if_set(
|
|
271
294
|
args, all_entries, command_label="monthly",
|
|
@@ -314,7 +337,9 @@ def cmd_monthly(args: argparse.Namespace) -> int:
|
|
|
314
337
|
months = list(reversed(months))
|
|
315
338
|
|
|
316
339
|
if args.json:
|
|
317
|
-
print(c._bucket_to_json(
|
|
340
|
+
print(c._bucket_to_json(
|
|
341
|
+
months, list_key="monthly", date_key="month",
|
|
342
|
+
extra=c.account_json_fields(acct_key))) # #341 R8
|
|
318
343
|
return 0
|
|
319
344
|
|
|
320
345
|
print(c._render_bucket_table(
|
|
@@ -336,6 +361,13 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
336
361
|
c._bridge_z_into_tz(args, config)
|
|
337
362
|
args._resolved_tz = c.resolve_display_tz(args, config)
|
|
338
363
|
|
|
364
|
+
# #341 --account: resolve the render filter (provider=claude; fail closed
|
|
365
|
+
# with exit 3 when the entry cache is unavailable, since weekly cost is
|
|
366
|
+
# recomputed from session_entries). None = merged / byte-stable.
|
|
367
|
+
acct_key, acct_exit = c.resolve_account_filter(args, "claude", needs_cache=True)
|
|
368
|
+
if acct_exit is not None:
|
|
369
|
+
return acct_exit
|
|
370
|
+
|
|
339
371
|
now_utc = _command_as_of()
|
|
340
372
|
range = c._parse_cli_date_range(args, now_utc=now_utc)
|
|
341
373
|
if isinstance(range, int):
|
|
@@ -351,6 +383,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
351
383
|
# calendar-week fallback uses the explicit override's `week_start`.
|
|
352
384
|
weeks = c._compute_subscription_weeks(
|
|
353
385
|
conn, range_start, range_end, config=config,
|
|
386
|
+
account_key=acct_key, # #341: None = merged (all-accounts) read
|
|
354
387
|
)
|
|
355
388
|
|
|
356
389
|
# Fetch entries and aggregate.
|
|
@@ -368,7 +401,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
368
401
|
)
|
|
369
402
|
else:
|
|
370
403
|
fetch_start = range_start
|
|
371
|
-
all_entries = c.get_entries(fetch_start, range_end)
|
|
404
|
+
all_entries = c.get_entries(fetch_start, range_end, account_key=acct_key)
|
|
372
405
|
|
|
373
406
|
c._emit_debug_samples_if_set(
|
|
374
407
|
args, all_entries, command_label="weekly",
|
|
@@ -399,6 +432,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
399
432
|
view = c.build_weekly_view(
|
|
400
433
|
conn, all_entries, weeks=weeks, now_utc=now_utc,
|
|
401
434
|
display_tz=args._resolved_tz, as_of_utc=as_of_utc, mode=args.mode,
|
|
435
|
+
account_key=acct_key,
|
|
402
436
|
)
|
|
403
437
|
buckets = list(reversed(view.aggregated))
|
|
404
438
|
overlay = list(reversed(view.overlay))
|
|
@@ -438,7 +472,10 @@ def cmd_weekly(args: argparse.Namespace) -> int:
|
|
|
438
472
|
overlay = list(reversed(overlay))
|
|
439
473
|
|
|
440
474
|
if args.json:
|
|
441
|
-
print(c._weekly_to_json(
|
|
475
|
+
print(c._weekly_to_json(
|
|
476
|
+
buckets, weeks, overlay,
|
|
477
|
+
extra=c.account_json_fields(acct_key), # #341 R8 decoration
|
|
478
|
+
))
|
|
442
479
|
return 0
|
|
443
480
|
|
|
444
481
|
if not buckets:
|
|
@@ -474,7 +511,13 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
474
511
|
return range
|
|
475
512
|
range_start, range_end = range
|
|
476
513
|
|
|
477
|
-
|
|
514
|
+
acct_key, acct_exit = c.resolve_account_filter(
|
|
515
|
+
args, "claude", needs_cache=True) # #341 --account (see cmd_daily)
|
|
516
|
+
if acct_exit is not None:
|
|
517
|
+
return acct_exit
|
|
518
|
+
|
|
519
|
+
entries = c.get_claude_session_entries(
|
|
520
|
+
range_start, range_end, account_key=acct_key)
|
|
478
521
|
|
|
479
522
|
# Issue #89: --debug report describes the joined-entry list filtered
|
|
480
523
|
# by --id (post-fallback session_id resolution) when set, matching
|
|
@@ -581,7 +624,8 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
581
624
|
sessions = list(reversed(sessions))
|
|
582
625
|
|
|
583
626
|
if args.json:
|
|
584
|
-
print(c._claude_sessions_to_json(
|
|
627
|
+
print(c._claude_sessions_to_json(
|
|
628
|
+
sessions, extra=c.account_json_fields(acct_key))) # #341 R8
|
|
585
629
|
return 0
|
|
586
630
|
|
|
587
631
|
if not sessions:
|
|
@@ -622,6 +666,11 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
|
622
666
|
eprint("Error: --end must be after --start")
|
|
623
667
|
return 1
|
|
624
668
|
|
|
669
|
+
acct_key, acct_exit = c.resolve_account_filter(
|
|
670
|
+
args, "claude", needs_cache=True) # #341 --account (see cmd_daily)
|
|
671
|
+
if acct_exit is not None:
|
|
672
|
+
return acct_exit
|
|
673
|
+
|
|
625
674
|
total_cost = 0.0
|
|
626
675
|
matched_entries = 0
|
|
627
676
|
first_match: dt.datetime | None = None
|
|
@@ -633,7 +682,8 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
|
633
682
|
# applied at the loader (SELECT-time), so the scope is the same.
|
|
634
683
|
# P2.2 (issue #89 review-loop): get_entries already returns
|
|
635
684
|
# list[UsageEntry] per bin/_cctally_cache.py:1224 — no list() wrap.
|
|
636
|
-
entries_list = c.get_entries(start_dt, end_dt, project=args.project
|
|
685
|
+
entries_list = c.get_entries(start_dt, end_dt, project=args.project,
|
|
686
|
+
account_key=acct_key)
|
|
637
687
|
c._emit_debug_samples_if_set(
|
|
638
688
|
args, entries_list, command_label="range-cost",
|
|
639
689
|
)
|
|
@@ -702,6 +752,8 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
|
|
|
702
752
|
),
|
|
703
753
|
"modelBreakdowns": breakdowns,
|
|
704
754
|
}
|
|
755
|
+
# #341 R8: conditional account decoration under an explicit --account.
|
|
756
|
+
output.update(c.account_json_fields(acct_key))
|
|
705
757
|
payload = c.stamp_schema_version(output)
|
|
706
758
|
sink = getattr(args, "_source_result_sink", None)
|
|
707
759
|
if sink is not None:
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -2726,7 +2726,15 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
2726
2726
|
try:
|
|
2727
2727
|
cache_conn = c.open_cache_db()
|
|
2728
2728
|
try:
|
|
2729
|
-
|
|
2729
|
+
cache_mod = c._load_sibling("_cctally_cache")
|
|
2730
|
+
stats, cache_conn = (
|
|
2731
|
+
cache_mod._run_cache_operation_with_recovery(
|
|
2732
|
+
cache_conn,
|
|
2733
|
+
lambda active_conn: c.sync_cache(
|
|
2734
|
+
active_conn, progress=progress.sync_callback
|
|
2735
|
+
),
|
|
2736
|
+
)
|
|
2737
|
+
)
|
|
2730
2738
|
finally:
|
|
2731
2739
|
try:
|
|
2732
2740
|
cache_conn.close()
|
|
@@ -20,6 +20,7 @@ from _cctally_core import (
|
|
|
20
20
|
parse_iso_datetime,
|
|
21
21
|
)
|
|
22
22
|
from _cctally_cache import _codex_provider_roots
|
|
23
|
+
import _lib_accounts
|
|
23
24
|
from _lib_quota import build_blocks
|
|
24
25
|
from _lib_pricing import _calculate_codex_entry_cost
|
|
25
26
|
from _cctally_quota import load_codex_quota_observations
|
|
@@ -81,6 +82,10 @@ class RootedCodexAccountingEntry:
|
|
|
81
82
|
reasoning_output_tokens: int
|
|
82
83
|
total_tokens: int
|
|
83
84
|
cost_usd: float
|
|
85
|
+
# #341 Task 4: the ingest-stamped account (NULL in cache.db reads as the
|
|
86
|
+
# reserved ``unattributed`` sentinel). Carried so the per-account hero cards
|
|
87
|
+
# can scope spend without a second query shape.
|
|
88
|
+
account_key: str = _lib_accounts.UNATTRIBUTED
|
|
84
89
|
|
|
85
90
|
|
|
86
91
|
_OPAQUE_PROJECT_KEY_RE = re.compile(r"^project:[0-9a-f]{24}$")
|
|
@@ -131,14 +136,23 @@ _CODEX_ACCOUNTING_ENTRIES_SQL = """
|
|
|
131
136
|
"""
|
|
132
137
|
|
|
133
138
|
|
|
139
|
+
# P3-4 (#341 Task 4 review): the `account_key` column is selected unguarded — it
|
|
140
|
+
# is NOT behind a `has_columns(...)` check like some other account-aware reads.
|
|
141
|
+
# This is safe by construction: `add_column_if_missing(conn, "codex_session_entries",
|
|
142
|
+
# "account_key", ...)` runs on every cache.db open (the pure column-addition
|
|
143
|
+
# schema-evolution path, spec §2), so the column is guaranteed present before any
|
|
144
|
+
# reader can reach this SQL. Defense-in-depth note only; do NOT add a runtime
|
|
145
|
+
# guard here (it would mask a genuinely broken/un-migrated cache instead of failing
|
|
146
|
+
# loudly, and every production reader opens the cache through that migration path).
|
|
134
147
|
_ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL = """
|
|
135
148
|
SELECT timestamp_utc, session_id, source_path, source_root_key, model,
|
|
136
149
|
input_tokens, cached_input_tokens, output_tokens,
|
|
137
|
-
reasoning_output_tokens, total_tokens
|
|
150
|
+
reasoning_output_tokens, total_tokens, account_key
|
|
138
151
|
FROM codex_session_entries INDEXED BY idx_codex_entries_ts_root_conversation
|
|
139
152
|
WHERE timestamp_utc >= ?
|
|
140
153
|
AND timestamp_utc < ?
|
|
141
154
|
{root_predicate}
|
|
155
|
+
{account_predicate}
|
|
142
156
|
ORDER BY timestamp_utc ASC, source_root_key ASC, conversation_key ASC, id ASC
|
|
143
157
|
"""
|
|
144
158
|
|
|
@@ -274,6 +288,7 @@ def load_cached_rooted_codex_accounting_entries(
|
|
|
274
288
|
speed: str,
|
|
275
289
|
cache_conn: sqlite3.Connection,
|
|
276
290
|
source_root_keys: Iterable[str] | None = None,
|
|
291
|
+
account_key: str | None = None,
|
|
277
292
|
) -> tuple[RootedCodexAccountingEntry, ...]:
|
|
278
293
|
"""Read bounded accounting from one caller-owned cache snapshot only.
|
|
279
294
|
|
|
@@ -282,6 +297,11 @@ def load_cached_rooted_codex_accounting_entries(
|
|
|
282
297
|
cached root key plus source path are the dashboard's file identity. A
|
|
283
298
|
caller can additionally constrain the read to roots that established one
|
|
284
299
|
native cycle; root identities remain internal to this adapter.
|
|
300
|
+
|
|
301
|
+
``account_key`` (#341 Task 4) optionally scopes the read to one account; the
|
|
302
|
+
reserved ``unattributed`` sentinel matches rows whose ``account_key`` is NULL
|
|
303
|
+
(``NULL ≡ unattributed`` — the cache-read rule). ``None`` reads every account
|
|
304
|
+
(byte-stable — the default merged read).
|
|
285
305
|
"""
|
|
286
306
|
if start.tzinfo is None or start.utcoffset() is None:
|
|
287
307
|
raise ValueError("start must be timezone-aware")
|
|
@@ -299,11 +319,22 @@ def load_cached_rooted_codex_accounting_entries(
|
|
|
299
319
|
"AND source_root_key IN (" + ", ".join("?" for _ in root_keys) + ")"
|
|
300
320
|
if source_root_keys is not None else ""
|
|
301
321
|
)
|
|
322
|
+
account_params: tuple = ()
|
|
323
|
+
if account_key is None:
|
|
324
|
+
account_predicate = ""
|
|
325
|
+
elif account_key == _lib_accounts.UNATTRIBUTED:
|
|
326
|
+
account_predicate = "AND (account_key IS NULL OR account_key = ?)"
|
|
327
|
+
account_params = (_lib_accounts.UNATTRIBUTED,)
|
|
328
|
+
else:
|
|
329
|
+
account_predicate = "AND account_key = ?"
|
|
330
|
+
account_params = (account_key,)
|
|
302
331
|
try:
|
|
303
332
|
rows = tuple(cache_conn.execute(
|
|
304
|
-
_ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL.format(
|
|
333
|
+
_ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL.format(
|
|
334
|
+
root_predicate=root_predicate, account_predicate=account_predicate),
|
|
305
335
|
(
|
|
306
|
-
start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(),
|
|
336
|
+
start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(),
|
|
337
|
+
*root_keys, *account_params,
|
|
307
338
|
),
|
|
308
339
|
))
|
|
309
340
|
except sqlite3.Error as exc:
|
|
@@ -315,12 +346,16 @@ def load_cached_rooted_codex_accounting_entries(
|
|
|
315
346
|
(
|
|
316
347
|
timestamp_raw, session_id_raw, source_path_raw, source_root_key_raw,
|
|
317
348
|
model_raw, input_raw, cached_input_raw, output_raw, reasoning_raw,
|
|
318
|
-
total_raw,
|
|
349
|
+
total_raw, account_key_raw,
|
|
319
350
|
) = row
|
|
320
351
|
source_path = source_path_raw if isinstance(source_path_raw, str) else ""
|
|
321
352
|
source_root_key = source_root_key_raw if isinstance(source_root_key_raw, str) else ""
|
|
322
353
|
if not source_path or not source_root_key:
|
|
323
354
|
raise ValueError("rooted accounting identity is absent")
|
|
355
|
+
entry_account_key = (
|
|
356
|
+
account_key_raw if isinstance(account_key_raw, str) and account_key_raw
|
|
357
|
+
else _lib_accounts.UNATTRIBUTED
|
|
358
|
+
)
|
|
324
359
|
model = str(model_raw)
|
|
325
360
|
input_tokens = int(input_raw)
|
|
326
361
|
cached_input_tokens = int(cached_input_raw)
|
|
@@ -349,6 +384,7 @@ def load_cached_rooted_codex_accounting_entries(
|
|
|
349
384
|
reasoning_output_tokens=reasoning_output_tokens,
|
|
350
385
|
total_tokens=total_tokens,
|
|
351
386
|
cost_usd=cost_usd,
|
|
387
|
+
account_key=entry_account_key,
|
|
352
388
|
))
|
|
353
389
|
return tuple(result)
|
|
354
390
|
|
|
@@ -451,7 +487,10 @@ def load_qualified_codex_entries(
|
|
|
451
487
|
previous_row_factory = conn.row_factory
|
|
452
488
|
try:
|
|
453
489
|
if sync:
|
|
454
|
-
|
|
490
|
+
cache_mod = c._load_sibling("_cctally_cache")
|
|
491
|
+
stats, conn = cache_mod._run_cache_operation_with_recovery(
|
|
492
|
+
conn, lambda active_conn: c.sync_codex_cache(active_conn)
|
|
493
|
+
)
|
|
455
494
|
if stats.lock_contended:
|
|
456
495
|
raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable")
|
|
457
496
|
conn.row_factory = sqlite3.Row
|
|
@@ -624,11 +663,15 @@ def load_codex_accounting_entries(
|
|
|
624
663
|
return tuple(result)
|
|
625
664
|
|
|
626
665
|
|
|
627
|
-
def _identity_sort_key(identity: object) -> tuple[str, str, str, str, int]:
|
|
666
|
+
def _identity_sort_key(identity: object) -> tuple[str, str, str, str, int, str]:
|
|
667
|
+
# account_key (#341) is appended LAST so single-account ordering is
|
|
668
|
+
# byte-identical (the tiebreak is constant) while a multi-account install
|
|
669
|
+
# gets a deterministic tiebreak between two accounts' windows.
|
|
628
670
|
return (
|
|
629
671
|
str(identity.source), str(identity.source_root_key),
|
|
630
672
|
str(identity.logical_limit_key), str(identity.observed_slot),
|
|
631
673
|
int(identity.window_minutes),
|
|
674
|
+
str(getattr(identity, "account_key", "unattributed")),
|
|
632
675
|
)
|
|
633
676
|
|
|
634
677
|
|
|
@@ -2286,7 +2329,13 @@ def cmd_source_report(args: object) -> int:
|
|
|
2286
2329
|
try:
|
|
2287
2330
|
cache = c.open_cache_db()
|
|
2288
2331
|
try:
|
|
2289
|
-
|
|
2332
|
+
cache_mod = c._load_sibling("_cctally_cache")
|
|
2333
|
+
sync_stats, cache = (
|
|
2334
|
+
cache_mod._run_cache_operation_with_recovery(
|
|
2335
|
+
cache,
|
|
2336
|
+
lambda active_conn: c.sync_codex_cache(active_conn),
|
|
2337
|
+
)
|
|
2338
|
+
)
|
|
2290
2339
|
finally:
|
|
2291
2340
|
cache.close()
|
|
2292
2341
|
c.reconcile_codex_quota_projection(now=as_of)
|
|
@@ -613,22 +613,59 @@ def _epoch_from_iso(value: object) -> int:
|
|
|
613
613
|
return int(_cctally_core.parse_iso_datetime(str(value), "statusline projection").timestamp())
|
|
614
614
|
|
|
615
615
|
|
|
616
|
+
def _statusline_active_account() -> "str | None":
|
|
617
|
+
"""The active Claude ``account_key`` to scope the statusline projection to
|
|
618
|
+
(#341, spec §3 write-path semantics — statusline resolves the active account
|
|
619
|
+
once per read and scopes its reset-aware clamps to it).
|
|
620
|
+
|
|
621
|
+
Torn-handling (reviewer-specified): a TORN ``~/.claude.json`` read keeps the
|
|
622
|
+
merged (``None``) behavior — never mis-stamp against a half-written file;
|
|
623
|
+
*identified* returns the real key; *stably-absent* / api-key returns the
|
|
624
|
+
``unattributed`` sentinel. For a <=1-real-account install every stats row
|
|
625
|
+
defaults to ``unattributed``, so the scoped clamp matches all of them and
|
|
626
|
+
the rendered output stays byte-frozen (R8). Best-effort / never-raise: any
|
|
627
|
+
failure falls back to the merged read."""
|
|
628
|
+
try:
|
|
629
|
+
ident = _cctally_core._resolve_active_claude_identity()
|
|
630
|
+
return None if ident.get("status") == "torn" else ident.get("account_key")
|
|
631
|
+
except Exception:
|
|
632
|
+
return None
|
|
633
|
+
|
|
634
|
+
|
|
616
635
|
def _read_db_projection_once() -> "_candidates.DbProjection":
|
|
617
636
|
conn = open_db()
|
|
637
|
+
_sl_account = _statusline_active_account()
|
|
638
|
+
# #341 (spec §3 write-path): the statusline resolves the active account once
|
|
639
|
+
# per read and scopes its ENTIRE projection to it — candidate selection AND
|
|
640
|
+
# grouping, not only the reset-aware clamps. Scoping the two candidate
|
|
641
|
+
# SELECTs here means one account's interleaved snapshots can never become
|
|
642
|
+
# another account's projection candidate. Same three-valued semantics as
|
|
643
|
+
# `_reset_aware_floor`: a real key / the `unattributed` sentinel scopes to
|
|
644
|
+
# `account_key = ?`; `None` (TORN read) keeps the merged/last-known set —
|
|
645
|
+
# never mis-scope against a half-written identity file. For a <=1-real-account
|
|
646
|
+
# install every stats row defaults to `unattributed` and the active read
|
|
647
|
+
# resolves to `unattributed`, so the predicate matches all rows and the
|
|
648
|
+
# rendered output stays byte-frozen (R8).
|
|
649
|
+
_acct_pred = "" if _sl_account is None else " AND account_key = ?"
|
|
650
|
+
_acct_params: tuple = () if _sl_account is None else (_sl_account,)
|
|
618
651
|
try:
|
|
619
652
|
weekly_rows = conn.execute(
|
|
620
653
|
"SELECT id, weekly_percent, week_start_date, week_start_at, week_end_at, "
|
|
621
654
|
" captured_at_utc, source "
|
|
622
655
|
"FROM weekly_usage_snapshots "
|
|
623
|
-
"WHERE weekly_percent IS NOT NULL AND week_end_at IS NOT NULL
|
|
624
|
-
|
|
656
|
+
"WHERE weekly_percent IS NOT NULL AND week_end_at IS NOT NULL"
|
|
657
|
+
+ _acct_pred +
|
|
658
|
+
" ORDER BY unixepoch(captured_at_utc) DESC, id DESC",
|
|
659
|
+
_acct_params,
|
|
625
660
|
).fetchall()
|
|
626
661
|
five_rows = conn.execute(
|
|
627
662
|
"SELECT id, five_hour_percent, five_hour_resets_at, five_hour_window_key, "
|
|
628
663
|
" captured_at_utc, source "
|
|
629
664
|
"FROM weekly_usage_snapshots "
|
|
630
|
-
"WHERE five_hour_percent IS NOT NULL AND five_hour_resets_at IS NOT NULL
|
|
631
|
-
|
|
665
|
+
"WHERE five_hour_percent IS NOT NULL AND five_hour_resets_at IS NOT NULL"
|
|
666
|
+
+ _acct_pred +
|
|
667
|
+
" ORDER BY unixepoch(captured_at_utc) DESC, id DESC",
|
|
668
|
+
_acct_params,
|
|
632
669
|
).fetchall()
|
|
633
670
|
|
|
634
671
|
weekly_groups: dict[int, list] = {}
|
|
@@ -660,6 +697,7 @@ def _read_db_projection_once() -> "_candidates.DbProjection":
|
|
|
660
697
|
str(reference["week_start_date"]),
|
|
661
698
|
str(week_start_at),
|
|
662
699
|
str(week_end_at),
|
|
700
|
+
account_key=_sl_account,
|
|
663
701
|
)
|
|
664
702
|
floor_epoch = _epoch_from_iso(floor) if floor else 0
|
|
665
703
|
eligible = [
|
|
@@ -1573,6 +1611,14 @@ def _build_statusline_injections(warn_once):
|
|
|
1573
1611
|
c = _cctally()
|
|
1574
1612
|
five_hwm = None
|
|
1575
1613
|
seven_hwm = None
|
|
1614
|
+
# #341 (spec §3): scope the 7d reset-aware clamp to the active Claude
|
|
1615
|
+
# account (torn -> merged None; identified -> real key; absent ->
|
|
1616
|
+
# `unattributed`). Byte-frozen for <=1 real account (all rows default
|
|
1617
|
+
# `unattributed`). `_resolve_active_claude_identity` is mtime-cached, so
|
|
1618
|
+
# resolving here (as well as in `_read_db_projection_once`) is a cache hit.
|
|
1619
|
+
_sl_account = _statusline_active_account()
|
|
1620
|
+
_acct_pred = "" if _sl_account is None else " AND account_key = ?"
|
|
1621
|
+
_acct_params = () if _sl_account is None else (_sl_account,)
|
|
1576
1622
|
try:
|
|
1577
1623
|
conn = open_db()
|
|
1578
1624
|
except Exception:
|
|
@@ -1629,21 +1675,22 @@ def _build_statusline_injections(warn_once):
|
|
|
1629
1675
|
floor_iso = c._reset_aware_floor(
|
|
1630
1676
|
conn, week_start_date,
|
|
1631
1677
|
week_start_dt.isoformat(), week_end_dt.isoformat(),
|
|
1678
|
+
account_key=_sl_account,
|
|
1632
1679
|
)
|
|
1633
1680
|
if floor_iso is not None:
|
|
1634
1681
|
row = conn.execute(
|
|
1635
1682
|
"SELECT MAX(weekly_percent) "
|
|
1636
1683
|
"FROM weekly_usage_snapshots "
|
|
1637
|
-
"WHERE week_start_date = ? "
|
|
1684
|
+
"WHERE week_start_date = ?" + _acct_pred + " "
|
|
1638
1685
|
" AND unixepoch(captured_at_utc) >= unixepoch(?)",
|
|
1639
|
-
(week_start_date, floor_iso),
|
|
1686
|
+
(week_start_date, *_acct_params, floor_iso),
|
|
1640
1687
|
).fetchone()
|
|
1641
1688
|
else:
|
|
1642
1689
|
row = conn.execute(
|
|
1643
1690
|
"SELECT MAX(weekly_percent) "
|
|
1644
1691
|
"FROM weekly_usage_snapshots "
|
|
1645
|
-
"WHERE week_start_date = ?",
|
|
1646
|
-
(week_start_date,),
|
|
1692
|
+
"WHERE week_start_date = ?" + _acct_pred,
|
|
1693
|
+
(week_start_date, *_acct_params),
|
|
1647
1694
|
).fetchone()
|
|
1648
1695
|
if row and row[0] is not None:
|
|
1649
1696
|
seven_hwm = float(row[0])
|
package/bin/_cctally_store.py
CHANGED
|
@@ -33,11 +33,11 @@ once ``user_version == registry head``. Add the column via a registered
|
|
|
33
33
|
migration (which bumps the head and re-triggers the apply) instead.
|
|
34
34
|
|
|
35
35
|
**Lock-order law** (spec §5.2 / §6.4; asserted here as documentation, exercised
|
|
36
|
-
by the storm test): maintenance flocks → ``journal.ingest.lock`` →
|
|
37
|
-
|
|
38
|
-
Codex) → SQLite transactions → ``journal.lock``
|
|
39
|
-
ingest lock while holding a provider
|
|
40
|
-
spans a flock acquisition.
|
|
36
|
+
by the storm test): maintenance flocks → ``journal.ingest.lock`` → the global
|
|
37
|
+
``cache.db.lock`` writer flock → the cache Codex provider flock → conversation
|
|
38
|
+
provider flocks (Claude → Codex) → SQLite transactions → ``journal.lock``
|
|
39
|
+
(leaf). Never acquire the ingest lock while holding a cache writer/provider
|
|
40
|
+
flock; no SQLite write transaction ever spans a flock acquisition.
|
|
41
41
|
|
|
42
42
|
**Raw-connect escape hatches stay OUT of this module by design** (spec §6.1):
|
|
43
43
|
``db checkpoint``'s ``mode=rw`` connect, ``db vacuum``'s exclusive connect, and
|
|
@@ -158,6 +158,12 @@ def apply_policy(conn: sqlite3.Connection, store: str) -> None:
|
|
|
158
158
|
if policy.auto_vacuum is not None:
|
|
159
159
|
conn.execute(f"PRAGMA auto_vacuum={policy.auto_vacuum}")
|
|
160
160
|
conn.execute(f"PRAGMA journal_mode={policy.journal_mode}")
|
|
161
|
+
apply_connection_policy(conn, store)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def apply_connection_policy(conn: sqlite3.Connection, store: str) -> None:
|
|
165
|
+
"""Apply non-schema connection settings without changing journal mode."""
|
|
166
|
+
policy = STORE_POLICY[store]
|
|
161
167
|
conn.execute(f"PRAGMA synchronous={policy.synchronous}")
|
|
162
168
|
conn.execute(f"PRAGMA busy_timeout={policy.busy_timeout}")
|
|
163
169
|
conn.execute(f"PRAGMA journal_size_limit={policy.journal_size_limit}")
|
|
@@ -497,10 +503,16 @@ def resolve_stats_epoch_mismatch():
|
|
|
497
503
|
"present to rebuild from. The journal/ directory is the "
|
|
498
504
|
"durable source — restore it from backup, then run "
|
|
499
505
|
"`cctally db rebuild --db stats`.")
|
|
500
|
-
# Preserve the version-ahead DB (nothing deleted), then
|
|
501
|
-
#
|
|
506
|
+
# Preserve the version-ahead DB (nothing deleted), then run the
|
|
507
|
+
# #341 account epoch-transition coordinator into the now-absent
|
|
508
|
+
# destination: resolve the cutover identity (no stats.db open),
|
|
509
|
+
# append the canonical cutover op, then rebuild account-scoped
|
|
510
|
+
# (spec §2 — op strictly before the rebuild HW). A same-epoch or
|
|
511
|
+
# legacy DB never reaches here; a fresh cutover install stamps the
|
|
512
|
+
# epoch directly (run_cutover), so this path is the 1000->1001
|
|
513
|
+
# upgrade of an already-journaled install.
|
|
502
514
|
_cctally_db.quarantine_db_family(path)
|
|
503
|
-
_cctally_journal.
|
|
515
|
+
_cctally_journal.run_epoch_transition()
|
|
504
516
|
finally:
|
|
505
517
|
_heal_release_flock(maint_fd)
|
|
506
518
|
return _cctally_core.open_db()
|
|
@@ -44,12 +44,13 @@ from _cctally_core import (
|
|
|
44
44
|
make_week_ref,
|
|
45
45
|
get_latest_usage_for_week,
|
|
46
46
|
now_utc_iso,
|
|
47
|
+
_resolve_active_claude_account,
|
|
47
48
|
)
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def cmd_sync_week(
|
|
51
52
|
args: argparse.Namespace, *, conn=None, as_of: "str | None" = None,
|
|
52
|
-
journal: "tuple | None" = None,
|
|
53
|
+
journal: "tuple | None" = None, account_key: str = "unattributed",
|
|
53
54
|
) -> int:
|
|
54
55
|
"""Compute + persist the week's cost snapshot.
|
|
55
56
|
|
|
@@ -103,6 +104,11 @@ def cmd_sync_week(
|
|
|
103
104
|
project=args.project,
|
|
104
105
|
start_iso_override=selection.start_iso_override,
|
|
105
106
|
end_iso_override=selection.end_iso_override,
|
|
107
|
+
# #341 P2-CQ2: the cost VALUE is scoped to the same account the
|
|
108
|
+
# snapshot is stamped for (below), so a per-account snapshot no longer
|
|
109
|
+
# carries the merged cost of every account sharing the week. Default
|
|
110
|
+
# `unattributed` matches all-NULL legacy rows -> byte-stable.
|
|
111
|
+
account_key=account_key,
|
|
106
112
|
)
|
|
107
113
|
week_start_at = selection.start_iso_override or format_local_iso(week_start, end_of_day=False)
|
|
108
114
|
week_end_at = selection.end_iso_override or format_local_iso(week_end, end_of_day=True)
|
|
@@ -120,6 +126,11 @@ def cmd_sync_week(
|
|
|
120
126
|
commit=own_conn,
|
|
121
127
|
as_of=as_of,
|
|
122
128
|
journal=journal,
|
|
129
|
+
# Per-account cost materialization (#341 P2-1, spec §3): the snapshot
|
|
130
|
+
# lands under the active/crossing Claude account so the account-scoped
|
|
131
|
+
# milestone cost read + `_reset_aware_floor` see it. Default
|
|
132
|
+
# `unattributed` keeps single-account / legacy installs byte-stable.
|
|
133
|
+
account_key=account_key,
|
|
123
134
|
)
|
|
124
135
|
|
|
125
136
|
week_ref = make_week_ref(
|
|
@@ -178,19 +189,28 @@ def _cmd_sync_week_via_ingest(c, args: argparse.Namespace) -> int:
|
|
|
178
189
|
dispatch maps it to the same exit code."""
|
|
179
190
|
import _cctally_journal as _jr
|
|
180
191
|
import _lib_journal as _lj
|
|
192
|
+
import _lib_accounts
|
|
181
193
|
|
|
194
|
+
# Per-account materialization (#341 P2-1, spec §3): resolve the active Claude
|
|
195
|
+
# account and carry it in the op payload so the fold stamps the cost snapshot
|
|
196
|
+
# under it (rebuild-deterministic). Byte-safe: the reserved sentinel OMITS
|
|
197
|
+
# the field, keeping a single-account / no-`~/.claude.json` op line identical.
|
|
198
|
+
_sync_account = _resolve_active_claude_account()
|
|
199
|
+
_sync_payload = {
|
|
200
|
+
"kind": "sync_week",
|
|
201
|
+
"week_start": args.week_start,
|
|
202
|
+
"week_end": args.week_end,
|
|
203
|
+
"week_start_name": args.week_start_name,
|
|
204
|
+
"mode": args.mode,
|
|
205
|
+
"offline": args.offline,
|
|
206
|
+
"project": args.project,
|
|
207
|
+
}
|
|
208
|
+
if _sync_account != _lib_accounts.UNATTRIBUTED:
|
|
209
|
+
_sync_payload["account_key"] = _sync_account
|
|
182
210
|
op = _lj.make_op(
|
|
183
211
|
at=now_utc_iso(),
|
|
184
212
|
src="sync-week",
|
|
185
|
-
payload=
|
|
186
|
-
"kind": "sync_week",
|
|
187
|
-
"week_start": args.week_start,
|
|
188
|
-
"week_end": args.week_end,
|
|
189
|
-
"week_start_name": args.week_start_name,
|
|
190
|
-
"mode": args.mode,
|
|
191
|
-
"offline": args.offline,
|
|
192
|
-
"project": args.project,
|
|
193
|
-
},
|
|
213
|
+
payload=_sync_payload,
|
|
194
214
|
)
|
|
195
215
|
_jr.append_record(op)
|
|
196
216
|
res = _jr.run_stats_ingest(mode="authoritative")
|