cctally 1.67.1 → 1.69.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 +68 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +754 -109
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +187 -15
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +366 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +131 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +321 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +238 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +325 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_lib_share.py
CHANGED
|
@@ -14,6 +14,7 @@ import re
|
|
|
14
14
|
from collections.abc import Callable, Mapping
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from datetime import datetime
|
|
17
|
+
from typing import Literal
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
# --- Version + digest ---
|
|
@@ -74,6 +75,7 @@ class ProjectCell:
|
|
|
74
75
|
MoneyCells (back-compat for every non-budget construction site)."""
|
|
75
76
|
label: str
|
|
76
77
|
rank_cost: float | None = None
|
|
78
|
+
identity: str | None = None
|
|
77
79
|
|
|
78
80
|
Cell = TextCell | MoneyCell | PercentCell | DateCell | DeltaCell | ProjectCell
|
|
79
81
|
|
|
@@ -87,6 +89,7 @@ class ColumnSpec:
|
|
|
87
89
|
align: str = "left" # "left" | "right" | "center"
|
|
88
90
|
emphasis: bool = False
|
|
89
91
|
kind: str | None = None # "project" | "model" | None — privacy chokepoint signal
|
|
92
|
+
project_identity: str | None = None
|
|
90
93
|
|
|
91
94
|
|
|
92
95
|
@dataclass(frozen=True)
|
|
@@ -117,6 +120,7 @@ class ChartPoint:
|
|
|
117
120
|
y_value: float
|
|
118
121
|
project_label: str | None = None
|
|
119
122
|
series_key: str | None = None
|
|
123
|
+
project_identity: str | None = None
|
|
120
124
|
|
|
121
125
|
|
|
122
126
|
@dataclass(frozen=True)
|
|
@@ -179,6 +183,21 @@ class ShareSnapshot:
|
|
|
179
183
|
generated_at: datetime
|
|
180
184
|
version: str
|
|
181
185
|
template_id: str | None = None
|
|
186
|
+
source: Literal["claude", "codex"] = "claude"
|
|
187
|
+
source_label: str | None = None
|
|
188
|
+
availability: Literal["ok", "empty", "unavailable"] = "ok"
|
|
189
|
+
availability_reason: str | None = None
|
|
190
|
+
|
|
191
|
+
def __post_init__(self) -> None:
|
|
192
|
+
if self.source not in {"claude", "codex"}:
|
|
193
|
+
raise ValueError("share source must be claude or codex")
|
|
194
|
+
if self.availability not in {"ok", "empty", "unavailable"}:
|
|
195
|
+
raise ValueError("share availability must be ok, empty, or unavailable")
|
|
196
|
+
if self.availability == "unavailable":
|
|
197
|
+
if not self.availability_reason:
|
|
198
|
+
raise ValueError("unavailable share snapshots require a reason")
|
|
199
|
+
elif self.availability_reason is not None:
|
|
200
|
+
raise ValueError("availability reason is only valid for unavailable snapshots")
|
|
182
201
|
|
|
183
202
|
|
|
184
203
|
# --- Compose: multi-section stitching (M3.1) ---
|
|
@@ -720,12 +739,25 @@ def _render_svg_header(snap: ShareSnapshot, *, palette: dict,
|
|
|
720
739
|
if snap.subtitle:
|
|
721
740
|
elements.append(svg_text(x, y + 36, snap.subtitle,
|
|
722
741
|
font_size=12, fill=palette["muted"]))
|
|
742
|
+
source_label, availability = _source_chrome(snap)
|
|
743
|
+
if source_label:
|
|
744
|
+
source_text = source_label if availability is None else f"{source_label} · {availability}"
|
|
745
|
+
elements.append(svg_text(x, y + 54, source_text,
|
|
746
|
+
font_size=10, fill=palette["muted"]))
|
|
723
747
|
elements.append(svg_text(x + width, y + 18,
|
|
724
748
|
_format_generated_at_iso(snap.generated_at),
|
|
725
749
|
font_size=10, fill=palette["muted"], anchor="end"))
|
|
726
750
|
return svg_group(elements)
|
|
727
751
|
|
|
728
752
|
|
|
753
|
+
def _svg_header_height(snap: ShareSnapshot, *, include_chrome: bool) -> float:
|
|
754
|
+
"""Reserve the added source/availability line without perturbing v1 SVGs."""
|
|
755
|
+
if not include_chrome:
|
|
756
|
+
return 0.0
|
|
757
|
+
source_label, _availability = _source_chrome(snap)
|
|
758
|
+
return _SVG_HEADER_H + (18.0 if source_label else 0.0)
|
|
759
|
+
|
|
760
|
+
|
|
729
761
|
def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
|
|
730
762
|
x: float, y: float, width: float, branding: bool) -> str:
|
|
731
763
|
if not branding:
|
|
@@ -739,6 +771,26 @@ def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
|
|
|
739
771
|
])
|
|
740
772
|
|
|
741
773
|
|
|
774
|
+
def _source_chrome(snap: ShareSnapshot) -> tuple[str | None, str | None]:
|
|
775
|
+
"""Return privacy-safe provider and availability text for new snapshots.
|
|
776
|
+
|
|
777
|
+
The all-default Claude shape deliberately returns no provider line so every
|
|
778
|
+
existing share artifact remains byte-for-byte unchanged.
|
|
779
|
+
"""
|
|
780
|
+
show_source = (
|
|
781
|
+
snap.source != "claude"
|
|
782
|
+
or snap.source_label is not None
|
|
783
|
+
or snap.availability != "ok"
|
|
784
|
+
)
|
|
785
|
+
label = snap.source_label or ("Claude" if snap.source == "claude" else "Codex")
|
|
786
|
+
status = (
|
|
787
|
+
"No data" if snap.availability == "empty" else
|
|
788
|
+
f"Unavailable: {snap.availability_reason}"
|
|
789
|
+
if snap.availability == "unavailable" else None
|
|
790
|
+
)
|
|
791
|
+
return (label if show_source else None, status)
|
|
792
|
+
|
|
793
|
+
|
|
742
794
|
# --- Scrubber ---
|
|
743
795
|
#
|
|
744
796
|
# Anonymization chokepoint (spec Section 5.3 / 7 / 8.4). Operates on a
|
|
@@ -751,7 +803,15 @@ def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
|
|
|
751
803
|
# costs` (gather) and `_apply_anon_mapping` (rewrite) must be extended.
|
|
752
804
|
|
|
753
805
|
|
|
754
|
-
|
|
806
|
+
_ProjectAnonKey = tuple[Literal["legacy", "qualified"], str]
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _project_anon_key(label: str, identity: str | None) -> _ProjectAnonKey:
|
|
810
|
+
"""Keep opaque qualified keys disjoint from legacy display labels."""
|
|
811
|
+
return ("qualified", identity) if identity is not None else ("legacy", label)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _collect_project_identity_costs(snap: ShareSnapshot) -> dict[_ProjectAnonKey, float]:
|
|
755
815
|
"""Walk rows: for each row containing a ProjectCell, sum MoneyCell values
|
|
756
816
|
in the same row under the project label — unless the ProjectCell carries an
|
|
757
817
|
explicit ``rank_cost``, which takes precedence over the MoneyCell sum (#130).
|
|
@@ -759,20 +819,19 @@ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
|
|
|
759
819
|
Charts also contribute via ChartPoint.project_label + y_value (when y_value
|
|
760
820
|
is in $). For consistency we union both sources; rows take precedence on
|
|
761
821
|
duplicates."""
|
|
762
|
-
costs: dict[
|
|
822
|
+
costs: dict[_ProjectAnonKey, float] = {}
|
|
763
823
|
for row in snap.rows:
|
|
764
|
-
|
|
824
|
+
project: ProjectCell | None = None
|
|
765
825
|
money = 0.0
|
|
766
|
-
explicit_rank: float | None = None
|
|
767
826
|
for cell in row.cells.values():
|
|
768
827
|
if isinstance(cell, ProjectCell):
|
|
769
|
-
|
|
770
|
-
explicit_rank = cell.rank_cost
|
|
828
|
+
project = cell
|
|
771
829
|
elif isinstance(cell, MoneyCell):
|
|
772
830
|
money += cell.usd
|
|
773
|
-
if
|
|
774
|
-
|
|
775
|
-
|
|
831
|
+
if project is not None:
|
|
832
|
+
key = _project_anon_key(project.label, project.identity)
|
|
833
|
+
contribution = project.rank_cost if project.rank_cost is not None else money
|
|
834
|
+
costs[key] = costs.get(key, 0.0) + contribution
|
|
776
835
|
|
|
777
836
|
if snap.chart is not None:
|
|
778
837
|
chart_pts: list[ChartPoint] = []
|
|
@@ -792,8 +851,10 @@ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
|
|
|
792
851
|
# bar charts but may be a ratio for trend charts. Affects sort order of
|
|
793
852
|
# project-N labels, not anonymization correctness.
|
|
794
853
|
for p in chart_pts:
|
|
795
|
-
if p.project_label
|
|
796
|
-
|
|
854
|
+
if p.project_label:
|
|
855
|
+
key = _project_anon_key(p.project_label, p.project_identity)
|
|
856
|
+
if key not in costs:
|
|
857
|
+
costs[key] = p.y_value
|
|
797
858
|
|
|
798
859
|
# project-typed columns (cross-tab Detail templates, issue #33). Sum the
|
|
799
860
|
# MoneyCell values for each kind='project' column across all rows; the
|
|
@@ -809,32 +870,58 @@ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
|
|
|
809
870
|
cell = row.cells.get(col.key)
|
|
810
871
|
if isinstance(cell, MoneyCell):
|
|
811
872
|
col_total += cell.usd
|
|
812
|
-
|
|
873
|
+
key = _project_anon_key(col.label, col.project_identity)
|
|
874
|
+
costs[key] = costs.get(key, 0.0) + col_total
|
|
875
|
+
|
|
876
|
+
return costs
|
|
813
877
|
|
|
878
|
+
|
|
879
|
+
def _collect_project_costs(
|
|
880
|
+
snap: ShareSnapshot,
|
|
881
|
+
) -> dict[_ProjectAnonKey, float] | dict[str, float]:
|
|
882
|
+
"""Return the historical legacy shape unless qualified identity is present.
|
|
883
|
+
|
|
884
|
+
The scrubber consumes :func:`_collect_project_identity_costs` directly so
|
|
885
|
+
its internal keys remain domain tagged. This compatibility facade keeps
|
|
886
|
+
callers of the older all-``None`` model on their established string-key
|
|
887
|
+
contract.
|
|
888
|
+
"""
|
|
889
|
+
costs = _collect_project_identity_costs(snap)
|
|
890
|
+
if all(domain == "legacy" for domain, _value in costs):
|
|
891
|
+
return {label: cost for (_domain, label), cost in costs.items()}
|
|
814
892
|
return costs
|
|
815
893
|
|
|
816
894
|
|
|
817
|
-
def _build_anon_mapping(
|
|
818
|
-
|
|
895
|
+
def _build_anon_mapping(
|
|
896
|
+
project_costs: dict[_ProjectAnonKey, float] | dict[str, float],
|
|
897
|
+
) -> dict[_ProjectAnonKey, str] | dict[str, str]:
|
|
898
|
+
"""Sort identities by descending cost (lex tie-break); assign project-1, project-2, ...
|
|
819
899
|
|
|
820
900
|
"(unknown)" is never numbered — keeps its literal label.
|
|
821
901
|
"""
|
|
902
|
+
legacy_input = all(isinstance(key, str) for key in project_costs)
|
|
903
|
+
normalized: dict[_ProjectAnonKey, float] = {
|
|
904
|
+
(("legacy", key) if isinstance(key, str) else key): cost
|
|
905
|
+
for key, cost in project_costs.items()
|
|
906
|
+
}
|
|
822
907
|
items = [
|
|
823
|
-
(
|
|
824
|
-
for
|
|
825
|
-
if
|
|
908
|
+
(key, cost)
|
|
909
|
+
for key, cost in normalized.items()
|
|
910
|
+
if key != ("legacy", "(unknown)")
|
|
826
911
|
]
|
|
827
912
|
items.sort(key=lambda kv: (-kv[1], kv[0]))
|
|
828
|
-
mapping: dict[
|
|
829
|
-
|
|
913
|
+
mapping: dict[_ProjectAnonKey, str] = {
|
|
914
|
+
key: f"project-{i + 1}" for i, (key, _cost) in enumerate(items)
|
|
830
915
|
}
|
|
831
|
-
if "(unknown)" in
|
|
832
|
-
mapping["(unknown)"] = "(unknown)"
|
|
916
|
+
if ("legacy", "(unknown)") in normalized:
|
|
917
|
+
mapping[("legacy", "(unknown)")] = "(unknown)"
|
|
918
|
+
if legacy_input:
|
|
919
|
+
return {key[1]: value for key, value in mapping.items()}
|
|
833
920
|
return mapping
|
|
834
921
|
|
|
835
922
|
|
|
836
923
|
def _apply_anon_mapping(
|
|
837
|
-
snap: ShareSnapshot, mapping: dict[str, str],
|
|
924
|
+
snap: ShareSnapshot, mapping: dict[_ProjectAnonKey, str] | dict[str, str],
|
|
838
925
|
) -> ShareSnapshot:
|
|
839
926
|
"""Return a new ShareSnapshot with project labels replaced everywhere.
|
|
840
927
|
|
|
@@ -842,13 +929,20 @@ def _apply_anon_mapping(
|
|
|
842
929
|
(b) ChartPoint.project_label AND .x_label (when x_label == project_label,
|
|
843
930
|
i.e. project-axis charts) on chart.points + multi_series + stacks.
|
|
844
931
|
"""
|
|
932
|
+
tagged_mapping: dict[_ProjectAnonKey, str] = {
|
|
933
|
+
("legacy", key) if isinstance(key, str) else key: value
|
|
934
|
+
for key, value in mapping.items()
|
|
935
|
+
}
|
|
845
936
|
new_rows: list[Row] = []
|
|
846
937
|
for row in snap.rows:
|
|
847
938
|
new_cells: dict[str, Cell] = {}
|
|
848
939
|
for key, cell in row.cells.items():
|
|
849
|
-
if isinstance(cell, ProjectCell)
|
|
940
|
+
if isinstance(cell, ProjectCell):
|
|
941
|
+
project_key = _project_anon_key(cell.label, cell.identity)
|
|
850
942
|
new_cells[key] = ProjectCell(
|
|
851
|
-
|
|
943
|
+
tagged_mapping.get(project_key, "(unknown)"),
|
|
944
|
+
rank_cost=cell.rank_cost,
|
|
945
|
+
identity=cell.identity,
|
|
852
946
|
)
|
|
853
947
|
else:
|
|
854
948
|
new_cells[key] = cell
|
|
@@ -863,7 +957,8 @@ def _apply_anon_mapping(
|
|
|
863
957
|
# points after gather) is mapped to "(unknown)" rather than
|
|
864
958
|
# passed through. Privacy invariant: never leak a non-anonymized
|
|
865
959
|
# label, even if the gather pass missed it.
|
|
866
|
-
|
|
960
|
+
project_key = _project_anon_key(p.project_label, p.project_identity)
|
|
961
|
+
new_label = tagged_mapping.get(project_key, "(unknown)")
|
|
867
962
|
else:
|
|
868
963
|
new_label = None
|
|
869
964
|
# x_label rewrite stays guarded — only anonymize if x_label is the
|
|
@@ -871,8 +966,8 @@ def _apply_anon_mapping(
|
|
|
871
966
|
# x_label values like time labels).
|
|
872
967
|
if (p.project_label
|
|
873
968
|
and p.x_label == p.project_label
|
|
874
|
-
and
|
|
875
|
-
new_x =
|
|
969
|
+
and project_key in tagged_mapping):
|
|
970
|
+
new_x = tagged_mapping[project_key]
|
|
876
971
|
else:
|
|
877
972
|
new_x = p.x_label
|
|
878
973
|
return ChartPoint(
|
|
@@ -881,6 +976,7 @@ def _apply_anon_mapping(
|
|
|
881
976
|
y_value=p.y_value,
|
|
882
977
|
project_label=new_label,
|
|
883
978
|
series_key=p.series_key,
|
|
979
|
+
project_identity=p.project_identity,
|
|
884
980
|
)
|
|
885
981
|
|
|
886
982
|
if isinstance(snap.chart, LineChart):
|
|
@@ -918,10 +1014,13 @@ def _apply_anon_mapping(
|
|
|
918
1014
|
new_columns: list[ColumnSpec] = []
|
|
919
1015
|
for col in snap.columns:
|
|
920
1016
|
if col.kind == "project":
|
|
921
|
-
new_label =
|
|
1017
|
+
new_label = tagged_mapping.get(
|
|
1018
|
+
_project_anon_key(col.label, col.project_identity), "(unknown)",
|
|
1019
|
+
)
|
|
922
1020
|
new_columns.append(ColumnSpec(
|
|
923
1021
|
key=col.key, label=new_label,
|
|
924
1022
|
align=col.align, emphasis=col.emphasis, kind=col.kind,
|
|
1023
|
+
project_identity=col.project_identity,
|
|
925
1024
|
))
|
|
926
1025
|
else:
|
|
927
1026
|
new_columns.append(col)
|
|
@@ -941,6 +1040,10 @@ def _apply_anon_mapping(
|
|
|
941
1040
|
generated_at=snap.generated_at,
|
|
942
1041
|
version=snap.version,
|
|
943
1042
|
template_id=snap.template_id,
|
|
1043
|
+
source=snap.source,
|
|
1044
|
+
source_label=snap.source_label,
|
|
1045
|
+
availability=snap.availability,
|
|
1046
|
+
availability_reason=snap.availability_reason,
|
|
944
1047
|
)
|
|
945
1048
|
|
|
946
1049
|
|
|
@@ -955,7 +1058,7 @@ def _scrub(snap: ShareSnapshot, *, reveal_projects: bool) -> ShareSnapshot:
|
|
|
955
1058
|
"""
|
|
956
1059
|
if reveal_projects:
|
|
957
1060
|
return snap
|
|
958
|
-
project_costs =
|
|
1061
|
+
project_costs = _collect_project_identity_costs(snap)
|
|
959
1062
|
if not project_costs:
|
|
960
1063
|
return snap
|
|
961
1064
|
mapping = _build_anon_mapping(project_costs)
|
|
@@ -974,6 +1077,11 @@ def _render_md_fragment(snap: ShareSnapshot, *, branding: bool) -> str:
|
|
|
974
1077
|
so future surfaces (compose, history) extend it once.
|
|
975
1078
|
"""
|
|
976
1079
|
parts = [f"# {_md_escape(snap.title)}"]
|
|
1080
|
+
source_label, availability = _source_chrome(snap)
|
|
1081
|
+
if source_label:
|
|
1082
|
+
parts.extend(["", f"**{_md_escape(source_label)}**"])
|
|
1083
|
+
if availability:
|
|
1084
|
+
parts.extend(["", _md_escape(availability)])
|
|
977
1085
|
if snap.subtitle:
|
|
978
1086
|
parts.append(f"_{_md_escape(snap.subtitle)}_")
|
|
979
1087
|
parts.append(f"_{_format_generated_at_iso(snap.generated_at)}_")
|
|
@@ -1277,10 +1385,11 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
|
1277
1385
|
"""
|
|
1278
1386
|
has_table = include_table and bool(snap.columns)
|
|
1279
1387
|
chart_h = _SVG_CHART_H if snap.chart is not None else 0
|
|
1388
|
+
header_h = _svg_header_height(snap, include_chrome=include_chrome)
|
|
1280
1389
|
|
|
1281
1390
|
# Pre-layout the table (we need its height before declaring outer SVG height).
|
|
1282
1391
|
if has_table:
|
|
1283
|
-
table_y = _SVG_PADDING +
|
|
1392
|
+
table_y = _SVG_PADDING + header_h + chart_h + _SVG_TABLE_GAP
|
|
1284
1393
|
table_svg, table_h, table_w = _render_svg_table(
|
|
1285
1394
|
snap, palette=palette, x=_SVG_PADDING, y=table_y, max_width=_SVG_WIDTH,
|
|
1286
1395
|
)
|
|
@@ -1297,7 +1406,7 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
|
1297
1406
|
table_block_h = (_SVG_TABLE_GAP + table_h) if has_table else 0
|
|
1298
1407
|
|
|
1299
1408
|
if include_chrome:
|
|
1300
|
-
height =
|
|
1409
|
+
height = header_h + chart_h + table_block_h + _SVG_FOOTER_H + (_SVG_PADDING * 2)
|
|
1301
1410
|
else:
|
|
1302
1411
|
height = chart_h + table_block_h + (_SVG_PADDING * 2)
|
|
1303
1412
|
|
|
@@ -1309,7 +1418,7 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
|
1309
1418
|
x=_SVG_PADDING, y=_SVG_PADDING, width=_SVG_WIDTH,
|
|
1310
1419
|
))
|
|
1311
1420
|
|
|
1312
|
-
chart_y = _SVG_PADDING +
|
|
1421
|
+
chart_y = _SVG_PADDING + header_h
|
|
1313
1422
|
if snap.chart is not None:
|
|
1314
1423
|
if isinstance(snap.chart, LineChart):
|
|
1315
1424
|
pieces.append(_render_line_chart_svg(
|
|
@@ -1331,7 +1440,7 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
|
1331
1440
|
pieces.append(table_svg)
|
|
1332
1441
|
|
|
1333
1442
|
if include_chrome:
|
|
1334
|
-
footer_y = _SVG_PADDING +
|
|
1443
|
+
footer_y = _SVG_PADDING + header_h + chart_h + table_block_h + _SVG_FOOTER_BASELINE
|
|
1335
1444
|
pieces.append(_render_svg_footer(
|
|
1336
1445
|
snap, palette=palette,
|
|
1337
1446
|
x=_SVG_PADDING, y=footer_y,
|
|
@@ -1429,6 +1538,15 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
|
|
|
1429
1538
|
if snap.chart is not None else ""
|
|
1430
1539
|
)
|
|
1431
1540
|
title_html = f'<h1 style="font-size:20px;color:{palette["fg"]};margin:0">{_xml_escape(snap.title)}</h1>'
|
|
1541
|
+
source_label, availability = _source_chrome(snap)
|
|
1542
|
+
source_html = (
|
|
1543
|
+
f'<div style="font-size:13px;color:{palette["muted"]};margin-top:4px">{_xml_escape(source_label)}</div>'
|
|
1544
|
+
if source_label else ""
|
|
1545
|
+
)
|
|
1546
|
+
availability_html = (
|
|
1547
|
+
f'<div style="font-size:13px;color:{palette["fg"]};margin-top:4px">{_xml_escape(availability)}</div>'
|
|
1548
|
+
if availability else ""
|
|
1549
|
+
)
|
|
1432
1550
|
subtitle_html = (
|
|
1433
1551
|
f'<div style="font-size:13px;color:{palette["muted"]};margin-top:4px">{_xml_escape(snap.subtitle)}</div>'
|
|
1434
1552
|
if snap.subtitle else ""
|
|
@@ -1452,7 +1570,7 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
|
|
|
1452
1570
|
else:
|
|
1453
1571
|
footer_html = ""
|
|
1454
1572
|
return (
|
|
1455
|
-
f'<header>{title_html}{subtitle_html}{timestamp_html}</header>'
|
|
1573
|
+
f'<header>{title_html}{source_html}{availability_html}{subtitle_html}{timestamp_html}</header>'
|
|
1456
1574
|
f'{chart_html}'
|
|
1457
1575
|
f'{table_html}'
|
|
1458
1576
|
f'{footer_html}'
|
|
@@ -85,6 +85,12 @@ class SnapshotSignature(NamedTuple):
|
|
|
85
85
|
# finalization UPSERT advances this leg while `max_entry_id` stays flat, so
|
|
86
86
|
# the dashboard leaves the idle path and recomputes the affected bucket.
|
|
87
87
|
entry_mutation_seq: int = 0
|
|
88
|
+
# #294 S4: Codex metadata, root, quota, and destructive mutations can
|
|
89
|
+
# leave `MAX(codex_session_entries.id)` flat. The cache-local physical
|
|
90
|
+
# sequence supplies that missing identity leg; the stats digest arrives
|
|
91
|
+
# from the independently-committed quota/budget projection database.
|
|
92
|
+
codex_physical_mutation_seq: int = 0
|
|
93
|
+
codex_stats_digest: str = ""
|
|
88
94
|
|
|
89
95
|
|
|
90
96
|
def _max_id(conn: sqlite3.Connection, table: str) -> int:
|
|
@@ -127,6 +133,23 @@ def _entry_mutation_seq(conn: sqlite3.Connection) -> int:
|
|
|
127
133
|
return int(row[0])
|
|
128
134
|
|
|
129
135
|
|
|
136
|
+
def _codex_physical_mutation_seq(conn: sqlite3.Connection) -> int:
|
|
137
|
+
"""Read the O(1) Codex physical mutation sequence, degrading to zero."""
|
|
138
|
+
try:
|
|
139
|
+
row = conn.execute(
|
|
140
|
+
"SELECT value FROM cache_meta "
|
|
141
|
+
"WHERE key='codex_physical_mutation_seq'"
|
|
142
|
+
).fetchone()
|
|
143
|
+
except sqlite3.Error:
|
|
144
|
+
return 0
|
|
145
|
+
if row is None or row[0] is None:
|
|
146
|
+
return 0
|
|
147
|
+
try:
|
|
148
|
+
return int(row[0])
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
130
153
|
def _reset_sig(conn: sqlite3.Connection) -> tuple[int, int]:
|
|
131
154
|
"""Change-signal over the two reset-event tables combined (spec §3).
|
|
132
155
|
|
|
@@ -155,6 +178,7 @@ def compute_signature(
|
|
|
155
178
|
stats_conn: sqlite3.Connection,
|
|
156
179
|
*,
|
|
157
180
|
generation: int,
|
|
181
|
+
codex_stats_digest: str = "",
|
|
158
182
|
) -> SnapshotSignature:
|
|
159
183
|
"""Composite data-version signature across cache.db + stats.db (spec §3).
|
|
160
184
|
|
|
@@ -173,6 +197,8 @@ def compute_signature(
|
|
|
173
197
|
max_codex_id=_max_id(cache_conn, "codex_session_entries"),
|
|
174
198
|
generation=int(generation),
|
|
175
199
|
entry_mutation_seq=_entry_mutation_seq(cache_conn),
|
|
200
|
+
codex_physical_mutation_seq=_codex_physical_mutation_seq(cache_conn),
|
|
201
|
+
codex_stats_digest=str(codex_stats_digest),
|
|
176
202
|
)
|
|
177
203
|
|
|
178
204
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Pure, opaque provider-qualified identity helpers for #294 S0/S1."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
IDENTITY_VERSION = 1
|
|
11
|
+
_SOURCES = frozenset(("claude", "codex"))
|
|
12
|
+
_SOURCE_ROOT_KEY_RE = re.compile(r"[0-9a-f]{32}\Z")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _required_string(value: object, name: str) -> str:
|
|
16
|
+
if not isinstance(value, str) or not value:
|
|
17
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def source_root_key(canonical_root: str) -> str:
|
|
22
|
+
"""Return the non-reversible, domain-separated key for one source root."""
|
|
23
|
+
root = _required_string(canonical_root, "canonical_root")
|
|
24
|
+
digest = hashlib.sha256(b"cctally-source-root-v1\0" + root.encode("utf-8"))
|
|
25
|
+
return digest.hexdigest()[:32]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def canonical_identity(
|
|
29
|
+
source: str,
|
|
30
|
+
resource_kind: str,
|
|
31
|
+
source_root: str | None,
|
|
32
|
+
native_key: str,
|
|
33
|
+
parent_key: str | None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Encode an opaque IdentityV1 after deriving an optional root key."""
|
|
36
|
+
root_key = None
|
|
37
|
+
if source_root is not None:
|
|
38
|
+
root_key = source_root_key(source_root)
|
|
39
|
+
return canonical_identity_from_root_key(
|
|
40
|
+
source, resource_kind, root_key, native_key, parent_key
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def canonical_identity_from_root_key(
|
|
45
|
+
source: str,
|
|
46
|
+
resource_kind: str,
|
|
47
|
+
source_root_key: str | None,
|
|
48
|
+
native_key: str,
|
|
49
|
+
parent_key: str | None,
|
|
50
|
+
) -> str:
|
|
51
|
+
"""Encode an opaque IdentityV1 from an already-derived source-root key."""
|
|
52
|
+
if source not in _SOURCES:
|
|
53
|
+
raise ValueError(f"source must be one of {sorted(_SOURCES)}")
|
|
54
|
+
kind = _required_string(resource_kind, "resource_kind")
|
|
55
|
+
native = _required_string(native_key, "native_key")
|
|
56
|
+
parent = None if parent_key is None else _required_string(parent_key, "parent_key")
|
|
57
|
+
if source_root_key is not None:
|
|
58
|
+
root_key = _required_string(source_root_key, "source_root_key")
|
|
59
|
+
if not _SOURCE_ROOT_KEY_RE.fullmatch(root_key):
|
|
60
|
+
raise ValueError("source_root_key must be a 32-character lowercase hex key")
|
|
61
|
+
else:
|
|
62
|
+
root_key = None
|
|
63
|
+
payload = {
|
|
64
|
+
"nativeKey": native,
|
|
65
|
+
"parentKey": parent,
|
|
66
|
+
"resourceKind": kind,
|
|
67
|
+
"source": source,
|
|
68
|
+
"sourceRootKey": root_key,
|
|
69
|
+
"version": IDENTITY_VERSION,
|
|
70
|
+
}
|
|
71
|
+
canonical = json.dumps(
|
|
72
|
+
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
73
|
+
).encode("utf-8")
|
|
74
|
+
return "v1." + base64.urlsafe_b64encode(canonical).decode("ascii").rstrip("=")
|