cctally 1.7.0 → 1.7.1
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 +7 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5218 -0
- package/bin/_cctally_db.py +1729 -0
- package/bin/_cctally_record.py +2120 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4381 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +414 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +58 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11034 -35415
- package/package.json +24 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Display-timezone primitives — the datetime render chokepoint.
|
|
2
|
+
|
|
3
|
+
Pure-fn layer (no I/O at import time): holds every helper that resolves a
|
|
4
|
+
display tz from CLI args / config, localizes a datetime through that
|
|
5
|
+
resolution, and formats it for human display. `format_display_dt` is the
|
|
6
|
+
chokepoint per CLAUDE.md — all human-displayed datetimes route through it.
|
|
7
|
+
|
|
8
|
+
`bin/cctally` re-exports every symbol below so internal call sites resolve
|
|
9
|
+
unchanged. Future pure layers (alerts payload, render, aggregators) import
|
|
10
|
+
the chokepoint from here directly via `_load_sibling("_lib_display_tz")`,
|
|
11
|
+
so they stay pure without back-importing `cctally`.
|
|
12
|
+
|
|
13
|
+
Module-level flags `_DISPLAY_TZ_BAD_CONFIG_WARNED` and
|
|
14
|
+
`_DISPLAY_TZ_RESOLVE_WARNED` move with their owning functions; the
|
|
15
|
+
`global` declarations work fine in the new module. A private `_eprint`
|
|
16
|
+
duplicates `bin/cctally:eprint` (two-line stderr helper) so this pure
|
|
17
|
+
layer carries zero back-imports per the split design's §5.3 contract.
|
|
18
|
+
|
|
19
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import datetime as dt
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _eprint(*args: Any) -> None:
|
|
33
|
+
print(*args, file=sys.stderr)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _local_tz_name() -> str:
|
|
37
|
+
"""Return an IANA timezone name if resolvable, else a best-effort fallback.
|
|
38
|
+
|
|
39
|
+
Used in Codex command title banners ("(Timezone: <name>)"). Upstream
|
|
40
|
+
uses IANA names (e.g. "Asia/Jerusalem"); we mirror when possible.
|
|
41
|
+
"""
|
|
42
|
+
# Preferred: TZ env var if IANA-looking ("/" in the value).
|
|
43
|
+
tz = os.environ.get("TZ", "")
|
|
44
|
+
if tz and "/" in tz:
|
|
45
|
+
return tz
|
|
46
|
+
try:
|
|
47
|
+
tz_path = os.readlink("/etc/localtime")
|
|
48
|
+
if "zoneinfo/" in tz_path:
|
|
49
|
+
return tz_path.split("zoneinfo/", 1)[1]
|
|
50
|
+
except (OSError, ValueError):
|
|
51
|
+
pass
|
|
52
|
+
# Fallback: time.tzname[0] (e.g. "IST"); may be non-IANA.
|
|
53
|
+
try:
|
|
54
|
+
import time as _time
|
|
55
|
+
return _time.tzname[0] or ""
|
|
56
|
+
except Exception:
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve_tz(tz_name: str | None, *, strict_iana: bool = False, fallback: Any = None) -> Any:
|
|
61
|
+
"""Return ZoneInfo(tz_name) or ``fallback``. Callers have already validated
|
|
62
|
+
the tz via _parse_cli_date_range; this is a defensive re-resolve that
|
|
63
|
+
falls back to ``fallback`` on any error so aggregators never crash.
|
|
64
|
+
|
|
65
|
+
With ``strict_iana=True``, names lacking a "/" (e.g. bare "UTC") are
|
|
66
|
+
rejected up-front and ``fallback`` is returned instead — mirrors
|
|
67
|
+
``_local_tz_name``'s gating to avoid the bare-UTC gotcha (see CLAUDE.md).
|
|
68
|
+
Default behavior (strict_iana=False, fallback=None) preserves the prior
|
|
69
|
+
contract for existing callers.
|
|
70
|
+
"""
|
|
71
|
+
if not tz_name:
|
|
72
|
+
return fallback
|
|
73
|
+
if strict_iana and "/" not in tz_name:
|
|
74
|
+
return fallback
|
|
75
|
+
try:
|
|
76
|
+
return ZoneInfo(tz_name)
|
|
77
|
+
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
78
|
+
return fallback
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
DISPLAY_TZ_DEFAULT = "local"
|
|
82
|
+
_DISPLAY_TZ_BAD_CONFIG_WARNED = False # one-shot warning flag
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def normalize_display_tz_value(raw: "str | None") -> str:
|
|
86
|
+
"""Canonicalize a display-tz value to "local" | "utc" | <IANA>.
|
|
87
|
+
|
|
88
|
+
- None / "" / case-insensitive "local" -> "local"
|
|
89
|
+
- case-insensitive "utc" -> "utc"
|
|
90
|
+
- anything else: must be a valid IANA name (validated via _resolve_tz
|
|
91
|
+
with strict_iana=True). Returns the trimmed value verbatim.
|
|
92
|
+
Raises ValueError on invalid input.
|
|
93
|
+
"""
|
|
94
|
+
if raw is None:
|
|
95
|
+
return "local"
|
|
96
|
+
s = str(raw).strip()
|
|
97
|
+
if not s:
|
|
98
|
+
return "local"
|
|
99
|
+
low = s.lower()
|
|
100
|
+
if low == "local":
|
|
101
|
+
return "local"
|
|
102
|
+
if low == "utc":
|
|
103
|
+
return "utc"
|
|
104
|
+
sentinel = object()
|
|
105
|
+
if _resolve_tz(s, strict_iana=True, fallback=sentinel) is sentinel:
|
|
106
|
+
raise ValueError(f"invalid IANA zone: {s!r}")
|
|
107
|
+
return s
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _config_has_explicit_display_tz(config: "dict | None") -> bool:
|
|
111
|
+
"""Return True iff `config["display"]["tz"]` is present and non-None.
|
|
112
|
+
|
|
113
|
+
Used by codex command tz-name resolution (F2): we need to distinguish
|
|
114
|
+
"user pinned a display tz" from "default 'local' fallback because no
|
|
115
|
+
config block is set" so upstream's `--timezone` can still apply in the
|
|
116
|
+
drop-in-parity case where neither --tz nor display.tz was specified.
|
|
117
|
+
"""
|
|
118
|
+
if not isinstance(config, dict):
|
|
119
|
+
return False
|
|
120
|
+
block = config.get("display")
|
|
121
|
+
return isinstance(block, dict) and block.get("tz") is not None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_display_tz_pref(config: "dict | None") -> str:
|
|
125
|
+
"""Read config['display']['tz']; default DISPLAY_TZ_DEFAULT.
|
|
126
|
+
|
|
127
|
+
Malformed value -> fall back to default with one-shot stderr warning.
|
|
128
|
+
Never raises.
|
|
129
|
+
"""
|
|
130
|
+
global _DISPLAY_TZ_BAD_CONFIG_WARNED
|
|
131
|
+
if not isinstance(config, dict):
|
|
132
|
+
return DISPLAY_TZ_DEFAULT
|
|
133
|
+
block = config.get("display")
|
|
134
|
+
if not isinstance(block, dict):
|
|
135
|
+
return DISPLAY_TZ_DEFAULT
|
|
136
|
+
raw = block.get("tz")
|
|
137
|
+
if raw is None:
|
|
138
|
+
return DISPLAY_TZ_DEFAULT
|
|
139
|
+
try:
|
|
140
|
+
return normalize_display_tz_value(raw)
|
|
141
|
+
except ValueError:
|
|
142
|
+
if not _DISPLAY_TZ_BAD_CONFIG_WARNED:
|
|
143
|
+
_eprint(
|
|
144
|
+
f"warning: ignoring malformed display.tz {raw!r} in config; "
|
|
145
|
+
f"using {DISPLAY_TZ_DEFAULT!r}"
|
|
146
|
+
)
|
|
147
|
+
_DISPLAY_TZ_BAD_CONFIG_WARNED = True
|
|
148
|
+
return DISPLAY_TZ_DEFAULT
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve_display_tz(args: argparse.Namespace,
|
|
152
|
+
config: "dict | None") -> "ZoneInfo | None":
|
|
153
|
+
"""Returns ZoneInfo for fixed zones, None for "local" (caller does
|
|
154
|
+
bare astimezone()). Precedence: --tz flag > config > default.
|
|
155
|
+
Invalid --tz values are normalized via normalize_display_tz_value
|
|
156
|
+
(which raises ValueError -- caller is responsible for catching at
|
|
157
|
+
argparse-time via the shared _argparse_tz type=callable).
|
|
158
|
+
"""
|
|
159
|
+
flag = getattr(args, "tz", None)
|
|
160
|
+
if flag is not None and str(flag).strip() != "":
|
|
161
|
+
canonical = normalize_display_tz_value(flag) # may raise
|
|
162
|
+
else:
|
|
163
|
+
canonical = get_display_tz_pref(config)
|
|
164
|
+
if canonical == "local":
|
|
165
|
+
return None
|
|
166
|
+
if canonical == "utc":
|
|
167
|
+
return ZoneInfo("Etc/UTC")
|
|
168
|
+
return ZoneInfo(canonical)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def display_tz_label(localized: dt.datetime) -> str:
|
|
172
|
+
"""Returns the suffix label for an already-localized datetime.
|
|
173
|
+
|
|
174
|
+
Prefer ``localized.tzname()`` if alphanumeric and len <= 5; otherwise
|
|
175
|
+
fall back to a numeric offset via ``%z`` trimmed to "+HH" or "+HHMM".
|
|
176
|
+
Works uniformly across local / utc / explicit IANA -- caller has
|
|
177
|
+
already done astimezone() before passing in.
|
|
178
|
+
"""
|
|
179
|
+
if localized.tzinfo is None:
|
|
180
|
+
# Defensive: shouldn't happen because callers localize first
|
|
181
|
+
localized = localized.replace(tzinfo=dt.timezone.utc)
|
|
182
|
+
name = localized.tzname() or ""
|
|
183
|
+
if (name
|
|
184
|
+
and name.replace("+", "").replace("-", "").isalnum()
|
|
185
|
+
and len(name) <= 5
|
|
186
|
+
and any(c.isalpha() for c in name)):
|
|
187
|
+
return name
|
|
188
|
+
# Numeric fallback. %z gives "+HHMM"; trim trailing "00" -> "+HH".
|
|
189
|
+
raw = localized.strftime("%z")
|
|
190
|
+
if not raw:
|
|
191
|
+
return "UTC"
|
|
192
|
+
if raw.endswith("00") and len(raw) == 5:
|
|
193
|
+
return raw[:-2] # "+0500" -> "+05"
|
|
194
|
+
# Insert colon for readability when minutes != 00: "+0530" -> "+05:30"
|
|
195
|
+
if len(raw) == 5:
|
|
196
|
+
return f"{raw[:3]}:{raw[3:]}"
|
|
197
|
+
return raw
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _localize(d: dt.datetime, tz: "ZoneInfo | None") -> dt.datetime:
|
|
201
|
+
"""Localize a tz-aware datetime through the resolved display tz.
|
|
202
|
+
|
|
203
|
+
Mirrors ``format_display_dt``'s tz handling for callers that need a
|
|
204
|
+
localized ``dt.datetime`` (not a formatted string) — e.g. when the
|
|
205
|
+
same instant is fed to multiple ``strftime`` formats. ``tz=None``
|
|
206
|
+
falls back to host local via bare ``astimezone()``.
|
|
207
|
+
"""
|
|
208
|
+
return d.astimezone(tz) if tz is not None else d.astimezone()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_DISPLAY_TZ_RESOLVE_WARNED = False # one-shot host-zone-fallback warning
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _resolve_display_tz_obj(config: dict) -> ZoneInfo:
|
|
215
|
+
"""Resolve config.display.tz to a concrete ZoneInfo object.
|
|
216
|
+
|
|
217
|
+
Single source of truth used by ``_compute_display_block`` and the
|
|
218
|
+
dashboard snapshot builders (``_tui_build_snapshot``,
|
|
219
|
+
``_handle_get_block_detail``). Local-fallback case emits a one-shot
|
|
220
|
+
warning via ``_DISPLAY_TZ_RESOLVE_WARNED``. Returns a ZoneInfo
|
|
221
|
+
(never None).
|
|
222
|
+
"""
|
|
223
|
+
global _DISPLAY_TZ_RESOLVE_WARNED
|
|
224
|
+
pref = get_display_tz_pref(config)
|
|
225
|
+
if pref == "local":
|
|
226
|
+
host_iana = _local_tz_name()
|
|
227
|
+
if host_iana and "/" in host_iana:
|
|
228
|
+
return ZoneInfo(host_iana)
|
|
229
|
+
if not _DISPLAY_TZ_RESOLVE_WARNED:
|
|
230
|
+
_eprint(
|
|
231
|
+
"warning: display.tz='local' but host IANA zone could "
|
|
232
|
+
"not be resolved; using Etc/UTC. Set TZ to an IANA "
|
|
233
|
+
"name (e.g. America/New_York) to fix."
|
|
234
|
+
)
|
|
235
|
+
_DISPLAY_TZ_RESOLVE_WARNED = True
|
|
236
|
+
return ZoneInfo("Etc/UTC")
|
|
237
|
+
if pref == "utc":
|
|
238
|
+
return ZoneInfo("Etc/UTC")
|
|
239
|
+
try:
|
|
240
|
+
return ZoneInfo(pref)
|
|
241
|
+
except Exception:
|
|
242
|
+
# Defense in depth -- pref was already canonicalized via
|
|
243
|
+
# normalize_display_tz_value, but if a stale config slipped
|
|
244
|
+
# through, fall back rather than crash.
|
|
245
|
+
if not _DISPLAY_TZ_RESOLVE_WARNED:
|
|
246
|
+
_eprint(
|
|
247
|
+
f"warning: display.tz={pref!r} could not be loaded as "
|
|
248
|
+
f"a ZoneInfo; using Etc/UTC"
|
|
249
|
+
)
|
|
250
|
+
_DISPLAY_TZ_RESOLVE_WARNED = True
|
|
251
|
+
return ZoneInfo("Etc/UTC")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _apply_display_tz_override(
|
|
255
|
+
config: dict,
|
|
256
|
+
override: "str | None",
|
|
257
|
+
) -> dict:
|
|
258
|
+
"""Return a shallow-copied config with `display.tz` substituted from
|
|
259
|
+
the override (F3 fix).
|
|
260
|
+
|
|
261
|
+
`--tz` on `cctally dashboard` should win over the persisted
|
|
262
|
+
`config.display.tz` for the lifetime of the server. Plumbing the
|
|
263
|
+
canonicalized string here lets every reader that already calls
|
|
264
|
+
`load_config()` (envelope builder, snapshot builder, block-detail
|
|
265
|
+
handler) pick up the override by routing the loaded config through
|
|
266
|
+
this thin wrapper -- without changing their existing
|
|
267
|
+
`_resolve_display_tz_obj(load_config())` shape.
|
|
268
|
+
|
|
269
|
+
Override semantics: a canonical token from
|
|
270
|
+
``normalize_display_tz_value`` (``"local"`` / ``"utc"`` / IANA
|
|
271
|
+
name). ``None`` means "no override; let config win" -- the input
|
|
272
|
+
config is returned unchanged. Always returns a fresh dict so callers
|
|
273
|
+
can't mutate the passed-in config via the result.
|
|
274
|
+
"""
|
|
275
|
+
if override is None:
|
|
276
|
+
return config
|
|
277
|
+
out = dict(config)
|
|
278
|
+
out["display"] = dict(out.get("display") or {})
|
|
279
|
+
out["display"]["tz"] = override
|
|
280
|
+
out["display"]["pinned"] = True
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _compute_display_block(config: dict, generated_at: dt.datetime) -> dict:
|
|
285
|
+
"""Compute the dashboard envelope's ``display`` block.
|
|
286
|
+
|
|
287
|
+
Resolves ``config.display.tz`` to a CONCRETE IANA name server-side
|
|
288
|
+
(F1 fix per spec) so the browser never has to guess "local". Also
|
|
289
|
+
computes the offset label and signed offset seconds at the snapshot
|
|
290
|
+
timestamp, so the client can label times consistently with the
|
|
291
|
+
server-rendered week_lbl / block labels.
|
|
292
|
+
|
|
293
|
+
Resolution flows through ``_resolve_display_tz_obj`` -- the same
|
|
294
|
+
helper that ``_tui_build_snapshot`` and ``_handle_get_block_detail``
|
|
295
|
+
use, so all three sites share warn-once stderr semantics.
|
|
296
|
+
"""
|
|
297
|
+
pref = get_display_tz_pref(config)
|
|
298
|
+
resolved_obj = _resolve_display_tz_obj(config)
|
|
299
|
+
resolved_iana = resolved_obj.key
|
|
300
|
+
|
|
301
|
+
if generated_at.tzinfo is None:
|
|
302
|
+
generated_at = generated_at.replace(tzinfo=dt.timezone.utc)
|
|
303
|
+
localized = generated_at.astimezone(resolved_obj)
|
|
304
|
+
offset = localized.utcoffset()
|
|
305
|
+
offset_seconds = int(offset.total_seconds()) if offset is not None else 0
|
|
306
|
+
block: dict = {
|
|
307
|
+
"tz": pref,
|
|
308
|
+
"resolved_tz": resolved_iana,
|
|
309
|
+
"offset_label": display_tz_label(localized),
|
|
310
|
+
"offset_seconds": offset_seconds,
|
|
311
|
+
}
|
|
312
|
+
# F3: surface --tz-pin so the React client can render a read-only
|
|
313
|
+
# state for the Settings UI when the operator launched the server
|
|
314
|
+
# with an explicit --tz override. Override application leaves the
|
|
315
|
+
# `pinned` key on `config["display"]`; we forward it as a runtime
|
|
316
|
+
# signal (NOT persisted into config.json -- POST /api/settings is
|
|
317
|
+
# blocked under pin in this mode).
|
|
318
|
+
display_cfg = config.get("display") if isinstance(config, dict) else None
|
|
319
|
+
if isinstance(display_cfg, dict) and display_cfg.get("pinned"):
|
|
320
|
+
block["pinned"] = True
|
|
321
|
+
return block
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def format_display_dt(value: "str | dt.datetime",
|
|
325
|
+
tz: "ZoneInfo | None",
|
|
326
|
+
*, fmt: str, suffix: bool = True) -> str:
|
|
327
|
+
"""Targeted-swap chokepoint. Naive value treated as UTC.
|
|
328
|
+
|
|
329
|
+
Output: "<strftime fmt> <suffix>" when suffix=True, else just the
|
|
330
|
+
strftime. Suffix is computed from the localized datetime via
|
|
331
|
+
display_tz_label.
|
|
332
|
+
"""
|
|
333
|
+
if isinstance(value, str):
|
|
334
|
+
s = value.replace("Z", "+00:00")
|
|
335
|
+
parsed = dt.datetime.fromisoformat(s)
|
|
336
|
+
else:
|
|
337
|
+
parsed = value
|
|
338
|
+
if parsed.tzinfo is None:
|
|
339
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
340
|
+
localized = parsed.astimezone(tz) if tz is not None else parsed.astimezone()
|
|
341
|
+
body = localized.strftime(fmt)
|
|
342
|
+
if not suffix:
|
|
343
|
+
return body
|
|
344
|
+
return f"{body} {display_tz_label(localized)}"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _argparse_tz(value: str) -> str:
|
|
348
|
+
"""argparse ``type=`` callable for ``--tz`` on any subcommand.
|
|
349
|
+
|
|
350
|
+
Canonicalizes via ``normalize_display_tz_value``: returns "local",
|
|
351
|
+
"utc", or a verbatim IANA name. Bad input raises
|
|
352
|
+
``argparse.ArgumentTypeError`` so argparse formats a standard
|
|
353
|
+
``error: argument --tz: ...`` message and exits 2.
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
return normalize_display_tz_value(value)
|
|
357
|
+
except ValueError:
|
|
358
|
+
raise argparse.ArgumentTypeError(
|
|
359
|
+
f"invalid timezone {value!r} -- expected 'local', 'utc', "
|
|
360
|
+
f"or an IANA name"
|
|
361
|
+
)
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -49,6 +49,17 @@ class DoctorState:
|
|
|
49
49
|
cache_entries_count: Optional[int]
|
|
50
50
|
cache_last_entry_at: Optional[dt.datetime]
|
|
51
51
|
claude_jsonl_present: bool
|
|
52
|
+
# Forked-bucket invariant counts (data.forked_buckets check).
|
|
53
|
+
# Keys: "usage", "cost", "milestones" — each maps to the count of
|
|
54
|
+
# rows in the respective table where ``week_start_at IS NOT NULL``
|
|
55
|
+
# AND ``week_start_date != substr(week_start_at, 1, 10)``. None
|
|
56
|
+
# means the stats.db couldn't be opened to check; the migration
|
|
57
|
+
# ``004_heal_forked_week_start_date_buckets`` auto-merges any
|
|
58
|
+
# detected rows on the next ``open_db()``, so a non-zero count
|
|
59
|
+
# here indicates either (a) the migration is gated as
|
|
60
|
+
# skipped/failed/pending or (b) a buggy writer slipped through
|
|
61
|
+
# after the migration ran.
|
|
62
|
+
forked_bucket_counts: Optional[dict]
|
|
52
63
|
codex_entries_count: Optional[int]
|
|
53
64
|
codex_last_entry_at: Optional[dt.datetime]
|
|
54
65
|
codex_jsonl_present: bool
|
|
@@ -502,6 +513,52 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
|
|
|
502
513
|
)
|
|
503
514
|
|
|
504
515
|
|
|
516
|
+
def _check_data_forked_buckets(s: DoctorState) -> CheckResult:
|
|
517
|
+
"""Invariant: for every row with ``week_start_at IS NOT NULL``,
|
|
518
|
+
``week_start_date == substr(week_start_at, 1, 10)``.
|
|
519
|
+
|
|
520
|
+
Pair with migration ``004_heal_forked_week_start_date_buckets``,
|
|
521
|
+
which auto-merges any detected rows on the next ``open_db()``. A
|
|
522
|
+
non-zero count here means either (a) the migration is gated as
|
|
523
|
+
skipped/failed/pending or (b) a buggy writer slipped through
|
|
524
|
+
after the migration ran. Either way the user has a fork that
|
|
525
|
+
needs attention.
|
|
526
|
+
"""
|
|
527
|
+
counts = s.forked_bucket_counts
|
|
528
|
+
if counts is None:
|
|
529
|
+
return CheckResult(
|
|
530
|
+
id="data.forked_buckets", title="Forked week buckets",
|
|
531
|
+
severity="fail", summary="state unavailable",
|
|
532
|
+
remediation="Check stats.db opens (`cctally db status`)",
|
|
533
|
+
details={"reason": "gather returned None"},
|
|
534
|
+
)
|
|
535
|
+
total = sum(int(counts.get(k, 0)) for k in ("usage", "cost", "milestones"))
|
|
536
|
+
if total == 0:
|
|
537
|
+
return CheckResult(
|
|
538
|
+
id="data.forked_buckets", title="Forked week buckets",
|
|
539
|
+
severity="ok", summary="none",
|
|
540
|
+
remediation=None,
|
|
541
|
+
details=dict(counts),
|
|
542
|
+
)
|
|
543
|
+
parts = [
|
|
544
|
+
f"{counts.get(k, 0)} {k}"
|
|
545
|
+
for k in ("usage", "cost", "milestones")
|
|
546
|
+
if counts.get(k, 0)
|
|
547
|
+
]
|
|
548
|
+
return CheckResult(
|
|
549
|
+
id="data.forked_buckets", title="Forked week buckets",
|
|
550
|
+
severity="fail",
|
|
551
|
+
summary=f"{total} forked row(s): {', '.join(parts)}",
|
|
552
|
+
remediation=(
|
|
553
|
+
"Run any cctally command to trigger the auto-heal migration "
|
|
554
|
+
"(`004_heal_forked_week_start_date_buckets`); if it's already "
|
|
555
|
+
"applied, see `cctally db status`."
|
|
556
|
+
),
|
|
557
|
+
details=dict(counts),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
|
|
505
562
|
_LOOPBACK_HOSTS = frozenset({"loopback", "127.0.0.1", "::1", "localhost"})
|
|
506
563
|
|
|
507
564
|
|
|
@@ -728,6 +785,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
728
785
|
("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
|
|
729
786
|
("data.cache_sync_state", "_check_data_cache_sync_state"),
|
|
730
787
|
("data.codex_cache", "_check_data_codex_cache"),
|
|
788
|
+
("data.forked_buckets", "_check_data_forked_buckets"),
|
|
731
789
|
)),
|
|
732
790
|
("safety", "Safety", (
|
|
733
791
|
("safety.dashboard_bind", "_check_safety_dashboard_bind"),
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""5-hour window canonical-key primitives.
|
|
2
|
+
|
|
3
|
+
Pure-fn layer (no I/O at import time): holds the two jitter-tolerant
|
|
4
|
+
floors that route every 5h-window identity decision through a single
|
|
5
|
+
granularity. `_canonical_5h_window_key` is the epoch-int chokepoint and
|
|
6
|
+
`_floor_to_ten_minutes` the datetime equivalent — CLAUDE.md's "5h window
|
|
7
|
+
key MUST go through `_canonical_5h_window_key`" invariant lives here.
|
|
8
|
+
|
|
9
|
+
Both helpers share `_FIVE_HOUR_JITTER_FLOOR_SECONDS = 600` so neither can
|
|
10
|
+
drift independently; the regression `bin/cctally-5h-canonical-test`
|
|
11
|
+
pins the cross-shape equivalence (epoch-int → datetime → epoch round-trip
|
|
12
|
+
matches the modulo floor on 600-aligned base epochs).
|
|
13
|
+
|
|
14
|
+
`bin/cctally` re-exports every symbol below so internal call sites and
|
|
15
|
+
SourceFileLoader-based tests/fixtures (`tests/test_blocks_recorded_anchor`,
|
|
16
|
+
`tests/test_five_hour_block_selector`, `tests/test_five_hour_blocks_json`,
|
|
17
|
+
`tests/test_five_hour_breakdown`, `bin/cctally-5h-canonical-test`,
|
|
18
|
+
`bin/cctally-record-usage-selfheal-test`) resolve unchanged. No
|
|
19
|
+
cross-sibling dependencies — this is a true leaf in the sibling graph.
|
|
20
|
+
|
|
21
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime as dt
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_FIVE_HOUR_JITTER_FLOOR_SECONDS = 600 # 10 minutes; tolerance band for resets_at jitter
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _floor_to_ten_minutes(d: dt.datetime) -> dt.datetime:
|
|
32
|
+
"""Floor a datetime to the previous 10-minute boundary.
|
|
33
|
+
|
|
34
|
+
Anthropic ``rate_limits.5h.resets_at`` arrives via the status line
|
|
35
|
+
with capture jitter and occasional transient bogus values that
|
|
36
|
+
differ from the real reset by tens of minutes (a brief mid-window
|
|
37
|
+
glitch sitting alongside the genuine reset). A 10-minute floor
|
|
38
|
+
collapses fine-grained jitter into shared buckets while leaving
|
|
39
|
+
truly distinct windows separable; structural conflicts that survive
|
|
40
|
+
the floor are resolved downstream by
|
|
41
|
+
``_select_non_overlapping_recorded_windows``.
|
|
42
|
+
"""
|
|
43
|
+
minute_bucket = _FIVE_HOUR_JITTER_FLOOR_SECONDS // 60
|
|
44
|
+
return d.replace(
|
|
45
|
+
minute=(d.minute // minute_bucket) * minute_bucket,
|
|
46
|
+
second=0, microsecond=0,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _canonical_5h_window_key(
|
|
51
|
+
resets_at_epoch: int,
|
|
52
|
+
prior_epoch: int | None = None,
|
|
53
|
+
prior_key: int | None = None,
|
|
54
|
+
) -> int:
|
|
55
|
+
"""Floor a 5h-window resets_at epoch to a jitter-tolerant canonical key.
|
|
56
|
+
|
|
57
|
+
Anthropic's status-line API jitters resets_at by ~seconds within the same
|
|
58
|
+
physical 5h window. Any code identifying 'this 5h window' across consecutive
|
|
59
|
+
fetches MUST derive its key via this function. Floor granularity matches
|
|
60
|
+
_floor_to_ten_minutes (the same tolerance already used for weekly_reset_events).
|
|
61
|
+
|
|
62
|
+
Required invariant: two ``record-usage`` calls with ``resets_at`` differing
|
|
63
|
+
by ≤ 599 seconds MUST resolve to the same window key. A pure modulo floor
|
|
64
|
+
cannot satisfy this when the two epochs straddle a 600-second bucket
|
|
65
|
+
boundary (e.g. 1746014999 → 1746014400 vs. 1746015000 → 1746015000, a
|
|
66
|
+
1-second delta producing different keys).
|
|
67
|
+
|
|
68
|
+
The optional ``prior_epoch`` / ``prior_key`` arguments close that gap: when
|
|
69
|
+
callers can supply the most-recent stored sample's raw ``five_hour_resets_at``
|
|
70
|
+
and its persisted ``five_hour_window_key``, the function reuses ``prior_key``
|
|
71
|
+
whenever ``|resets_at_epoch - prior_epoch| < _FIVE_HOUR_JITTER_FLOOR_SECONDS``
|
|
72
|
+
— boundary-straddling jitter then collapses to the first-seen anchor instead
|
|
73
|
+
of forking a new key. With no anchor (or with the anchor too far away to be
|
|
74
|
+
the same physical window), falls back to the pure floor.
|
|
75
|
+
"""
|
|
76
|
+
if (
|
|
77
|
+
prior_epoch is not None
|
|
78
|
+
and prior_key is not None
|
|
79
|
+
and abs(resets_at_epoch - prior_epoch) < _FIVE_HOUR_JITTER_FLOOR_SECONDS
|
|
80
|
+
):
|
|
81
|
+
return prior_key
|
|
82
|
+
return resets_at_epoch - (resets_at_epoch % _FIVE_HOUR_JITTER_FLOOR_SECONDS)
|