cctally 1.92.2 → 1.93.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/_cctally_cache.py +354 -0
  3. package/bin/_cctally_core.py +180 -3
  4. package/bin/_cctally_dashboard.py +71 -1
  5. package/bin/_cctally_dashboard_envelope.py +28 -2
  6. package/bin/_cctally_dashboard_share.py +75 -19
  7. package/bin/_cctally_dashboard_sources.py +12 -0
  8. package/bin/_cctally_db.py +89 -1
  9. package/bin/_cctally_doctor.py +31 -0
  10. package/bin/_cctally_forecast.py +4 -2
  11. package/bin/_cctally_journal.py +3482 -258
  12. package/bin/_cctally_journal_repair.py +123 -32
  13. package/bin/_cctally_milestone_history.py +4 -1
  14. package/bin/_cctally_project.py +8 -6
  15. package/bin/_cctally_quota.py +420 -20
  16. package/bin/_cctally_rederive.py +57 -23
  17. package/bin/_cctally_reporting.py +8 -6
  18. package/bin/_cctally_share.py +74 -37
  19. package/bin/_cctally_source_analytics.py +6 -8
  20. package/bin/_cctally_store.py +13 -2
  21. package/bin/_cctally_tui.py +53 -0
  22. package/bin/_lib_cache_coverage.py +547 -0
  23. package/bin/_lib_doctor.py +54 -2
  24. package/bin/_lib_journal.py +235 -95
  25. package/bin/_lib_journal_router.py +21 -0
  26. package/bin/_lib_segment_summary.py +374 -0
  27. package/bin/_lib_selector_state.py +959 -0
  28. package/bin/_lib_share.py +1073 -165
  29. package/bin/_lib_share_templates.py +35 -11
  30. package/bin/_lib_stats_wal.py +327 -0
  31. package/bin/_lib_view_models.py +2 -1
  32. package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
  33. package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +5 -1
  36. package/dashboard/static/assets/index-DnWdv8um.css +0 -1
@@ -36,7 +36,7 @@ from typing import Any
36
36
 
37
37
  # --- Panel set ---
38
38
  #
39
- # Share-capable panels are the 8 data-view panels in the dashboard.
39
+ # Share-capable panels are the 9 data-view panels in the dashboard.
40
40
  # RecentAlertsPanel ('alerts') is intentionally excluded: it's a
41
41
  # notification stream, not a data view — shipping share templates over
42
42
  # alerts would conflate the two concepts (spec §6.1, §9.5).
@@ -65,8 +65,8 @@ class ShareTemplate:
65
65
  builder: Callable[..., Any] # (panel_data, share_options) -> ShareSnapshot
66
66
 
67
67
 
68
- # Filled in subsequent tasks (M1.4 adds the 8 Recap templates;
69
- # M2.1 adds the 16 Visual + Detail templates).
68
+ # Filled in below: 9 Recap templates, then 9 Visual + 9 Detail, for 27
69
+ # templates across the 9 share-capable panels.
70
70
  SHARE_TEMPLATES: tuple[ShareTemplate, ...] = ()
71
71
 
72
72
 
@@ -198,8 +198,11 @@ def _top_projects_rows(top_projects, cap: int) -> tuple:
198
198
  """Build `Row` tuple with ProjectCell + MoneyCell from a list of
199
199
  `(project_path, cost_usd)` pairs.
200
200
 
201
- Anonymization happens later in `_scrub()` — builders always emit real
202
- names. Accepts both 2-tuples and `(path, cost, ...)` longer tuples;
201
+ Anonymization happens later, in the preparation pass `render()` and
202
+ `compose()` run over the raw snapshot builders always emit real names.
203
+ (It is NOT `_scrub()`: that function is retained for backward
204
+ compatibility and no production path calls it.) Accepts both 2-tuples
205
+ and `(path, cost, ...)` longer tuples;
203
206
  only the first two positional elements are used so callers can pass
204
207
  enriched rows without copy-coercion.
205
208
  """
