cctally 1.108.0 → 1.110.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 +147 -0
- package/README.md +6 -6
- package/bin/_cctally_account.py +4 -0
- package/bin/_cctally_alerts.py +6 -2
- package/bin/_cctally_cache.py +1652 -166
- package/bin/_cctally_config.py +3 -2
- package/bin/_cctally_core.py +594 -33
- package/bin/_cctally_dashboard.py +1585 -201
- package/bin/_cctally_dashboard_conversation.py +494 -48
- package/bin/_cctally_dashboard_envelope.py +167 -11
- package/bin/_cctally_dashboard_share.py +154 -36
- package/bin/_cctally_dashboard_sources.py +2615 -422
- package/bin/_cctally_db.py +678 -5
- package/bin/_cctally_diagnosis_sources.py +238 -26
- package/bin/_cctally_doctor.py +99 -18
- package/bin/_cctally_five_hour.py +1295 -40
- package/bin/_cctally_forecast.py +124 -28
- package/bin/_cctally_journal.py +408 -38
- package/bin/_cctally_milestone_history.py +150 -14
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_percent_breakdown.py +6 -1
- package/bin/_cctally_project.py +48 -76
- package/bin/_cctally_quota.py +39 -7
- package/bin/_cctally_quota_model.py +17 -2
- package/bin/_cctally_record.py +1647 -443
- package/bin/_cctally_rederive.py +899 -30
- package/bin/_cctally_refresh.py +2 -1
- package/bin/_cctally_share.py +24 -5
- package/bin/_cctally_source_analytics.py +1035 -78
- package/bin/_cctally_statusline.py +83 -19
- package/bin/_cctally_store.py +61 -4
- package/bin/_cctally_transcript.py +27 -2
- package/bin/_cctally_tui.py +264 -76
- package/bin/_cctally_weekrefs.py +28 -5
- package/bin/_lib_blocks.py +127 -20
- package/bin/_lib_cache_report.py +20 -5
- package/bin/_lib_codex_conversation_query.py +297 -64
- package/bin/_lib_codex_hooks.py +102 -0
- package/bin/_lib_codex_metadata.py +207 -0
- package/bin/_lib_conversation.py +119 -0
- package/bin/_lib_conversation_dispatch.py +31 -1
- package/bin/_lib_conversation_query.py +315 -51
- package/bin/_lib_conversation_retention.py +413 -25
- package/bin/_lib_dashboard_sources.py +267 -7
- package/bin/_lib_diagnosis.py +10 -0
- package/bin/_lib_diff_kernel.py +12 -1
- package/bin/_lib_doctor.py +230 -8
- package/bin/_lib_ingest_frontier.py +1366 -49
- package/bin/_lib_journal.py +19 -2
- package/bin/_lib_merge_gate.py +307 -0
- package/bin/_lib_pricing.py +588 -29
- package/bin/_lib_record.py +130 -12
- package/bin/_lib_rederive.py +903 -2
- package/bin/_lib_render.py +9 -3
- package/bin/_lib_retained_size.py +27 -2
- package/bin/_lib_segment_summary.py +66 -8
- package/bin/_lib_share_templates.py +28 -18
- package/bin/_lib_snapshot_cache.py +115 -1
- package/bin/_lib_source_retry.py +320 -0
- package/bin/_lib_statusline_candidates.py +400 -36
- package/bin/_lib_tick_stats.py +119 -0
- package/bin/_lib_view_models.py +256 -76
- package/bin/cctally +69 -0
- package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
- package/dashboard/static/assets/{DoctorModal-CWn3U4Wl.js → DoctorModal-DFY2iPDf.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
- package/dashboard/static/assets/ProjectsDrillPanel-DAv39uSo.js +1 -0
- package/dashboard/static/assets/SourceDetailModal-DSAWo2Fp.js +1 -0
- package/dashboard/static/assets/{UpdateModal-D3m8GG6V.js → UpdateModal-R_9dOVnh.js} +3 -3
- package/dashboard/static/assets/dashboardStream.shared-worker-DbG6Ef0T.js +1 -0
- package/dashboard/static/assets/index-COhxe3vU.css +1 -0
- package/dashboard/static/assets/index-DdU62sov.js +13 -0
- package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
- package/dashboard/static/assets/useKeymap-CW7Up0Ay.js +1 -0
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +4 -1
- package/dashboard/static/assets/ConversationsView-BOSaBRtu.js +0 -72
- package/dashboard/static/assets/ModalRoot-SR070V6c.js +0 -1
- package/dashboard/static/assets/ProjectsDrillPanel-QLI9i5mZ.js +0 -1
- package/dashboard/static/assets/SourceDetailModal-pv0wlFxl.js +0 -1
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +0 -1
- package/dashboard/static/assets/index-BHu4mxd8.css +0 -1
- package/dashboard/static/assets/index-CIWsbux3.js +0 -13
- package/dashboard/static/assets/outlineNavigation-CJvKqmLV.js +0 -9
- package/dashboard/static/assets/useKeymap-ffqsS5G0.js +0 -1
package/bin/_lib_render.py
CHANGED
|
@@ -2890,9 +2890,15 @@ def _render_project_table(
|
|
|
2890
2890
|
return ("\u2014", _gray) # em-dash for unknown
|
|
2891
2891
|
base = f"{ap:.1f}%"
|
|
2892
2892
|
if weeks_in_range > 1:
|
|
2893
|
-
#
|
|
2894
|
-
# (
|
|
2895
|
-
|
|
2893
|
+
# The count of BILLING CYCLES the window covers, surfaced as an
|
|
2894
|
+
# `(Ncy)` suffix. It was `(Nwk)`, and a credited week contributes
|
|
2895
|
+
# two cycles, so the abbreviation named the wrong unit — the same
|
|
2896
|
+
# inconsistency #750 S4 D2 removes from every other surface. `cy`
|
|
2897
|
+
# rather than the spelled-out word because the suffix is exactly as
|
|
2898
|
+
# wide as before: the cell already truncates to `80.0% (…` at
|
|
2899
|
+
# ordinary terminal widths, and a longer token would truncate more
|
|
2900
|
+
# often. The summary line below the table spells `cycles` out.
|
|
2901
|
+
base = f"{base} ({weeks_in_range}cy)"
|
|
2896
2902
|
return (base, _used_pct_color(ap))
|
|
2897
2903
|
|
|
2898
2904
|
def _cost_per_pct_cell(cpp: float | None) -> tuple[str, Any]:
|
|
@@ -110,6 +110,8 @@ def retained_size_bytes(
|
|
|
110
110
|
*,
|
|
111
111
|
stop_after: int | None = None,
|
|
112
112
|
cancelled: Callable[[], bool] | None = None,
|
|
113
|
+
borrowed: Callable[[object], bool] | None = None,
|
|
114
|
+
opaque: Callable[[object], bool] | None = None,
|
|
113
115
|
_object_id: Callable[[object], int] = id,
|
|
114
116
|
) -> int:
|
|
115
117
|
"""Return owned reachable bytes, or ``stop_after + 1`` once over budget.
|
|
@@ -120,6 +122,25 @@ def retained_size_bytes(
|
|
|
120
122
|
charged for their object shell and, when present, ``__dict__``/``__slots__``.
|
|
121
123
|
Modules, functions and classes therefore do not cause an unbounded walk of
|
|
122
124
|
interpreter-global state.
|
|
125
|
+
|
|
126
|
+
``borrowed`` is the ownership-graph seam (#769 S6 T2a, #716 Task B). It
|
|
127
|
+
answers, for one referent, "does some OTHER declared owner carry this
|
|
128
|
+
payload's charge?". A borrowed referent is charged nothing AND is not
|
|
129
|
+
descended into, so a cache that merely repeats a payload another cache
|
|
130
|
+
retains pays for its own containers and references and for nothing else.
|
|
131
|
+
That is what makes a sum of per-owner charges comparable with one walk of
|
|
132
|
+
the whole retained graph instead of double counting every repeat. The
|
|
133
|
+
predicate is never applied to ``value`` itself: a caller asking for the
|
|
134
|
+
size of an object it declares borrowed is asking a contradictory question,
|
|
135
|
+
and answering zero would silently drop the owner's own charge.
|
|
136
|
+
|
|
137
|
+
``opaque`` is the same seam for a CONTAINER of borrowed payloads. The
|
|
138
|
+
container's own shell is the owner's charge — a tuple of 200,000 borrowed
|
|
139
|
+
rows costs eight bytes a slot and somebody has to pay for them — but
|
|
140
|
+
descending into it to ask ``borrowed`` about each slot would make the
|
|
141
|
+
charge proportional to a population the owner does not own. An opaque
|
|
142
|
+
referent is therefore charged its ``sys.getsizeof`` and not descended into.
|
|
143
|
+
Like ``borrowed`` it is never applied to ``value`` itself.
|
|
123
144
|
"""
|
|
124
145
|
if stop_after is not None and stop_after < 0:
|
|
125
146
|
raise ValueError("stop_after must be non-negative or None")
|
|
@@ -146,12 +167,16 @@ def retained_size_bytes(
|
|
|
146
167
|
)
|
|
147
168
|
)
|
|
148
169
|
|
|
149
|
-
def walk(obj) -> bool:
|
|
170
|
+
def walk(obj, root: bool = False) -> bool:
|
|
150
171
|
object_id = _object_id(obj)
|
|
151
172
|
if object_id in seen:
|
|
152
173
|
return False
|
|
174
|
+
if not root and borrowed is not None and borrowed(obj):
|
|
175
|
+
return False
|
|
153
176
|
if add(obj, object_id):
|
|
154
177
|
return True
|
|
178
|
+
if not root and opaque is not None and opaque(obj):
|
|
179
|
+
return False
|
|
155
180
|
if isinstance(obj, _ATOMIC):
|
|
156
181
|
return False
|
|
157
182
|
if isinstance(obj, (types.ModuleType, type)) or callable(obj):
|
|
@@ -183,5 +208,5 @@ def retained_size_bytes(
|
|
|
183
208
|
return True
|
|
184
209
|
return False
|
|
185
210
|
|
|
186
|
-
exceeded = walk(value)
|
|
211
|
+
exceeded = walk(value, root=True)
|
|
187
212
|
return sentinel if exceeded and sentinel is not None else total
|
|
@@ -52,9 +52,12 @@ the raw `st_size`, and elision requires all three to be equal.
|
|
|
52
52
|
This module performs no I/O beyond the sidecar itself and imports nothing from
|
|
53
53
|
`_cctally_journal`, so it is unit-testable without a journal on disk — the same
|
|
54
54
|
rule `bin/_lib_journal_router.py` and `bin/_lib_cache_coverage.py` follow. The
|
|
55
|
-
|
|
56
|
-
themselves leaf modules;
|
|
57
|
-
`summary_version` is derived from,
|
|
55
|
+
three modules it does import — `_lib_journal_router`, `_cctally_core` and
|
|
56
|
+
`_lib_ingest_frontier` — are themselves leaf modules; the first two supply the
|
|
57
|
+
two constants `summary_version` is derived from, and the third supplies
|
|
58
|
+
`source_identity_replaced`, the repository's single point of truth for "does a
|
|
59
|
+
different file now occupy this pathname". The prefix proof the identity check
|
|
60
|
+
may require is supplied BY THE CALLER as a callable, so the no-I/O rule holds.
|
|
58
61
|
"""
|
|
59
62
|
from __future__ import annotations
|
|
60
63
|
|
|
@@ -66,6 +69,7 @@ import tempfile
|
|
|
66
69
|
from dataclasses import dataclass, field, replace
|
|
67
70
|
|
|
68
71
|
import _cctally_core
|
|
72
|
+
import _lib_ingest_frontier
|
|
69
73
|
import _lib_journal_router
|
|
70
74
|
|
|
71
75
|
|
|
@@ -80,7 +84,7 @@ SIDECAR_NAME = ".segment-summaries"
|
|
|
80
84
|
#: Bumped by hand when a summary's FIELD SET or the meaning of one of its
|
|
81
85
|
#: numbers changes. It is only one of the three inputs to `summary_version`; the
|
|
82
86
|
#: other two are derived, for the reason that function's docstring gives.
|
|
83
|
-
SUMMARY_SHAPE_VERSION =
|
|
87
|
+
SUMMARY_SHAPE_VERSION = 3
|
|
84
88
|
|
|
85
89
|
#: The refusal reasons `summary_is_elidable` returns. They are stable, because
|
|
86
90
|
#: the rebuild record reports them (spec section 6.3, "recorded, not silent").
|
|
@@ -121,6 +125,14 @@ class SegmentSummary:
|
|
|
121
125
|
#: retained records plus placeholders, and never a malformed line. `None`
|
|
122
126
|
#: means the summary predates the field and cannot be elided against.
|
|
123
127
|
decoded_entry_count: "int | None" = 0
|
|
128
|
+
#: Hex `sha256` of the segment's bytes in `[0, complete_line_covered_offset)`
|
|
129
|
+
#: — the COMPLETE prefix this summary describes. #834 S2 (#827): it exists
|
|
130
|
+
#: so a device-only identity change can be settled by content rather than
|
|
131
|
+
#: refused. Inode numbers are unique only within a device, and no other
|
|
132
|
+
#: field separates two files sharing device-independent identity, size and
|
|
133
|
+
#: pinned extent. `None` means the summary predates the field, and no proof
|
|
134
|
+
#: is possible against it, so such a summary refuses a device-only change.
|
|
135
|
+
complete_prefix_sha256: "str | None" = None
|
|
124
136
|
#: The segment's partial `LastSeenAccumulator` state.
|
|
125
137
|
last_seen_stamped: dict = field(default_factory=dict)
|
|
126
138
|
last_seen_legacy_claude_at: "str | None" = None
|
|
@@ -129,7 +141,7 @@ class SegmentSummary:
|
|
|
129
141
|
|
|
130
142
|
def summary_is_elidable(
|
|
131
143
|
summary, *, pinned_raw_extent, is_last, certificate_covers,
|
|
132
|
-
resolution_seen, stat_identity=None,
|
|
144
|
+
resolution_seen, stat_identity=None, prefix_proof=None,
|
|
133
145
|
) -> "tuple[bool, str]":
|
|
134
146
|
"""``(verdict, reason)`` for one segment against spec section 5.5.
|
|
135
147
|
|
|
@@ -138,6 +150,13 @@ def summary_is_elidable(
|
|
|
138
150
|
and a unit test asserting one of the other conditions does not; when it is
|
|
139
151
|
absent the identity condition is the caller's to have checked.
|
|
140
152
|
|
|
153
|
+
``prefix_proof`` is a callable returning the hex ``sha256`` of the segment's
|
|
154
|
+
CURRENT bytes in ``[0, complete_line_covered_offset)``. It is evaluated ONLY
|
|
155
|
+
on a device-only identity change, because evaluating it on the common path
|
|
156
|
+
would read every segment elision exists to skip. ``None`` means the caller
|
|
157
|
+
can supply no proof, which refuses that change rather than reusing
|
|
158
|
+
optimistically.
|
|
159
|
+
|
|
141
160
|
The order is deliberate, and two parts of it change behaviour rather than
|
|
142
161
|
only wording. `resolution_seen` comes first because it is a property of the
|
|
143
162
|
PASS rather than of the segment, so reporting a segment-shaped reason for it
|
|
@@ -149,15 +168,47 @@ def summary_is_elidable(
|
|
|
149
168
|
segment the summary's own extent already disqualifies, and a test asserting
|
|
150
169
|
the refusal REASON is what distinguishes the two mechanisms. Reordering
|
|
151
170
|
these two is therefore a behaviour change, not a cleanup.
|
|
171
|
+
|
|
172
|
+
#834 S2 (#827): THE INODE DECIDES; THE DEVICE CORROBORATES. This used to
|
|
173
|
+
compare ``(st_dev, st_ino)`` as a pair, so an ordinary remount of a journal
|
|
174
|
+
on an external or network volume refused every summary for files that had
|
|
175
|
+
not changed at all — `st_dev` is assigned when a volume is mounted rather
|
|
176
|
+
than when a file is created. The verdict now goes through
|
|
177
|
+
`_lib_ingest_frontier.source_identity_replaced`, which both Codex walks and
|
|
178
|
+
`classify_recent_active_path` already use, because three copies of one
|
|
179
|
+
comparison is the shape in which one of them silently stops matching the
|
|
180
|
+
others.
|
|
181
|
+
|
|
182
|
+
A device-only change additionally requires the byte-identity proof, because
|
|
183
|
+
inode numbers are unique only WITHIN a device and no other field separates
|
|
184
|
+
two files sharing device-independent identity, size and pinned extent. The
|
|
185
|
+
collision is narrow — size and pinned extent must also match — but the
|
|
186
|
+
failure it produces is a silent omission of retained data rather than a
|
|
187
|
+
recomputation, so it is refused rather than argued about. A summary written
|
|
188
|
+
before the digest field carries ``None`` and refuses the same way.
|
|
152
189
|
"""
|
|
153
190
|
if resolution_seen:
|
|
154
191
|
return False, REASON_RESOLUTION
|
|
155
192
|
if is_last:
|
|
156
193
|
return False, REASON_LAST
|
|
157
194
|
if stat_identity is not None:
|
|
158
|
-
|
|
159
|
-
|
|
195
|
+
observed_dev, observed_ino = int(stat_identity[0]), int(stat_identity[1])
|
|
196
|
+
if _lib_ingest_frontier.source_identity_replaced(
|
|
197
|
+
int(summary.st_dev), int(summary.st_ino),
|
|
198
|
+
observed_dev, observed_ino):
|
|
160
199
|
return False, REASON_IDENTITY
|
|
200
|
+
if int(summary.st_dev) != observed_dev:
|
|
201
|
+
digest = summary.complete_prefix_sha256
|
|
202
|
+
if not digest or prefix_proof is None:
|
|
203
|
+
return False, REASON_IDENTITY
|
|
204
|
+
try:
|
|
205
|
+
observed_digest = prefix_proof()
|
|
206
|
+
except OSError:
|
|
207
|
+
# An unreadable prefix is not a proof. Refusing costs a re-read
|
|
208
|
+
# of one segment; accepting would skip retained data.
|
|
209
|
+
return False, REASON_IDENTITY
|
|
210
|
+
if observed_digest != digest:
|
|
211
|
+
return False, REASON_IDENTITY
|
|
161
212
|
# ONE equality over three operands, not two comparisons: see the module
|
|
162
213
|
# docstring for why the raw size and the newline boundary are independent.
|
|
163
214
|
if not (int(summary.complete_line_covered_offset)
|
|
@@ -176,7 +227,8 @@ def summary_is_elidable(
|
|
|
176
227
|
_FIELDS = (
|
|
177
228
|
"segment_name", "st_dev", "st_ino", "summarized_size",
|
|
178
229
|
"complete_line_covered_offset", "lines", "bytes", "decodes", "malformed",
|
|
179
|
-
"quota_only", "decoded_entry_count", "
|
|
230
|
+
"quota_only", "decoded_entry_count", "complete_prefix_sha256",
|
|
231
|
+
"last_seen_stamped",
|
|
180
232
|
"last_seen_legacy_claude_at", "last_seen_legacy_codex_at",
|
|
181
233
|
)
|
|
182
234
|
|
|
@@ -236,6 +288,9 @@ def to_wire(summary) -> dict:
|
|
|
236
288
|
"decoded_entry_count": (
|
|
237
289
|
None if summary.decoded_entry_count is None
|
|
238
290
|
else int(summary.decoded_entry_count)),
|
|
291
|
+
"complete_prefix_sha256": (
|
|
292
|
+
None if summary.complete_prefix_sha256 is None
|
|
293
|
+
else str(summary.complete_prefix_sha256)),
|
|
239
294
|
"last_seen_stamped": {
|
|
240
295
|
str(key): str(value)
|
|
241
296
|
for key, value in dict(summary.last_seen_stamped).items()
|
|
@@ -272,6 +327,9 @@ def from_wire(item) -> SegmentSummary:
|
|
|
272
327
|
malformed=int(item["malformed"]),
|
|
273
328
|
quota_only=bool(item["quota_only"]),
|
|
274
329
|
decoded_entry_count=None if count is None else int(count),
|
|
330
|
+
complete_prefix_sha256=(
|
|
331
|
+
None if item["complete_prefix_sha256"] is None
|
|
332
|
+
else str(item["complete_prefix_sha256"])),
|
|
275
333
|
last_seen_stamped={
|
|
276
334
|
str(key): str(value) for key, value in stamped.items()},
|
|
277
335
|
last_seen_legacy_claude_at=(
|
|
@@ -750,12 +750,16 @@ def _build_trend_recap(*, panel_data, options):
|
|
|
750
750
|
delta = panel_data.get("delta_3_weeks") or {}
|
|
751
751
|
return _LS.ShareSnapshot(
|
|
752
752
|
cmd="report",
|
|
753
|
-
title="$/% trend — last 8
|
|
753
|
+
title="$/% trend — last 8 cycles",
|
|
754
754
|
subtitle=None,
|
|
755
|
-
period=_civil_period(start, end, label="Last 8
|
|
755
|
+
period=_civil_period(start, end, label="Last 8 cycles",
|
|
756
756
|
display_tz=_display_tz(options)),
|
|
757
757
|
columns=(
|
|
758
|
-
|
|
758
|
+
# The KEY stays `week` — it is a frozen wire name. Only the
|
|
759
|
+
# human label changes: a Claude trend row is a billing CYCLE,
|
|
760
|
+
# and `report` split a credited week into two long before this
|
|
761
|
+
# epic (#750 S4 D2 / §4.5).
|
|
762
|
+
_LS.ColumnSpec(key="week", label="Cycle", align="left"),
|
|
759
763
|
_LS.ColumnSpec(key="cost", label="$", align="right", emphasis=True),
|
|
760
764
|
_LS.ColumnSpec(key="pct", label="% used", align="right"),
|
|
761
765
|
_LS.ColumnSpec(key="dpp", label="$/%", align="right"),
|
|
@@ -776,8 +780,8 @@ def _build_trend_recap(*, panel_data, options):
|
|
|
776
780
|
reference_lines=(),
|
|
777
781
|
) if weeks else None,
|
|
778
782
|
totals=_kpi_strip(
|
|
779
|
-
("Δ $/% (
|
|
780
|
-
("Δ $ (
|
|
783
|
+
("Δ $/% (3cy)", _optional_signed_pct(delta.get('dpp_change_pct'))),
|
|
784
|
+
("Δ $ (3cy)", _optional_signed_money(delta.get('cost_change_usd'))),
|
|
781
785
|
),
|
|
782
786
|
notes=(),
|
|
783
787
|
generated_at=_utc_now(),
|
|
@@ -1261,9 +1265,9 @@ def _build_trend_visual(*, panel_data, options):
|
|
|
1261
1265
|
delta = panel_data.get("delta_3_weeks") or {}
|
|
1262
1266
|
return _LS.ShareSnapshot(
|
|
1263
1267
|
cmd="report",
|
|
1264
|
-
title="$/% trend visual — last 8
|
|
1268
|
+
title="$/% trend visual — last 8 cycles",
|
|
1265
1269
|
subtitle=None,
|
|
1266
|
-
period=_civil_period(start, end, label="Last 8
|
|
1270
|
+
period=_civil_period(start, end, label="Last 8 cycles",
|
|
1267
1271
|
display_tz=_display_tz(options)),
|
|
1268
1272
|
columns=(),
|
|
1269
1273
|
rows=(),
|
|
@@ -1274,8 +1278,8 @@ def _build_trend_visual(*, panel_data, options):
|
|
|
1274
1278
|
reference_lines=(),
|
|
1275
1279
|
) if weeks else None,
|
|
1276
1280
|
totals=_kpi_strip(
|
|
1277
|
-
("Δ $/% (
|
|
1278
|
-
("Δ $ (
|
|
1281
|
+
("Δ $/% (3cy)", _optional_signed_pct(delta.get('dpp_change_pct'))),
|
|
1282
|
+
("Δ $ (3cy)", _optional_signed_money(delta.get('cost_change_usd'))),
|
|
1279
1283
|
),
|
|
1280
1284
|
notes=(),
|
|
1281
1285
|
generated_at=_utc_now(),
|
|
@@ -1292,12 +1296,16 @@ def _build_trend_detail(*, panel_data, options):
|
|
|
1292
1296
|
delta = panel_data.get("delta_3_weeks") or {}
|
|
1293
1297
|
return _LS.ShareSnapshot(
|
|
1294
1298
|
cmd="report",
|
|
1295
|
-
title="$/% trend detail — last 8
|
|
1299
|
+
title="$/% trend detail — last 8 cycles",
|
|
1296
1300
|
subtitle=None,
|
|
1297
|
-
period=_civil_period(start, end, label="Last 8
|
|
1301
|
+
period=_civil_period(start, end, label="Last 8 cycles",
|
|
1298
1302
|
display_tz=_display_tz(options)),
|
|
1299
1303
|
columns=(
|
|
1300
|
-
|
|
1304
|
+
# The KEY stays `week` — it is a frozen wire name. Only the
|
|
1305
|
+
# human label changes: a Claude trend row is a billing CYCLE,
|
|
1306
|
+
# and `report` split a credited week into two long before this
|
|
1307
|
+
# epic (#750 S4 D2 / §4.5).
|
|
1308
|
+
_LS.ColumnSpec(key="week", label="Cycle", align="left"),
|
|
1301
1309
|
_LS.ColumnSpec(key="cost", label="$", align="right", emphasis=True),
|
|
1302
1310
|
_LS.ColumnSpec(key="pct", label="% used", align="right"),
|
|
1303
1311
|
_LS.ColumnSpec(key="dpp", label="$/%", align="right"),
|
|
@@ -1318,8 +1326,8 @@ def _build_trend_detail(*, panel_data, options):
|
|
|
1318
1326
|
reference_lines=(),
|
|
1319
1327
|
) if weeks else None,
|
|
1320
1328
|
totals=_kpi_strip(
|
|
1321
|
-
("Δ $/% (
|
|
1322
|
-
("Δ $ (
|
|
1329
|
+
("Δ $/% (3cy)", _optional_signed_pct(delta.get('dpp_change_pct'))),
|
|
1330
|
+
("Δ $ (3cy)", _optional_signed_money(delta.get('cost_change_usd'))),
|
|
1323
1331
|
),
|
|
1324
1332
|
notes=(),
|
|
1325
1333
|
generated_at=_utc_now(),
|
|
@@ -1929,8 +1937,10 @@ def _projects_chart_for_template(rows: list[dict], cap: int):
|
|
|
1929
1937
|
def _projects_period(panel_data: dict, options: dict):
|
|
1930
1938
|
start = panel_data["period_start"]
|
|
1931
1939
|
end = panel_data["period_end"]
|
|
1940
|
+
# `window_weeks` stays the frozen wire name; the rendered noun does not.
|
|
1941
|
+
# The Projects window is a set of billing-cycle buckets (#750 S4 D2/D6).
|
|
1932
1942
|
weeks = int(panel_data.get("window_weeks", 1) or 1)
|
|
1933
|
-
label = "This
|
|
1943
|
+
label = "This cycle" if weeks == 1 else f"Last {weeks} cycles"
|
|
1934
1944
|
return _period(start, end, label=label,
|
|
1935
1945
|
display_tz=_display_tz(options))
|
|
1936
1946
|
|
|
@@ -2048,11 +2058,11 @@ _RECAP = (
|
|
|
2048
2058
|
default_options={"top_n": 5, "show_chart": True, "show_table": True},
|
|
2049
2059
|
builder=_build_weekly_recap),
|
|
2050
2060
|
ShareTemplate(id="current-week-recap", panel="current-week", label="Recap",
|
|
2051
|
-
description="
|
|
2061
|
+
description="Cycle-to-date KPIs + line + top-3 projects",
|
|
2052
2062
|
default_options={"top_n": 3, "show_chart": True, "show_table": True},
|
|
2053
2063
|
builder=_build_current_week_recap),
|
|
2054
2064
|
ShareTemplate(id="trend-recap", panel="trend", label="Recap",
|
|
2055
|
-
description="$/% trend over 8
|
|
2065
|
+
description="$/% trend over 8 cycles + 3-cycle delta",
|
|
2056
2066
|
default_options={"top_n": 3, "show_chart": True, "show_table": True},
|
|
2057
2067
|
builder=_build_trend_recap),
|
|
2058
2068
|
ShareTemplate(id="daily-recap", panel="daily", label="Recap",
|
|
@@ -2093,7 +2103,7 @@ _VISUAL = (
|
|
|
2093
2103
|
default_options={"top_n": 8, "show_chart": True, "show_table": False},
|
|
2094
2104
|
builder=_build_weekly_visual),
|
|
2095
2105
|
ShareTemplate(id="current-week-visual", panel="current-week", label="Visual",
|
|
2096
|
-
description="
|
|
2106
|
+
description="Cycle-to-date line with KPI overlay",
|
|
2097
2107
|
default_options={"top_n": 8, "show_chart": True, "show_table": False},
|
|
2098
2108
|
builder=_build_current_week_visual),
|
|
2099
2109
|
ShareTemplate(id="trend-visual", panel="trend", label="Visual",
|
|
@@ -1079,6 +1079,14 @@ def reset_session_cache_state() -> None:
|
|
|
1079
1079
|
# === #582 — persistent Codex accounting rows by dirty physical path =========
|
|
1080
1080
|
|
|
1081
1081
|
|
|
1082
|
+
#: Bumped when anything about how this population or the labels derived from
|
|
1083
|
+
#: it is COMPUTED changes, rather than when the data changes. A consumer
|
|
1084
|
+
#: comparing the signature is therefore invalidated by an algorithm change as
|
|
1085
|
+
#: well as by a mutation, which is what lets the project label cache retain
|
|
1086
|
+
#: derived labels across ticks at all.
|
|
1087
|
+
CODEX_POPULATION_SIGNATURE_VERSION = "codex-accounting-population-v1"
|
|
1088
|
+
|
|
1089
|
+
|
|
1082
1090
|
@dataclass(frozen=True)
|
|
1083
1091
|
class CodexAccountingCacheResult:
|
|
1084
1092
|
"""One cold or incrementally refreshed accounting population."""
|
|
@@ -1089,6 +1097,12 @@ class CodexAccountingCacheResult:
|
|
|
1089
1097
|
cold: bool
|
|
1090
1098
|
changed_old: tuple[object, ...] = ()
|
|
1091
1099
|
changed_new: tuple[object, ...] = ()
|
|
1100
|
+
#: The exact identity of the population in `entries` (#769 S6 T2a, #716
|
|
1101
|
+
#: Task B). A consumer that compares this instead of scanning the rows
|
|
1102
|
+
#: cannot serve a stale population, and a clean comparison visits no row at
|
|
1103
|
+
#: all. `None` means "cannot establish an identity", which every consumer
|
|
1104
|
+
#: must treat as a cold read rather than as a hit.
|
|
1105
|
+
population_signature: tuple | None = None
|
|
1092
1106
|
|
|
1093
1107
|
|
|
1094
1108
|
_CODEX_ACCOUNTING_CACHE_STATE: dict[str, object] = _ObservedSnapshotDict()
|
|
@@ -1135,6 +1149,64 @@ def _codex_accounting_mutation_seq(conn: sqlite3.Connection) -> int | None:
|
|
|
1135
1149
|
return None
|
|
1136
1150
|
|
|
1137
1151
|
|
|
1152
|
+
def _codex_window_attribution_revision(conn: sqlite3.Connection) -> str | None:
|
|
1153
|
+
"""`codex_window_attributions`' revision — a LEADING attribution signal.
|
|
1154
|
+
|
|
1155
|
+
An earlier version of this docstring said that window-scoped spend adoption
|
|
1156
|
+
rewrites `account_key` on existing rows and bumps this revision in the same
|
|
1157
|
+
transaction. Both halves were wrong, and the leg is worth keeping for a
|
|
1158
|
+
different reason.
|
|
1159
|
+
|
|
1160
|
+
The restamp does not need this leg. `account_key` is a member of
|
|
1161
|
+
`_CODEX_ACCOUNTING_SEMANTIC_COLUMNS` (`bin/_cctally_db.py`), so an UPDATE to
|
|
1162
|
+
it fires `trg_codex_accounting_upd`, advances `codex_accounting_mutation_seq`
|
|
1163
|
+
and marks the path dirty — the ledger DDL's own docstring calls this "the
|
|
1164
|
+
ordinary account-adoption case", and the sequence leg above already carries
|
|
1165
|
+
it. And `apply_codex_window_spend_adoption` (`bin/_cctally_cache.py`) does
|
|
1166
|
+
not call `bump_codex_window_attribution_revision` at all; the three callers
|
|
1167
|
+
are in `bin/_cctally_journal.py` and belong to the `codex_window_attributions`
|
|
1168
|
+
overlay, a different table outside the accounting population.
|
|
1169
|
+
|
|
1170
|
+
What this leg actually covers is the GAP BETWEEN the two. The revision
|
|
1171
|
+
advances in the same transaction as the overlay's own rows, during journal
|
|
1172
|
+
replay. The restamp those rows imply is performed later, by
|
|
1173
|
+
`reconcile_codex_window_attribution_spend` at the end of the next Codex
|
|
1174
|
+
sync. Between the two the accounting ledger head has not moved, so a memo
|
|
1175
|
+
keyed on the ledger alone would keep serving a population built before the
|
|
1176
|
+
operator's attribution landed. Carrying the revision turns that window into
|
|
1177
|
+
an unnecessary miss, which is the direction this signature is written in.
|
|
1178
|
+
"""
|
|
1179
|
+
try:
|
|
1180
|
+
row = conn.execute(
|
|
1181
|
+
"SELECT value FROM cache_meta "
|
|
1182
|
+
"WHERE key='codex_window_attribution_revision'"
|
|
1183
|
+
).fetchone()
|
|
1184
|
+
except sqlite3.Error:
|
|
1185
|
+
return None
|
|
1186
|
+
return None if row is None or row[0] is None else str(row[0])
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _codex_main_database_path(conn: sqlite3.Connection) -> str | None:
|
|
1190
|
+
try:
|
|
1191
|
+
return next(
|
|
1192
|
+
str(row[2]) for row in conn.execute("PRAGMA database_list")
|
|
1193
|
+
if str(row[1]) == "main"
|
|
1194
|
+
)
|
|
1195
|
+
except (sqlite3.Error, StopIteration):
|
|
1196
|
+
return None
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def current_codex_population_signature() -> tuple | None:
|
|
1200
|
+
"""The signature of the population the process cache currently holds.
|
|
1201
|
+
|
|
1202
|
+
Read by the project and label caches, which are built from that population
|
|
1203
|
+
but are not handed the `CodexAccountingCacheResult` that produced it.
|
|
1204
|
+
"""
|
|
1205
|
+
_assert_owner()
|
|
1206
|
+
signature = _CODEX_ACCOUNTING_CACHE_STATE.get("population_signature")
|
|
1207
|
+
return signature if isinstance(signature, tuple) else None
|
|
1208
|
+
|
|
1209
|
+
|
|
1138
1210
|
def codex_accounting_cache_pending(conn: sqlite3.Connection) -> bool:
|
|
1139
1211
|
"""Whether the process cache has not consumed the durable ledger head."""
|
|
1140
1212
|
_assert_owner()
|
|
@@ -1174,8 +1246,35 @@ def build_cached_codex_accounting(
|
|
|
1174
1246
|
):
|
|
1175
1247
|
raise ValueError("Codex accounting cache range must be aware and ordered")
|
|
1176
1248
|
current_seq = _codex_accounting_mutation_seq(cache_conn)
|
|
1249
|
+
database_path = _codex_main_database_path(cache_conn)
|
|
1250
|
+
attribution_revision = _codex_window_attribution_revision(cache_conn)
|
|
1177
1251
|
state = _CODEX_ACCOUNTING_CACHE_STATE
|
|
1178
1252
|
prior_cached_entries = tuple(state.get("entries", ())) if state else ()
|
|
1253
|
+
|
|
1254
|
+
def signature(generation: int) -> tuple | None:
|
|
1255
|
+
"""The exact identity of the population this call is returning.
|
|
1256
|
+
|
|
1257
|
+
`range_end` is deliberately NOT a leg. It advances on every tick, so a
|
|
1258
|
+
signature carrying it would move every tick and no consumer could ever
|
|
1259
|
+
reuse anything — which is the same trap `_cached_projects_wire` already
|
|
1260
|
+
records for its own key. An upper bound that actually reveals a row
|
|
1261
|
+
dirties that row's path, and the generation below moves with it; an
|
|
1262
|
+
upper bound that reveals nothing leaves the population identical, and
|
|
1263
|
+
saying so is correct rather than optimistic.
|
|
1264
|
+
"""
|
|
1265
|
+
if current_seq is None or database_path is None:
|
|
1266
|
+
# No ledger is not an idle ledger. Refusing to name an identity is
|
|
1267
|
+
# what makes every consumer take a cold read.
|
|
1268
|
+
return None
|
|
1269
|
+
return (
|
|
1270
|
+
CODEX_POPULATION_SIGNATURE_VERSION,
|
|
1271
|
+
database_path,
|
|
1272
|
+
int(current_seq),
|
|
1273
|
+
int(generation),
|
|
1274
|
+
range_start,
|
|
1275
|
+
extra_signature,
|
|
1276
|
+
attribution_revision,
|
|
1277
|
+
)
|
|
1179
1278
|
cold = (
|
|
1180
1279
|
current_seq is None
|
|
1181
1280
|
or not state
|
|
@@ -1240,6 +1339,8 @@ def build_cached_codex_accounting(
|
|
|
1240
1339
|
str(account_of(entry))
|
|
1241
1340
|
for entry in (*prior_cached_entries, *entries)
|
|
1242
1341
|
}))
|
|
1342
|
+
generation = int(state.get("generation", 0)) + 1
|
|
1343
|
+
population_signature = signature(generation)
|
|
1243
1344
|
_CODEX_ACCOUNTING_CACHE_STATE.clear()
|
|
1244
1345
|
_CODEX_ACCOUNTING_CACHE_STATE.update({
|
|
1245
1346
|
"entries": entries,
|
|
@@ -1247,16 +1348,24 @@ def build_cached_codex_accounting(
|
|
|
1247
1348
|
"start": range_start,
|
|
1248
1349
|
"end": range_end,
|
|
1249
1350
|
"extra": extra_signature,
|
|
1351
|
+
"generation": generation,
|
|
1352
|
+
"population_signature": population_signature,
|
|
1250
1353
|
})
|
|
1251
1354
|
return CodexAccountingCacheResult(
|
|
1252
1355
|
entries, (), accounts, True, prior_cached_entries, entries,
|
|
1356
|
+
population_signature=population_signature,
|
|
1253
1357
|
)
|
|
1254
1358
|
|
|
1255
1359
|
prior_entries = tuple(state["entries"])
|
|
1256
1360
|
if not dirty:
|
|
1361
|
+
population_signature = signature(int(state.get("generation", 0)))
|
|
1257
1362
|
state["seq"] = current_seq
|
|
1258
1363
|
state["end"] = range_end
|
|
1259
|
-
|
|
1364
|
+
state["population_signature"] = population_signature
|
|
1365
|
+
return CodexAccountingCacheResult(
|
|
1366
|
+
prior_entries, (), (), False,
|
|
1367
|
+
population_signature=population_signature,
|
|
1368
|
+
)
|
|
1260
1369
|
|
|
1261
1370
|
dirty_paths = tuple(sorted(dirty))
|
|
1262
1371
|
prior_dirty = tuple(
|
|
@@ -1294,11 +1403,16 @@ def build_cached_codex_accounting(
|
|
|
1294
1403
|
# while keeping the tuple's shallow shape. Mark this semantic replacement
|
|
1295
1404
|
# explicitly; the routine ``seq``/``end`` watermark updates below do not.
|
|
1296
1405
|
_mark_snapshot_accelerators_dirty()
|
|
1406
|
+
generation = int(state.get("generation", 0)) + 1
|
|
1407
|
+
population_signature = signature(generation)
|
|
1297
1408
|
state["entries"] = entries
|
|
1298
1409
|
state["seq"] = current_seq
|
|
1299
1410
|
state["end"] = range_end
|
|
1411
|
+
state["generation"] = generation
|
|
1412
|
+
state["population_signature"] = population_signature
|
|
1300
1413
|
return CodexAccountingCacheResult(
|
|
1301
1414
|
entries, dirty_paths, dirty_accounts, False, changed_old, changed_new,
|
|
1415
|
+
population_signature=population_signature,
|
|
1302
1416
|
)
|
|
1303
1417
|
|
|
1304
1418
|
|