cctally 1.96.2 → 1.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/bin/_cctally_dashboard.py +595 -46
- package/bin/_cctally_dashboard_envelope.py +57 -23
- package/bin/_cctally_dashboard_share.py +25 -5
- package/bin/_cctally_dashboard_sources.py +123 -36
- package/bin/_cctally_db.py +10 -0
- package/bin/_cctally_doctor.py +15 -10
- package/bin/_cctally_journal_repair.py +7 -6
- package/bin/_cctally_parser.py +47 -22
- package/bin/_cctally_tui.py +515 -63
- package/bin/_lib_alert_axes.py +8 -3
- package/bin/_lib_dashboard_sources.py +837 -102
- package/bin/_lib_journal_router.py +14 -0
- package/bin/_lib_share.py +191 -36
- package/bin/_lib_snapshot_cache.py +12 -0
- package/bin/cctally +48 -1
- package/dashboard/static/assets/index-CC8TTZUC.css +1 -0
- package/dashboard/static/assets/index-CChXFhs_.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BRFMIN18.js +0 -97
- package/dashboard/static/assets/index-Crj7bzyj.css +0 -1
|
@@ -27,6 +27,20 @@ import hashlib
|
|
|
27
27
|
RETAINED_RECORD_TYPES = frozenset({"evt", "correction", "correction_batch", "op"})
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def selector_slot(record):
|
|
31
|
+
"""Return one position-preserving input slot for the shared selector.
|
|
32
|
+
|
|
33
|
+
Every successfully decoded journal line consumes one sequence number in
|
|
34
|
+
``resolve_effective_events``. Decision records therefore stay decoded,
|
|
35
|
+
while observations and other irrelevant records become ``None`` rather
|
|
36
|
+
than being dropped (which renumbers durable protocol fingerprints) or kept
|
|
37
|
+
as dictionaries (which makes memory follow observation volume).
|
|
38
|
+
"""
|
|
39
|
+
if record.get("t") in RETAINED_RECORD_TYPES:
|
|
40
|
+
return record
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
30
44
|
class LastSeenAccumulator:
|
|
31
45
|
"""Reproduce `_derive_account_last_seen`'s contribution set from a stream.
|
|
32
46
|
|
package/bin/_lib_share.py
CHANGED
|
@@ -381,7 +381,7 @@ PALETTE_LIGHT = {
|
|
|
381
381
|
"#dc2626", # red-600
|
|
382
382
|
"#0891b2", # cyan-600
|
|
383
383
|
),
|
|
384
|
-
"ref_warn": "#
|
|
384
|
+
"ref_warn": "#b45309", # amber-700
|
|
385
385
|
"ref_alarm": "#dc2626", # red-600
|
|
386
386
|
"table_header_bg": "#f3f4f6",
|
|
387
387
|
"table_row_alt": "#f9fafb",
|
|
@@ -903,7 +903,10 @@ def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
|
|
|
903
903
|
seg_bot_y = iy + scale_y(y_running)
|
|
904
904
|
seg_h = seg_bot_y - seg_top_y
|
|
905
905
|
color = series_palette[k_idx % len(series_palette)]
|
|
906
|
-
elements.append(svg_rect(
|
|
906
|
+
elements.append(svg_rect(
|
|
907
|
+
bx, seg_top_y, bar_w, seg_h,
|
|
908
|
+
fill=color, stroke=palette["bg"],
|
|
909
|
+
))
|
|
907
910
|
y_running += seg_v
|
|
908
911
|
else:
|
|
909
912
|
by = iy + scale_y(p.y_value)
|
|
@@ -1965,7 +1968,9 @@ def _merge_inventories(
|
|
|
1965
1968
|
# original project labels.
|
|
1966
1969
|
#
|
|
1967
1970
|
# Half two — unambiguous classes, scanned document-wide. Only identifier
|
|
1968
|
-
# classes that cannot plausibly occur as legitimate artifact content.
|
|
1971
|
+
# classes that cannot plausibly occur as legitimate artifact content. The two
|
|
1972
|
+
# reveal-basename exceptions are masked at typed project sites before the
|
|
1973
|
+
# complete document reaches this unchanged detector, then restored afterward.
|
|
1969
1974
|
#
|
|
1970
1975
|
# Original project labels are deliberately NOT in half two. A project
|
|
1971
1976
|
# legitimately named `cctally` collides with the static branding string this
|
|
@@ -2177,11 +2182,10 @@ def _scan_forbidden_classes(text: str) -> "list[tuple[str, str]]":
|
|
|
2177
2182
|
"""Return `(class label, matched value)` per unambiguous class in `text`.
|
|
2178
2183
|
|
|
2179
2184
|
The matched value is carried out of the scan, not just the class name.
|
|
2180
|
-
Naming only the class leaves the user nothing to act on
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
their directories to rename.
|
|
2185
|
+
Naming only the class leaves the user nothing to act on when an untyped
|
|
2186
|
+
field leaks a value. The narrow reveal-basename exception is handled by
|
|
2187
|
+
provenance-aware masking before a complete document reaches this scanner;
|
|
2188
|
+
direct scanner calls remain fail-closed.
|
|
2185
2189
|
"""
|
|
2186
2190
|
findings: list[tuple[str, str]] = []
|
|
2187
2191
|
match = _UUID_RE.search(text)
|
|
@@ -2238,11 +2242,12 @@ def _verify_output(
|
|
|
2238
2242
|
handler's exception converter turns it into the generic 500 envelope and
|
|
2239
2243
|
the CLI converts it to a stderr refusal and exit 3.
|
|
2240
2244
|
|
|
2241
|
-
Takes NO privacy mode. Both halves are mode-independent — half one
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2245
|
+
Takes NO privacy mode. Both halves are mode-independent — half one compares
|
|
2246
|
+
against the allowlist preparation itself built under whichever mode was
|
|
2247
|
+
asked for, while the entry points mask the two provenance-qualified reveal
|
|
2248
|
+
basename shapes before calling this unchanged document-wide detector. A
|
|
2249
|
+
direct call still rejects every forbidden class. The former mode parameter
|
|
2250
|
+
was read by nothing.
|
|
2246
2251
|
"""
|
|
2247
2252
|
# Half one — provenance. Every project display value the prepared snapshot
|
|
2248
2253
|
# carries must be one preparation was allowed to write. This catches a
|
|
@@ -2280,6 +2285,127 @@ def _verify_output(
|
|
|
2280
2285
|
)
|
|
2281
2286
|
|
|
2282
2287
|
|
|
2288
|
+
def _reveal_basename_token(label: str) -> "str | None":
|
|
2289
|
+
"""Return the narrowly exemptible token from one raw project label.
|
|
2290
|
+
|
|
2291
|
+
The exception is for a directory whose BASENAME is exactly a UUID or a
|
|
2292
|
+
source-root-shaped token. A label that merely contains either shape is not
|
|
2293
|
+
eligible. Parent qualifiers added later by `disambiguate_basenames` do not
|
|
2294
|
+
broaden this decision because eligibility is derived from the raw label.
|
|
2295
|
+
"""
|
|
2296
|
+
segments = _path_segments(label)
|
|
2297
|
+
basename = segments[-1] if segments else label
|
|
2298
|
+
if _UUID_RE.fullmatch(basename) or _SOURCE_ROOT_KEY_RE.fullmatch(basename):
|
|
2299
|
+
return basename
|
|
2300
|
+
return None
|
|
2301
|
+
|
|
2302
|
+
|
|
2303
|
+
def _width_safe_hex_mask(token: str, ordinal: int) -> str:
|
|
2304
|
+
"""Build a detector-safe token that never under-reserves SVG width.
|
|
2305
|
+
|
|
2306
|
+
Helvetica's ``g``/``h`` are each 556 units wide: equal to digits and at
|
|
2307
|
+
least as wide as every lowercase hex letter. ``H``/``N`` are each 722
|
|
2308
|
+
units wide, at least as wide as every uppercase hex letter. Hyphens retain
|
|
2309
|
+
their real width. The binary ordinal makes equal-shaped UUID/source-root
|
|
2310
|
+
masks unique without introducing another hexadecimal run. NUL sentinels
|
|
2311
|
+
keep the placeholder distinct from every ordinary renderer string; they
|
|
2312
|
+
exist only in the intermediate Python value and add safe width headroom.
|
|
2313
|
+
"""
|
|
2314
|
+
chars = [
|
|
2315
|
+
char if char == "-" else ("H" if char.isupper() else "g")
|
|
2316
|
+
for char in token
|
|
2317
|
+
]
|
|
2318
|
+
slots = [index for index, char in enumerate(chars) if char != "-"]
|
|
2319
|
+
bits = f"{ordinal + 1:b}"
|
|
2320
|
+
for index, bit in zip(reversed(slots), reversed(bits)):
|
|
2321
|
+
if token[index].isupper():
|
|
2322
|
+
chars[index] = "N" if bit == "1" else "H"
|
|
2323
|
+
else:
|
|
2324
|
+
chars[index] = "h" if bit == "1" else "g"
|
|
2325
|
+
return "\x00" + "".join(chars) + "\x00"
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
def _mask_reveal_basename_mapping(
|
|
2329
|
+
snaps: "Sequence[ShareSnapshot]",
|
|
2330
|
+
mapping: "dict[_ProjectAnonKey, str]",
|
|
2331
|
+
*,
|
|
2332
|
+
reveal_projects: bool,
|
|
2333
|
+
reserved_strings: "Sequence[str]" = (),
|
|
2334
|
+
) -> "tuple[dict[_ProjectAnonKey, str], tuple[tuple[str, str], ...]]":
|
|
2335
|
+
"""Mask only provenance-qualified UUID/hex basenames before rendering.
|
|
2336
|
+
|
|
2337
|
+
This is deliberately a mapping transformation, not a document allowlist.
|
|
2338
|
+
Untyped fields retain their bytes and therefore remain visible to the
|
|
2339
|
+
unchanged document-wide detector. The returned substitutions are applied
|
|
2340
|
+
only after that detector accepts the complete document.
|
|
2341
|
+
"""
|
|
2342
|
+
if not reveal_projects:
|
|
2343
|
+
return mapping, ()
|
|
2344
|
+
|
|
2345
|
+
raw_tokens: dict[_ProjectAnonKey, set[str | None]] = {}
|
|
2346
|
+
reserved: set[str] = set(mapping.values()) | set(reserved_strings)
|
|
2347
|
+
for snap in snaps:
|
|
2348
|
+
def _record_token(site: _ProjectDisplaySite) -> "str | None":
|
|
2349
|
+
if site.keyed and site.value:
|
|
2350
|
+
key = _project_anon_key(site.value, site.identity)
|
|
2351
|
+
raw_tokens.setdefault(key, set()).add(
|
|
2352
|
+
_reveal_basename_token(site.value))
|
|
2353
|
+
return site.value
|
|
2354
|
+
|
|
2355
|
+
# Visit every typed site directly. `_project_label_by_key` is a display
|
|
2356
|
+
# helper and intentionally keeps only the first label per identity;
|
|
2357
|
+
# security eligibility must instead hear every raw label.
|
|
2358
|
+
_map_project_display(snap, _record_token)
|
|
2359
|
+
_walk_strings(snap, reserved, set())
|
|
2360
|
+
|
|
2361
|
+
eligible_by_key: dict[_ProjectAnonKey, str] = {}
|
|
2362
|
+
eligible: dict[str, str] = {}
|
|
2363
|
+
for key, displayed in mapping.items():
|
|
2364
|
+
candidates = raw_tokens.get(key, set())
|
|
2365
|
+
token = next(iter(candidates)) if len(candidates) == 1 else None
|
|
2366
|
+
if token is not None and token in displayed:
|
|
2367
|
+
eligible_by_key[key] = token
|
|
2368
|
+
eligible.setdefault(token, "")
|
|
2369
|
+
if not eligible:
|
|
2370
|
+
return mapping, ()
|
|
2371
|
+
|
|
2372
|
+
substitutions: list[tuple[str, str]] = []
|
|
2373
|
+
ordinal = 0
|
|
2374
|
+
for token in sorted(eligible):
|
|
2375
|
+
while True:
|
|
2376
|
+
placeholder = _width_safe_hex_mask(token, ordinal)
|
|
2377
|
+
ordinal += 1
|
|
2378
|
+
if all(placeholder not in value for value in reserved):
|
|
2379
|
+
break
|
|
2380
|
+
eligible[token] = placeholder
|
|
2381
|
+
substitutions.append((placeholder, token))
|
|
2382
|
+
reserved.add(placeholder)
|
|
2383
|
+
|
|
2384
|
+
masked: dict[_ProjectAnonKey, str] = {}
|
|
2385
|
+
for key, displayed in mapping.items():
|
|
2386
|
+
token = eligible_by_key.get(key)
|
|
2387
|
+
placeholder = eligible.get(token or "")
|
|
2388
|
+
masked[key] = (
|
|
2389
|
+
displayed.replace(token, placeholder, 1)
|
|
2390
|
+
if token is not None and placeholder is not None
|
|
2391
|
+
else displayed
|
|
2392
|
+
)
|
|
2393
|
+
return masked, tuple(substitutions)
|
|
2394
|
+
|
|
2395
|
+
|
|
2396
|
+
def _verify_and_restore_reveal_basenames(
|
|
2397
|
+
text: str,
|
|
2398
|
+
*,
|
|
2399
|
+
inventory: SensitiveInventory,
|
|
2400
|
+
substitutions: "Sequence[tuple[str, str]]",
|
|
2401
|
+
) -> str:
|
|
2402
|
+
"""Verify the masked complete document, then restore approved basenames."""
|
|
2403
|
+
_verify_output(text, inventory=inventory)
|
|
2404
|
+
for placeholder, token in substitutions:
|
|
2405
|
+
text = text.replace(placeholder, token)
|
|
2406
|
+
return text
|
|
2407
|
+
|
|
2408
|
+
|
|
2283
2409
|
def _encode_probe_identity_key() -> str:
|
|
2284
2410
|
"""A syntactically valid IdentityV1 key, for the detector's own tests.
|
|
2285
2411
|
|
|
@@ -3633,7 +3759,7 @@ def _print_stylesheet() -> str:
|
|
|
3633
3759
|
|
|
3634
3760
|
THE DATA COLOURS ARE MAPPED TOO (#503 S2 review F1/F2), because they
|
|
3635
3761
|
are not legible on white: dark `ref_warn` #fbbf24 measures 1.67:1
|
|
3636
|
-
where its light counterpart #
|
|
3762
|
+
where its light counterpart #b45309 measures 5.02:1, dark `ref_alarm`
|
|
3637
3763
|
#f87171 measures 2.77:1 against 4.83:1, and dark `series_primary`
|
|
3638
3764
|
#60a5fa measures 2.54:1 against 5.17:1. A reference LABEL is mapped
|
|
3639
3765
|
alongside its LINE, so the colour that encodes severity stays paired;
|
|
@@ -3708,18 +3834,20 @@ def _print_stylesheet() -> str:
|
|
|
3708
3834
|
"""
|
|
3709
3835
|
light = PALETTE_LIGHT
|
|
3710
3836
|
dark = PALETTE_DARK
|
|
3711
|
-
#
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3837
|
+
# Fill and stroke roles are mapped separately. A dark source value may
|
|
3838
|
+
# intentionally mean different things in those channels: #fbbf24 is both
|
|
3839
|
+
# the warning stroke/text and the fourth stacked-series fill, whose light
|
|
3840
|
+
# counterparts are #b45309 and #d97706 respectively (#524). Deduplicating
|
|
3841
|
+
# them in one value-only map silently assigns one role the other's target.
|
|
3842
|
+
fill_pairs = [(dark[role], light[role])
|
|
3843
|
+
for role in ("series_primary", "series_secondary")]
|
|
3844
|
+
fill_pairs += list(zip(dark["series_palette"], light["series_palette"]))
|
|
3717
3845
|
fills = []
|
|
3718
|
-
|
|
3719
|
-
for dark_value, light_value in
|
|
3720
|
-
if dark_value in
|
|
3846
|
+
fill_seen: set = set()
|
|
3847
|
+
for dark_value, light_value in fill_pairs:
|
|
3848
|
+
if dark_value in fill_seen:
|
|
3721
3849
|
continue
|
|
3722
|
-
|
|
3850
|
+
fill_seen.add(dark_value)
|
|
3723
3851
|
# `rect` is the only element this kernel currently fills with a
|
|
3724
3852
|
# data colour, but `path` and `polyline` are filled elements too,
|
|
3725
3853
|
# so a future filled area chart would otherwise print dark. Named
|
|
@@ -3730,6 +3858,16 @@ def _print_stylesheet() -> str:
|
|
|
3730
3858
|
f' svg path[fill="{dark_value}"],'
|
|
3731
3859
|
f' svg polyline[fill="{dark_value}"]'
|
|
3732
3860
|
f' {{ fill: {light_value} !important; }}')
|
|
3861
|
+
|
|
3862
|
+
stroke_pairs = [(dark[role], light[role])
|
|
3863
|
+
for role in ("series_primary", "series_secondary",
|
|
3864
|
+
"ref_warn", "ref_alarm")]
|
|
3865
|
+
strokes = []
|
|
3866
|
+
stroke_seen: set = set()
|
|
3867
|
+
for dark_value, light_value in stroke_pairs:
|
|
3868
|
+
if dark_value in stroke_seen:
|
|
3869
|
+
continue
|
|
3870
|
+
stroke_seen.add(dark_value)
|
|
3733
3871
|
strokes.append(
|
|
3734
3872
|
f' svg line[stroke="{dark_value}"] {{ stroke: {light_value} !important; }}'
|
|
3735
3873
|
f' svg polyline[stroke="{dark_value}"] {{ stroke: {light_value} !important; }}'
|
|
@@ -3996,8 +4134,9 @@ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> s
|
|
|
3996
4134
|
|
|
3997
4135
|
The second complete-document boundary that owns the privacy contract
|
|
3998
4136
|
(#503 S1). `sections` must carry RAW snapshots: `compose()` prepares them
|
|
3999
|
-
itself under `opts.reveal_projects`, stitches,
|
|
4000
|
-
|
|
4137
|
+
itself under `opts.reveal_projects`, stitches, verifies the whole composed
|
|
4138
|
+
document, and only then restores provenance-qualified reveal basenames.
|
|
4139
|
+
Callers must not pre-scrub — a pre-scrubbed section
|
|
4001
4140
|
reaching a second aliasing pass merges two distinct projects that each
|
|
4002
4141
|
mapped locally to `project-1` into one alias.
|
|
4003
4142
|
"""
|
|
@@ -4009,10 +4148,15 @@ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> s
|
|
|
4009
4148
|
# a handler-only fix would miss the CLI `source=all` path.
|
|
4010
4149
|
merged = _merged_project_mapping(
|
|
4011
4150
|
[sec.snap for sec in sections], reveal_projects=opts.reveal_projects)
|
|
4151
|
+
render_mapping, reveal_substitutions = _mask_reveal_basename_mapping(
|
|
4152
|
+
[sec.snap for sec in sections], merged,
|
|
4153
|
+
reveal_projects=opts.reveal_projects,
|
|
4154
|
+
reserved_strings=(opts.title,),
|
|
4155
|
+
)
|
|
4012
4156
|
prepared = tuple(
|
|
4013
4157
|
ComposedSection(
|
|
4014
4158
|
snap=_prepare(sec.snap, reveal_projects=opts.reveal_projects,
|
|
4015
|
-
mapping=
|
|
4159
|
+
mapping=render_mapping),
|
|
4016
4160
|
drift_detected=sec.drift_detected,
|
|
4017
4161
|
)
|
|
4018
4162
|
for sec in sections
|
|
@@ -4040,10 +4184,13 @@ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> s
|
|
|
4040
4184
|
body = _stitch_svg(prepared, opts=opts)
|
|
4041
4185
|
else:
|
|
4042
4186
|
raise ValueError(f"unknown format: {fmt!r}")
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4187
|
+
return _verify_and_restore_reveal_basenames(
|
|
4188
|
+
body,
|
|
4189
|
+
inventory=_merge_inventories([
|
|
4190
|
+
(raw.snap, out.snap) for raw, out in zip(sections, prepared)
|
|
4191
|
+
]),
|
|
4192
|
+
substitutions=reveal_substitutions,
|
|
4193
|
+
)
|
|
4047
4194
|
|
|
4048
4195
|
|
|
4049
4196
|
def _stitch_html(sections: tuple[ComposedSection, ...], *,
|
|
@@ -4255,8 +4402,8 @@ def render(snap: ShareSnapshot, *, format: str, theme: str, branding: bool,
|
|
|
4255
4402
|
responsible for emitting the result (stdout/file/clipboard/open).
|
|
4256
4403
|
|
|
4257
4404
|
One of the two complete-document boundaries that own the privacy contract
|
|
4258
|
-
(#503 S1). It runs inventory -> prepare -> render -> verify
|
|
4259
|
-
here and in `compose()`, not in `_render_fragment` and not in
|
|
4405
|
+
(#503 S1). It runs inventory -> prepare/mask -> render -> verify -> restore.
|
|
4406
|
+
The gate goes here and in `compose()`, not in `_render_fragment` and not in
|
|
4260
4407
|
`_wrap_document`, because composition bypasses the latter and a fragment
|
|
4261
4408
|
is not a complete document.
|
|
4262
4409
|
|
|
@@ -4270,11 +4417,19 @@ def render(snap: ShareSnapshot, *, format: str, theme: str, branding: bool,
|
|
|
4270
4417
|
snapshot renumbers aliases on the legacy path, so preparation refuses it.
|
|
4271
4418
|
"""
|
|
4272
4419
|
inventory_source = snap
|
|
4273
|
-
|
|
4420
|
+
resolved = _resolved_project_labels(
|
|
4421
|
+
snap, reveal_projects=reveal_projects)
|
|
4422
|
+
render_mapping, reveal_substitutions = _mask_reveal_basename_mapping(
|
|
4423
|
+
[snap], resolved, reveal_projects=reveal_projects)
|
|
4424
|
+
prepared = _prepare(
|
|
4425
|
+
snap, reveal_projects=reveal_projects, mapping=render_mapping)
|
|
4274
4426
|
out = _render_prepared(prepared, format=format, theme=theme,
|
|
4275
4427
|
branding=branding)
|
|
4276
|
-
|
|
4277
|
-
|
|
4428
|
+
return _verify_and_restore_reveal_basenames(
|
|
4429
|
+
out,
|
|
4430
|
+
inventory=_inventory_for(inventory_source, prepared),
|
|
4431
|
+
substitutions=reveal_substitutions,
|
|
4432
|
+
)
|
|
4278
4433
|
|
|
4279
4434
|
|
|
4280
4435
|
def _render_prepared(snap: ShareSnapshot, *, format: str, theme: str,
|
|
@@ -106,6 +106,16 @@ class SnapshotSignature(NamedTuple):
|
|
|
106
106
|
# Codex mutation happens along. Empty when nothing is owed (the writer
|
|
107
107
|
# DELETEs the key at zero), so a fully-ingested store is byte-neutral.
|
|
108
108
|
codex_ingest_backlog_sig: str = ""
|
|
109
|
+
# #556 S3 §2.9: the Claude twin of `codex_stats_digest`. The stats legs
|
|
110
|
+
# above are `MAX(id)` over the two weekly snapshot tables plus the
|
|
111
|
+
# reset-event change signal, and a Claude milestone INSERT or an
|
|
112
|
+
# `alerted_at` arming UPDATE touches none of them — measured: inserting a
|
|
113
|
+
# `budget_milestones` row with `vendor='claude'` left every other leg
|
|
114
|
+
# byte-identical. Without this leg a fired Claude alert could leave the
|
|
115
|
+
# idle path short-circuiting on a retained prior bundle. Unlike the two
|
|
116
|
+
# legs above, this one is a digest and is never empty: a store with no
|
|
117
|
+
# armed Claude alert carries a constant hash, not the empty string.
|
|
118
|
+
claude_stats_digest: str = ""
|
|
109
119
|
|
|
110
120
|
|
|
111
121
|
def _max_id(conn: sqlite3.Connection, table: str) -> int:
|
|
@@ -221,6 +231,7 @@ def compute_signature(
|
|
|
221
231
|
generation: int,
|
|
222
232
|
codex_stats_digest: str = "",
|
|
223
233
|
accounts_digest: str = "",
|
|
234
|
+
claude_stats_digest: str = "",
|
|
224
235
|
) -> SnapshotSignature:
|
|
225
236
|
"""Composite data-version signature across cache.db + stats.db (spec §3).
|
|
226
237
|
|
|
@@ -243,6 +254,7 @@ def compute_signature(
|
|
|
243
254
|
codex_stats_digest=str(codex_stats_digest),
|
|
244
255
|
accounts_digest=str(accounts_digest),
|
|
245
256
|
codex_ingest_backlog_sig=_codex_ingest_backlog_sig(cache_conn),
|
|
257
|
+
claude_stats_digest=str(claude_stats_digest),
|
|
246
258
|
)
|
|
247
259
|
|
|
248
260
|
|
package/bin/cctally
CHANGED
|
@@ -1372,6 +1372,19 @@ _dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
|
|
|
1372
1372
|
_dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
|
|
1373
1373
|
_dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
|
|
1374
1374
|
_empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
|
|
1375
|
+
# #556 S2: the shared cross-provider aggregate range and the two range-native
|
|
1376
|
+
# folds it feeds. Re-exported so the source-bundle builder in
|
|
1377
|
+
# `_cctally_tui._tui_build_source_bundle` reaches them through `_cctally()`,
|
|
1378
|
+
# which is also the seam its fold-failure tests monkeypatch.
|
|
1379
|
+
resolve_shared_range = _cctally_dashboard.resolve_shared_range
|
|
1380
|
+
iter_shared_range_entries = _cctally_dashboard.iter_shared_range_entries
|
|
1381
|
+
fold_projects_over_range = _cctally_dashboard.fold_projects_over_range
|
|
1382
|
+
fold_daily_over_range = _cctally_dashboard.fold_daily_over_range
|
|
1383
|
+
materialise_daily_calendar = _cctally_dashboard.materialise_daily_calendar
|
|
1384
|
+
build_daily_aggregate_rows = _cctally_dashboard.build_daily_aggregate_rows
|
|
1385
|
+
daily_panel_row_to_wire = _cctally_dashboard.daily_panel_row_to_wire
|
|
1386
|
+
build_project_aggregate_rows = _cctally_dashboard.build_project_aggregate_rows
|
|
1387
|
+
legacy_project_labels = _cctally_dashboard.legacy_project_labels
|
|
1375
1388
|
# _iso_z is NOT bound here anymore — the former dashboard-then-forecast
|
|
1376
1389
|
# double-bind collapses to a single canonical bind below (#279 S6 W4).
|
|
1377
1390
|
# Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
|
|
@@ -1864,7 +1877,34 @@ def _sum_cost_for_range(
|
|
|
1864
1877
|
by the per-account ``sync-week`` cost materialization + per-account budget
|
|
1865
1878
|
ladders so a snapshot / budget carries genuinely per-account cost.
|
|
1866
1879
|
"""
|
|
1880
|
+
return _sum_cost_and_tokens_for_range(
|
|
1881
|
+
start, end, mode, project, skip_sync=skip_sync, account_key=account_key,
|
|
1882
|
+
)[0]
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
def _sum_cost_and_tokens_for_range(
|
|
1886
|
+
start: dt.datetime,
|
|
1887
|
+
end: dt.datetime,
|
|
1888
|
+
mode: str = "auto",
|
|
1889
|
+
project: str | None = None,
|
|
1890
|
+
*,
|
|
1891
|
+
skip_sync: bool = False,
|
|
1892
|
+
account_key: "str | None" = None,
|
|
1893
|
+
) -> "tuple[float, int]":
|
|
1894
|
+
"""Sum USD cost and #104 total tokens over ONE walk of the same entries.
|
|
1895
|
+
|
|
1896
|
+
#556 S1 §3.3. The two halves must describe the SAME entry set, so they are
|
|
1897
|
+
accumulated together rather than by a second read: a separate read runs on
|
|
1898
|
+
another connection with its own end-boundary semantics and can legitimately
|
|
1899
|
+
disagree with this one.
|
|
1900
|
+
|
|
1901
|
+
Tokens follow the #104 convention (`input + output + cache_create +
|
|
1902
|
+
cache_read`). `cache_creation_1h_input_tokens` is deliberately absent — it
|
|
1903
|
+
is a TTL SUBDIVISION of `cache_creation_input_tokens` (#195), so adding it
|
|
1904
|
+
would double-count every 1-hour cache write.
|
|
1905
|
+
"""
|
|
1867
1906
|
total = 0.0
|
|
1907
|
+
tokens = 0
|
|
1868
1908
|
for entry in get_entries(start, end, project=project, skip_sync=skip_sync,
|
|
1869
1909
|
account_key=account_key):
|
|
1870
1910
|
total += _calculate_entry_cost(
|
|
@@ -1873,7 +1913,14 @@ def _sum_cost_for_range(
|
|
|
1873
1913
|
mode=mode,
|
|
1874
1914
|
cost_usd=entry.cost_usd,
|
|
1875
1915
|
)
|
|
1876
|
-
|
|
1916
|
+
usage = entry.usage
|
|
1917
|
+
tokens += (
|
|
1918
|
+
int(usage.get("input_tokens", 0) or 0)
|
|
1919
|
+
+ int(usage.get("output_tokens", 0) or 0)
|
|
1920
|
+
+ int(usage.get("cache_creation_input_tokens", 0) or 0)
|
|
1921
|
+
+ int(usage.get("cache_read_input_tokens", 0) or 0)
|
|
1922
|
+
)
|
|
1923
|
+
return total, tokens
|
|
1877
1924
|
|
|
1878
1925
|
|
|
1879
1926
|
def _bridge_z_into_tz(args: argparse.Namespace,
|