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.
- package/CHANGELOG.md +43 -0
- package/bin/_cctally_cache.py +354 -0
- package/bin/_cctally_core.py +180 -3
- package/bin/_cctally_dashboard.py +71 -1
- package/bin/_cctally_dashboard_envelope.py +28 -2
- package/bin/_cctally_dashboard_share.py +75 -19
- package/bin/_cctally_dashboard_sources.py +12 -0
- package/bin/_cctally_db.py +89 -1
- package/bin/_cctally_doctor.py +31 -0
- package/bin/_cctally_forecast.py +4 -2
- package/bin/_cctally_journal.py +3482 -258
- package/bin/_cctally_journal_repair.py +123 -32
- package/bin/_cctally_milestone_history.py +4 -1
- package/bin/_cctally_project.py +8 -6
- package/bin/_cctally_quota.py +420 -20
- package/bin/_cctally_rederive.py +57 -23
- package/bin/_cctally_reporting.py +8 -6
- package/bin/_cctally_share.py +74 -37
- package/bin/_cctally_source_analytics.py +6 -8
- package/bin/_cctally_store.py +13 -2
- package/bin/_cctally_tui.py +53 -0
- package/bin/_lib_cache_coverage.py +547 -0
- package/bin/_lib_doctor.py +54 -2
- package/bin/_lib_journal.py +235 -95
- package/bin/_lib_journal_router.py +21 -0
- package/bin/_lib_segment_summary.py +374 -0
- package/bin/_lib_selector_state.py +959 -0
- package/bin/_lib_share.py +1073 -165
- package/bin/_lib_share_templates.py +35 -11
- package/bin/_lib_stats_wal.py +327 -0
- package/bin/_lib_view_models.py +2 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
- package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- 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
|
|
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
|
|
69
|
-
#
|
|
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 `
|
|
202
|
-
|
|
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
|
-
# ---
|
|
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
|
-
# ---
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -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
|
-
#
|
|
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
|