@@ -387,7 +390,7 @@ def _display_tz(options) -> str:
387
390
  return options.get("display_tz", "Etc/UTC")
388
391
 
389
392
 
390
- # --- 8 Recap builders ---
393
+ # --- 9 Recap builders ---
391
394
 
392
395
 
393
396
  def _build_weekly_recap(*, panel_data, options):
@@ -834,7 +837,7 @@ def _build_sessions_recap(*, panel_data, options):
834
837
  )
835
838
 
836
839
 
837
- # --- 16 Visual + Detail builders ---
840
+ # --- 18 Visual + Detail builders ---
838
841
  #
839
842
  # Archetype contract (spec §9.4):
840
843
  # - Visual: chart populated (same density as Recap), `rows=()`, `columns=()`,
@@ -1525,10 +1528,19 @@ def _build_sessions_visual(*, panel_data, options):
1525
1528
  chart=_LS.HorizontalBarChart(
1526
1529
  points=tuple(
1527
1530
  _LS.ChartPoint(
1528
- x_label=str(s.get("session_id") or ""),
1531
+ # #503 S1 P7: the gutter label is RANK + PROJECT. It used
1532
+ # to be the raw session id, which is a canonical UUID in
1533
+ # production and was disclosed verbatim next to
1534
+ # `project-1` in the same anonymized artifact (F1). The
1535
+ # rank is what correlates a bar with its table row, and it
1536
+ # carries no identifier. Preparation composes the final
1537
+ # `x_label` from this prefix and the resolved project.
1538
+ x_label=str(i + 1),
1529
1539
  x_value=float(i),
1530
1540
  y_value=float(s.get("cost_usd") or 0.0),
1531
1541
  project_label=str(s.get("project_path") or "") or None,
1542
+ x_label_kind="project",
1543
+ x_label_prefix=str(i + 1),
1532
1544
  )
1533
1545
  for i, s in enumerate(rows_iter)
1534
1546
  ),
@@ -1551,7 +1563,7 @@ def _build_sessions_detail(*, panel_data, options):
1551
1563
  Default `top_n` is 50 (Recap's is 15). Sessions Recap explicitly omits the
1552
1564
  chart (table-first panel); Detail re-introduces a compact horizontal bar
1553
1565
  of the same top-N so the archetype contract (chart populated + rows
1554
- populated) holds uniformly across all 8 panels' Detail siblings.
1566
+ populated) holds uniformly across all 9 panels' Detail siblings.
1555
1567
  """
1556
1568
  sessions = panel_data.get("sessions") or []
1557
1569
  cap = options.get("top_n", 50)
@@ -1587,10 +1599,19 @@ def _build_sessions_detail(*, panel_data, options):
1587
1599
  chart=_LS.HorizontalBarChart(
1588
1600
  points=tuple(
1589
1601
  _LS.ChartPoint(
1590
- x_label=str(s.get("session_id") or ""),
1602
+ # #503 S1 P7: the gutter label is RANK + PROJECT. It used
1603
+ # to be the raw session id, which is a canonical UUID in
1604
+ # production and was disclosed verbatim next to
1605
+ # `project-1` in the same anonymized artifact (F1). The
1606
+ # rank is what correlates a bar with its table row, and it
1607
+ # carries no identifier. Preparation composes the final
1608
+ # `x_label` from this prefix and the resolved project.
1609
+ x_label=str(i + 1),
1591
1610
  x_value=float(i),
1592
1611
  y_value=float(s.get("cost_usd") or 0.0),
1593
1612
  project_label=str(s.get("project_path") or "") or None,
1613
+ x_label_kind="project",
1614
+ x_label_prefix=str(i + 1),
1594
1615
  )
1595
1616
  for i, s in enumerate(rows_iter)
1596
1617
  ),
@@ -1667,6 +1688,7 @@ def _projects_chart_for_template(rows: list[dict], cap: int):
1667
1688
  x_value=cost,
1668
1689
  y_value=cost,
1669
1690
  project_label=label,
1691
+ x_label_kind="project",
1670
1692
  ))
1671
1693
  return _LS.HorizontalBarChart(
1672
1694
  points=tuple(points), x_label="$", cap=cap,
@@ -1683,7 +1705,9 @@ def _projects_period(panel_data: dict, options: dict):
1683
1705
 
1684
1706
 
1685
1707
  def _projects_subtitle(options: dict, total_rows: int) -> str:
1686
- reveal = options.get("reveal_projects", True)
1708
+ # FAIL CLOSED (#503 S1 F3): an omitted field made the Projects panel
1709
+ # print the claim "real projects" about its own contents.
1710
+ reveal = options.get("reveal_projects", False)
1687
1711
  return " · ".join([
1688
1712
  f"{total_rows} project{'' if total_rows == 1 else 's'}",
1689
1713
  "real projects" if reveal else "projects anonymized",
@@ -0,0 +1,327 @@
1
+ """Bounded structural inspection of a SQLite WAL and its wal-index.
2
+
3
+ The WAL carries database pages. This module reads only file/header identity,
4
+ WAL frame headers, and wal-index page numbers; it never reads or returns frame
5
+ payloads. Callers decide whether their surrounding locks make the snapshot
6
+ authoritative enough for an operational decision.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import pathlib
13
+ import struct
14
+ import sys
15
+
16
+
17
+ _WAL_MAGICS = {0x377F0682, 0x377F0683}
18
+ _FIRST_PGNO_COUNT = 4062
19
+ _LATER_PGNO_COUNT = 4096
20
+ _SHM_REGION_BYTES = 32768
21
+ _MISMATCH_SAMPLE_MAX = 8
22
+ _FRAME_ANALYSIS_MAX = 16384
23
+
24
+ _INCOHERENT_VERDICTS = {
25
+ "wal_index_generation_mismatch",
26
+ "wal_index_mapping_mismatch",
27
+ }
28
+
29
+
30
+ def _file_record(stat: os.stat_result) -> dict:
31
+ return {
32
+ "inode": int(stat.st_ino),
33
+ "sizeBytes": int(stat.st_size),
34
+ "mtimeNs": int(stat.st_mtime_ns),
35
+ }
36
+
37
+
38
+ def _same_file(left: os.stat_result, right: os.stat_result) -> bool:
39
+ return (
40
+ left.st_dev,
41
+ left.st_ino,
42
+ left.st_size,
43
+ left.st_mtime_ns,
44
+ ) == (
45
+ right.st_dev,
46
+ right.st_ino,
47
+ right.st_size,
48
+ right.st_mtime_ns,
49
+ )
50
+
51
+
52
+ def _read_at(handle, offset: int, size: int) -> bytes:
53
+ """Read one bounded structural range (test seam for byte accounting)."""
54
+ handle.seek(offset)
55
+ return handle.read(size)
56
+
57
+
58
+ def _native_u16(raw: bytes, offset: int) -> int:
59
+ prefix = "<" if sys.byteorder == "little" else ">"
60
+ return int(struct.unpack_from(f"{prefix}H", raw, offset)[0])
61
+
62
+
63
+ def _native_u32(raw: bytes, offset: int) -> int:
64
+ prefix = "<" if sys.byteorder == "little" else ">"
65
+ return int(struct.unpack_from(f"{prefix}I", raw, offset)[0])
66
+
67
+
68
+ def _shm_page_offset(frame_number: int) -> int:
69
+ index = frame_number - 1
70
+ if index < _FIRST_PGNO_COUNT:
71
+ return 136 + index * 4
72
+ index -= _FIRST_PGNO_COUNT
73
+ region = 1 + index // _LATER_PGNO_COUNT
74
+ return region * _SHM_REGION_BYTES + (index % _LATER_PGNO_COUNT) * 4
75
+
76
+
77
+ def _empty_evidence(verdict: str, reason: str | None = None) -> dict:
78
+ return {
79
+ "schemaVersion": 1,
80
+ "verdict": verdict,
81
+ "captureStable": False,
82
+ "reason": reason,
83
+ "wal": None,
84
+ "shm": None,
85
+ "frameMapping": {
86
+ "comparedCount": 0,
87
+ "mismatchCount": 0,
88
+ "mismatchSample": [],
89
+ "truncated": False,
90
+ },
91
+ }
92
+
93
+
94
+ def _path_matches(path: pathlib.Path, opened: os.stat_result) -> bool:
95
+ try:
96
+ return _same_file(path.stat(), opened)
97
+ except OSError:
98
+ return False
99
+
100
+
101
+ def _inspect_zero_wal(wal_path: pathlib.Path, shm_path: pathlib.Path) -> dict:
102
+ result = _empty_evidence("wal_empty")
103
+ try:
104
+ with wal_path.open("rb") as wal_handle:
105
+ wal_before = os.fstat(wal_handle.fileno())
106
+ result["wal"] = _file_record(wal_before)
107
+ if wal_before.st_size != 0:
108
+ result["verdict"] = "capture_raced"
109
+ result["reason"] = "WAL refilled before zero-WAL capture"
110
+ return result
111
+ try:
112
+ with shm_path.open("rb") as shm_handle:
113
+ shm_before = os.fstat(shm_handle.fileno())
114
+ result["shm"] = _file_record(shm_before)
115
+ wal_after = os.fstat(wal_handle.fileno())
116
+ shm_after = os.fstat(shm_handle.fileno())
117
+ result["captureStable"] = (
118
+ _same_file(wal_before, wal_after)
119
+ and _same_file(shm_before, shm_after)
120
+ and _path_matches(wal_path, wal_after)
121
+ and _path_matches(shm_path, shm_after)
122
+ )
123
+ except FileNotFoundError:
124
+ # Absence cannot be proven as one stable pair on the unlocked
125
+ # forensic path. The locked cutover still treats a zero WAL as
126
+ # safe, but the evidence does not overclaim capture stability.
127
+ result["captureStable"] = False
128
+ except OSError as exc:
129
+ result["reason"] = f"shm open failed: {exc}"[:200]
130
+ except OSError as exc:
131
+ return _empty_evidence("capture_raced", str(exc)[:200])
132
+ if not result["captureStable"] and result["shm"] is not None:
133
+ result["verdict"] = "capture_raced"
134
+ result["reason"] = "WAL or SHM identity changed during capture"
135
+ return result
136
+
137
+
138
+ def inspect_wal_index_family(db_path) -> dict:
139
+ """Return bounded WAL/SHM coherence evidence without opening SQLite."""
140
+ db_path = pathlib.Path(db_path)
141
+ wal_path = pathlib.Path(f"{db_path}-wal")
142
+ shm_path = pathlib.Path(f"{db_path}-shm")
143
+ try:
144
+ wal_stat = wal_path.stat()
145
+ except FileNotFoundError:
146
+ return _empty_evidence("wal_absent")
147
+ except OSError as exc:
148
+ return _empty_evidence("unavailable", f"wal stat failed: {exc}"[:200])
149
+ if wal_stat.st_size == 0:
150
+ return _inspect_zero_wal(wal_path, shm_path)
151
+ try:
152
+ shm_path.stat()
153
+ except FileNotFoundError:
154
+ result = _empty_evidence("shm_absent")
155
+ result["wal"] = _file_record(wal_stat)
156
+ return result
157
+ except OSError as exc:
158
+ return _empty_evidence("unavailable", f"shm stat failed: {exc}"[:200])
159
+
160
+ result = _empty_evidence("unavailable")
161
+ try:
162
+ with wal_path.open("rb") as wal_handle, shm_path.open("rb") as shm_handle:
163
+ wal_before = os.fstat(wal_handle.fileno())
164
+ shm_before = os.fstat(shm_handle.fileno())
165
+ result["wal"] = _file_record(wal_before)
166
+ result["shm"] = _file_record(shm_before)
167
+ wal_header = _read_at(wal_handle, 0, 32)
168
+ shm_header = _read_at(shm_handle, 0, 136)
169
+ if len(wal_header) < 32 or len(shm_header) < 136:
170
+ result["reason"] = "WAL or SHM header is truncated"
171
+ else:
172
+ _populate_evidence(
173
+ result, wal_handle, shm_handle, wal_header, shm_header,
174
+ wal_before, shm_before,
175
+ )
176
+ wal_header_after = _read_at(wal_handle, 0, 32)
177
+ shm_header_after = _read_at(shm_handle, 0, 136)
178
+ wal_after = os.fstat(wal_handle.fileno())
179
+ shm_after = os.fstat(shm_handle.fileno())
180
+ stable = (
181
+ _same_file(wal_before, wal_after)
182
+ and _same_file(shm_before, shm_after)
183
+ and _path_matches(wal_path, wal_after)
184
+ and _path_matches(shm_path, shm_after)
185
+ and wal_header == wal_header_after
186
+ and shm_header == shm_header_after
187
+ )
188
+ except OSError as exc:
189
+ return _empty_evidence("capture_raced", str(exc)[:200])
190
+
191
+ result["captureStable"] = stable
192
+ if not stable:
193
+ result["verdict"] = "capture_raced"
194
+ result["reason"] = "WAL or SHM identity changed during capture"
195
+ return result
196
+
197
+
198
+ def _populate_evidence(
199
+ result: dict,
200
+ wal_handle,
201
+ shm_handle,
202
+ wal_header: bytes,
203
+ shm_header: bytes,
204
+ wal_stat: os.stat_result,
205
+ shm_stat: os.stat_result,
206
+ ) -> None:
207
+ magic = struct.unpack_from(">I", wal_header, 0)[0]
208
+ page_size_raw = struct.unpack_from(">I", wal_header, 8)[0]
209
+ page_size = 65536 if page_size_raw == 1 else int(page_size_raw)
210
+ if magic not in _WAL_MAGICS or page_size < 512 or page_size > 65536:
211
+ result["reason"] = "WAL header magic or page size is invalid"
212
+ return
213
+
214
+ frame_bytes = 24 + page_size
215
+ physical_frames = max(0, (wal_stat.st_size - 32) // frame_bytes)
216
+ trailing_bytes = max(0, (wal_stat.st_size - 32) % frame_bytes)
217
+ wal_salt_bytes = wal_header[16:24]
218
+ wal_salt = wal_salt_bytes.hex()
219
+ shm_salt = shm_header[32:40].hex()
220
+ shm_page_size_raw = _native_u16(shm_header, 14)
221
+ shm_page_size = 65536 if shm_page_size_raw == 1 else shm_page_size_raw
222
+ mx_frame = _native_u32(shm_header, 16)
223
+ scan_limit = min(physical_frames, _FRAME_ANALYSIS_MAX)
224
+ current_generation_frames = 0
225
+ current_generation_commit_frames = 0
226
+ first_stale_tail_frame = None
227
+ frame_headers: dict[int, bytes] = {}
228
+ for frame_number in range(1, scan_limit + 1):
229
+ offset = 32 + (frame_number - 1) * frame_bytes
230
+ frame_header = _read_at(wal_handle, offset, 24)
231
+ if len(frame_header) < 24:
232
+ result["reason"] = "WAL frame header is truncated"
233
+ return
234
+ frame_headers[frame_number] = frame_header
235
+ if frame_header[8:16] != wal_salt_bytes:
236
+ first_stale_tail_frame = frame_number
237
+ break
238
+ current_generation_frames = frame_number
239
+ if struct.unpack_from(">I", frame_header, 4)[0] != 0:
240
+ current_generation_commit_frames = frame_number
241
+
242
+ analysis_truncated = (
243
+ first_stale_tail_frame is None
244
+ and physical_frames > _FRAME_ANALYSIS_MAX
245
+ ) or mx_frame > _FRAME_ANALYSIS_MAX
246
+ compared_limit = min(
247
+ mx_frame, current_generation_commit_frames, _FRAME_ANALYSIS_MAX
248
+ )
249
+ mismatch_count = 0
250
+ mismatch_sample = []
251
+ compared_count = 0
252
+ mapping_incomplete = False
253
+ for frame_number in range(1, compared_limit + 1):
254
+ frame_header = frame_headers.get(frame_number)
255
+ if frame_header is None:
256
+ offset = 32 + (frame_number - 1) * frame_bytes
257
+ frame_header = _read_at(wal_handle, offset, 24)
258
+ shm_offset = _shm_page_offset(frame_number)
259
+ if shm_offset + 4 > shm_stat.st_size:
260
+ mapping_incomplete = True
261
+ break
262
+ shm_page_raw = _read_at(shm_handle, shm_offset, 4)
263
+ if len(shm_page_raw) < 4:
264
+ mapping_incomplete = True
265
+ break
266
+ wal_page = int(struct.unpack_from(">I", frame_header, 0)[0])
267
+ shm_page = _native_u32(shm_page_raw, 0)
268
+ compared_count += 1
269
+ if wal_page != shm_page:
270
+ mismatch_count += 1
271
+ if len(mismatch_sample) < _MISMATCH_SAMPLE_MAX:
272
+ mismatch_sample.append({
273
+ "frame": frame_number,
274
+ "walPage": wal_page,
275
+ "shmPage": shm_page,
276
+ })
277
+
278
+ header_copies_match = shm_header[:48] == shm_header[48:96]
279
+ result["wal"].update({
280
+ "magicHex": f"{magic:08x}",
281
+ "saltHex": wal_salt,
282
+ "pageSize": page_size,
283
+ "physicalFrameCount": physical_frames,
284
+ "trailingBytes": trailing_bytes,
285
+ "currentGenerationFrameCount": current_generation_frames,
286
+ "currentGenerationCommitFrameCount": current_generation_commit_frames,
287
+ "stalePhysicalTailFrameCount": max(
288
+ 0, physical_frames - current_generation_frames
289
+ ),
290
+ })
291
+ result["shm"].update({
292
+ "headerCopiesMatch": header_copies_match,
293
+ "saltHex": shm_salt,
294
+ "pageSize": shm_page_size,
295
+ "mxFrame": mx_frame,
296
+ "nPage": _native_u32(shm_header, 20),
297
+ "nBackfill": _native_u32(shm_header, 96),
298
+ "nBackfillAttempted": _native_u32(shm_header, 128),
299
+ })
300
+ result["frameMapping"] = {
301
+ "comparedCount": compared_count,
302
+ "mismatchCount": mismatch_count,
303
+ "mismatchSample": mismatch_sample,
304
+ "truncated": analysis_truncated or mapping_incomplete,
305
+ }
306
+
307
+ if analysis_truncated:
308
+ verdict = "analysis_truncated"
309
+ elif wal_salt != shm_salt:
310
+ verdict = "wal_index_generation_mismatch"
311
+ elif (
312
+ not header_copies_match
313
+ or mismatch_count
314
+ or mapping_incomplete
315
+ or mx_frame != current_generation_commit_frames
316
+ or shm_page_size != page_size
317
+ or trailing_bytes != 0
318
+ ):
319
+ verdict = "wal_index_mapping_mismatch"
320
+ else:
321
+ verdict = "coherent"
322
+ result["verdict"] = verdict
323
+ result["reason"] = None
324
+
325
+
326
+ def is_incoherent_wal_index(evidence: dict) -> bool:
327
+ return evidence.get("verdict") in _INCOHERENT_VERDICTS
@@ -262,7 +262,8 @@ class TuiSessionRow:
262
262
  # Absolute project_path (NULL ⇒ ``(unknown)`` resolved upstream).
263
263
  # Carried so the sync-thread builder can compute ``project_key``
264
264
  # without re-reading ``session_files`` and so the share path can
265
- # privacy-scrub via ``_lib_share._scrub``.
265
+ # anonymize it in the preparation pass ``_lib_share.render()`` /
266
+ # ``compose()`` run over the raw snapshot.
266
267
  project_path: str | None = None
267
268
  # Dashboard/TUI-only: the human-readable session title (AI-generated
268
269
  # title when present, else the first non-marker user prompt). Populated