cctally 1.93.0 → 1.94.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 +48 -0
- package/bin/_cctally_cache.py +55 -6
- package/bin/_cctally_codex.py +35 -3
- package/bin/_cctally_config.py +108 -2
- package/bin/_cctally_core.py +37 -8
- package/bin/_cctally_dashboard.py +6 -0
- package/bin/_cctally_dashboard_share.py +744 -137
- package/bin/_cctally_dashboard_sources.py +9 -0
- package/bin/_cctally_db.py +315 -40
- package/bin/_cctally_doctor.py +188 -3
- package/bin/_cctally_five_hour.py +39 -12
- package/bin/_cctally_forecast.py +29 -47
- package/bin/_cctally_journal.py +79 -7
- package/bin/_cctally_parser.py +71 -1
- package/bin/_cctally_project.py +0 -2
- package/bin/_cctally_record.py +18 -0
- package/bin/_cctally_reporting.py +0 -9
- package/bin/_cctally_retention.py +2890 -0
- package/bin/_cctally_share.py +97 -92
- package/bin/_cctally_source_analytics.py +44 -16
- package/bin/_cctally_store.py +194 -14
- package/bin/_lib_artifact_retention.py +1645 -0
- package/bin/_lib_display_tz.py +18 -0
- package/bin/_lib_doctor.py +378 -24
- package/bin/_lib_share.py +1359 -183
- package/bin/_lib_share_templates.py +181 -69
- package/bin/_lib_view_models.py +12 -0
- package/bin/cctally +36 -4
- package/dashboard/static/assets/{index-HlIK7k8Q.js → index-CaUQziq_.js} +48 -48
- package/dashboard/static/assets/index-DUQjFSX7.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +0 -1
package/bin/_cctally_doctor.py
CHANGED
|
@@ -144,7 +144,179 @@ def _journal_heal_incident(kind: str, name: str, now_utc: dt.datetime) -> dict:
|
|
|
144
144
|
break
|
|
145
145
|
except ValueError:
|
|
146
146
|
continue
|
|
147
|
-
return {"kind": kind, "name": name, "age_s": age_s}
|
|
147
|
+
return {"kind": kind, "name": name, "age_s": age_s, "shape": None}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _incident_shape_token(incident) -> "str | None":
|
|
151
|
+
"""One incident's `damage.preserved.shapeToken` (#496 S6 §7.2 / §3.4).
|
|
152
|
+
|
|
153
|
+
Read here rather than in the kernel, which takes no filesystem. A manifest
|
|
154
|
+
that cannot be read contributes no shape, which is the safe direction: a
|
|
155
|
+
missing shape cannot manufacture a recurrence.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
manifest = json.loads(
|
|
159
|
+
(incident / "manifest.json").read_text(encoding="utf-8")
|
|
160
|
+
)
|
|
161
|
+
except (OSError, ValueError):
|
|
162
|
+
return None
|
|
163
|
+
if not isinstance(manifest, dict):
|
|
164
|
+
return None
|
|
165
|
+
damage = manifest.get("damage")
|
|
166
|
+
preserved = damage.get("preserved") if isinstance(damage, dict) else None
|
|
167
|
+
token = preserved.get("shapeToken") if isinstance(preserved, dict) else None
|
|
168
|
+
return token if isinstance(token, str) and token else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _gather_heal_detections(now_utc: dt.datetime) -> "list | None":
|
|
172
|
+
"""The durable heal ring, as `{heal_id, age_s}` per entry (§7.2).
|
|
173
|
+
|
|
174
|
+
A DETECTION is a ring entry keyed by `healId`, which is a different thing
|
|
175
|
+
from an incident: a declined or coalesced detection produces a ring entry
|
|
176
|
+
and no quarantine directory at all, and only the ring can report the RATE.
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
import _cctally_store
|
|
180
|
+
|
|
181
|
+
events = _cctally_store.read_stats_heal_events()
|
|
182
|
+
except Exception:
|
|
183
|
+
return None
|
|
184
|
+
found = []
|
|
185
|
+
for event in events:
|
|
186
|
+
if not isinstance(event, dict):
|
|
187
|
+
continue
|
|
188
|
+
age_s = None
|
|
189
|
+
stamp = event.get("detectedAtUtc")
|
|
190
|
+
if isinstance(stamp, str) and stamp:
|
|
191
|
+
try:
|
|
192
|
+
parsed = dt.datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
193
|
+
age_s = int((now_utc - parsed).total_seconds())
|
|
194
|
+
except ValueError:
|
|
195
|
+
age_s = None
|
|
196
|
+
found.append({
|
|
197
|
+
"heal_id": str(event.get("healId") or ""),
|
|
198
|
+
"age_s": age_s,
|
|
199
|
+
"outcome": event.get("outcome"),
|
|
200
|
+
})
|
|
201
|
+
return found
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _gather_retained_artifacts(
|
|
205
|
+
now_utc: dt.datetime, *, deep: bool = False,
|
|
206
|
+
) -> "dict | None":
|
|
207
|
+
"""The read-only retention scan and plan behind `db.retained_artifacts`.
|
|
208
|
+
|
|
209
|
+
Takes NO lock and writes nothing — it is the same walk and the same kernel
|
|
210
|
+
plan `cctally db prune`'s preview runs. A malformed policy is reported
|
|
211
|
+
rather than repaired, because §6.5 makes an unreadable policy a FAIL and
|
|
212
|
+
not a fallback to the defaults.
|
|
213
|
+
|
|
214
|
+
**The walk and the planner are `deep`-gated, like the `quick_check` legs.**
|
|
215
|
+
This gather is reached from the TUI and the dashboard snapshot precompute
|
|
216
|
+
on every rebuild, not only from `GET /api/doctor`, and the pair costs tens
|
|
217
|
+
of milliseconds at the maintainer's corpus size and grows with it. What
|
|
218
|
+
stays in the shallow path is what an operator must act on and what costs
|
|
219
|
+
one file read and one glob: the policy resolution and the stuck reclaim
|
|
220
|
+
records.
|
|
221
|
+
|
|
222
|
+
`now_utc` is the gather's clock, which honours `CCTALLY_AS_OF`. Letting the
|
|
223
|
+
planner read the wall clock instead would make the age bound — and every
|
|
224
|
+
golden that depends on it — drift with the calendar.
|
|
225
|
+
|
|
226
|
+
The failure path names the exception CLASS rather than returning a bare
|
|
227
|
+
None. Doctor must not crash, but a degrade that says only "unavailable" is
|
|
228
|
+
indistinguishable from a healthy install with nothing retained, and a
|
|
229
|
+
programming error here reached four golden fixtures and made all four of
|
|
230
|
+
them vacuous before anything reported it.
|
|
231
|
+
"""
|
|
232
|
+
now_epoch = now_utc.timestamp()
|
|
233
|
+
try:
|
|
234
|
+
import _cctally_retention
|
|
235
|
+
|
|
236
|
+
resolution = _cctally_retention.read_retention_policy()
|
|
237
|
+
stuck = [
|
|
238
|
+
{
|
|
239
|
+
"planId": record["planId"],
|
|
240
|
+
"memberIds": sorted(record["entries"]),
|
|
241
|
+
"stuck": bool(record.get("stuck")),
|
|
242
|
+
"ageSeconds": record.get("ageSeconds"),
|
|
243
|
+
}
|
|
244
|
+
for record in _cctally_retention.list_stuck_reclaim_records(
|
|
245
|
+
now_epoch=now_epoch,
|
|
246
|
+
)
|
|
247
|
+
]
|
|
248
|
+
if resolution.status == "malformed":
|
|
249
|
+
# No byte figures are reported, rather than zeros: the scan never
|
|
250
|
+
# ran, and a zero here is a false measurement a consumer would
|
|
251
|
+
# render as "nothing retained".
|
|
252
|
+
return {
|
|
253
|
+
"policy_status": "malformed",
|
|
254
|
+
"policy_reason": resolution.reason,
|
|
255
|
+
"stuck_records": stuck,
|
|
256
|
+
}
|
|
257
|
+
bounds = {
|
|
258
|
+
"max_age_seconds": resolution.policy.max_age_seconds,
|
|
259
|
+
"max_count_per_family": resolution.policy.max_count_per_family,
|
|
260
|
+
"max_total_bytes": resolution.policy.max_total_bytes,
|
|
261
|
+
"min_free_bytes": resolution.policy.min_free_bytes,
|
|
262
|
+
}
|
|
263
|
+
if not deep:
|
|
264
|
+
return {
|
|
265
|
+
"policy_status": "not-scanned",
|
|
266
|
+
"policy_reason": None,
|
|
267
|
+
"stuck_records": stuck,
|
|
268
|
+
**bounds,
|
|
269
|
+
}
|
|
270
|
+
scan, plan, graph = _cctally_retention.plan_retention(
|
|
271
|
+
policy=resolution.policy, now_epoch=now_epoch, with_graph=True,
|
|
272
|
+
)
|
|
273
|
+
return {
|
|
274
|
+
"policy_status": resolution.status,
|
|
275
|
+
"policy_reason": None,
|
|
276
|
+
"retained_bytes": plan.before_bytes,
|
|
277
|
+
"reclaimable_bytes": plan.reclaimable_bytes,
|
|
278
|
+
"protected_bytes": _retention_protected_bytes(scan, plan, graph),
|
|
279
|
+
"protected_roots": len(plan.protected_ids),
|
|
280
|
+
"roots": len(plan.delete_ids) + len(plan.keep_ids)
|
|
281
|
+
+ len(plan.protected_ids),
|
|
282
|
+
"free_disk_bytes": scan.free_disk_bytes,
|
|
283
|
+
"partial_scan": bool(scan.partial),
|
|
284
|
+
"unsatisfied_rules": list(plan.unsatisfied_rules),
|
|
285
|
+
# Which bounds actually SELECTED something. The WARN summary used
|
|
286
|
+
# to name the byte budget whatever drove the reclamation, the same
|
|
287
|
+
# false sentence the FAIL summary printed.
|
|
288
|
+
"driving_rules": [
|
|
289
|
+
rule for rule in _cctally_retention._kernel.BOUND_ORDER
|
|
290
|
+
if rule in set(plan.reasons.values())
|
|
291
|
+
],
|
|
292
|
+
"floor_retained_roots": len(plan.floor_retained_ids),
|
|
293
|
+
"floor_retained_bytes": plan.floor_retained_bytes,
|
|
294
|
+
"stuck_records": stuck,
|
|
295
|
+
**bounds,
|
|
296
|
+
}
|
|
297
|
+
except Exception as exc: # noqa: BLE001 — doctor never crashes
|
|
298
|
+
# Read-only diagnostics never fail the gather; the leg degrades. The
|
|
299
|
+
# class is carried so the degrade is STATED rather than silent.
|
|
300
|
+
return {"policy_status": "unavailable", "scan_error": type(exc).__name__}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _retention_protected_bytes(scan, plan, graph=None) -> int:
|
|
304
|
+
"""Bytes held by protected roots, counted once across shared members.
|
|
305
|
+
|
|
306
|
+
The graph is handed in. Letting `summarize_prune` build its own made the
|
|
307
|
+
doctor leg construct the reference graph TWICE per gather, once inside
|
|
308
|
+
`plan_retention` and once here, for a structure neither call mutates.
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
import _cctally_retention
|
|
312
|
+
|
|
313
|
+
return int(
|
|
314
|
+
_cctally_retention.summarize_prune(
|
|
315
|
+
scan, plan, graph=graph,
|
|
316
|
+
)["protectedBytes"]
|
|
317
|
+
)
|
|
318
|
+
except Exception:
|
|
319
|
+
return 0
|
|
148
320
|
|
|
149
321
|
|
|
150
322
|
def _read_guard_log_tail(path) -> list[str]:
|
|
@@ -1770,8 +1942,13 @@ def _doctor_gather_state_impl(
|
|
|
1770
1942
|
_incident_read_ok = True
|
|
1771
1943
|
for entry in qroot.iterdir():
|
|
1772
1944
|
if entry.is_dir():
|
|
1773
|
-
|
|
1774
|
-
|
|
1945
|
+
record = _journal_heal_incident(
|
|
1946
|
+
"quarantine", entry.name, now_utc)
|
|
1947
|
+
# §7.2 escalates on a REPEATED damage shape, so the shape
|
|
1948
|
+
# has to travel with the incident it belongs to — counted
|
|
1949
|
+
# once per incident, never once per manifest read.
|
|
1950
|
+
record["shape"] = _incident_shape_token(entry)
|
|
1951
|
+
_incidents.append(record)
|
|
1775
1952
|
except OSError:
|
|
1776
1953
|
pass
|
|
1777
1954
|
try:
|
|
@@ -1791,6 +1968,12 @@ def _doctor_gather_state_impl(
|
|
|
1791
1968
|
d["age_s"] if d["age_s"] is not None else 0))
|
|
1792
1969
|
journal_heal_incidents = _incidents
|
|
1793
1970
|
|
|
1971
|
+
# #496 S6 §7.2 / §7.3. Both are read-only and take no lock; both degrade to
|
|
1972
|
+
# None rather than failing the gather, because `doctor` is reached from the
|
|
1973
|
+
# TUI and the dashboard snapshot precompute as well as from the CLI.
|
|
1974
|
+
journal_heal_detections = _gather_heal_detections(now_utc)
|
|
1975
|
+
retained_artifacts = _gather_retained_artifacts(now_utc, deep=deep)
|
|
1976
|
+
|
|
1794
1977
|
# #386/#389 stats sole-writer guard log (spec §6.4). Read-only, fail-soft: an
|
|
1795
1978
|
# absent log is the NORMAL state and must read as INFO, never as a gather
|
|
1796
1979
|
# failure. Read only the bounded tail; rotation and cross-process throttling
|
|
@@ -1910,6 +2093,8 @@ def _doctor_gather_state_impl(
|
|
|
1910
2093
|
journal_hw_segment=journal_hw_segment,
|
|
1911
2094
|
journal_cursor_segment=journal_cursor_segment,
|
|
1912
2095
|
journal_heal_incidents=journal_heal_incidents,
|
|
2096
|
+
journal_heal_detections=journal_heal_detections,
|
|
2097
|
+
retained_artifacts=retained_artifacts,
|
|
1913
2098
|
journal_writer_guard=journal_writer_guard,
|
|
1914
2099
|
# Multi-account attribution legs (#341).
|
|
1915
2100
|
accounts_state=accounts_state,
|
|
@@ -995,6 +995,27 @@ def _load_breakdown(
|
|
|
995
995
|
return [dict(r) for r in rows]
|
|
996
996
|
|
|
997
997
|
|
|
998
|
+
def _blocks_period_instant(raw: object) -> "dt.datetime":
|
|
999
|
+
"""A block boundary as a real INSTANT for the artifact's period.
|
|
1000
|
+
|
|
1001
|
+
`block_start_at` and `five_hour_resets_at` are timestamps, not
|
|
1002
|
+
calendar labels, so they convert into the display zone (#503 S2 D7).
|
|
1003
|
+
Keeping only the UTC date part — which is what this site used to do —
|
|
1004
|
+
named the wrong civil day west of UTC and disagreed with the block's
|
|
1005
|
+
own row cell, which `format_display_dt` renders in the display zone.
|
|
1006
|
+
|
|
1007
|
+
It takes no zone argument. The conversion into the display zone
|
|
1008
|
+
happens in `period_civil_dates`, from the zone the `PeriodSpec` is
|
|
1009
|
+
labelled with, so a zone passed here would be a second authority that
|
|
1010
|
+
could disagree with the first (#503 S2 second review N8).
|
|
1011
|
+
"""
|
|
1012
|
+
_c = _cctally()
|
|
1013
|
+
try:
|
|
1014
|
+
return parse_iso_datetime(str(raw), "five_hour_blocks.block_start_at")
|
|
1015
|
+
except (TypeError, ValueError):
|
|
1016
|
+
return _c._share_now_utc()
|
|
1017
|
+
|
|
1018
|
+
|
|
998
1019
|
def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
999
1020
|
"""List API-anchored 5h blocks with rollup totals + 7d-drift columns."""
|
|
1000
1021
|
_c = _cctally()
|
|
@@ -1172,11 +1193,13 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
1172
1193
|
since_iso, args._resolved_tz,
|
|
1173
1194
|
)
|
|
1174
1195
|
elif block_dicts:
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1196
|
+
# A block start is a real INSTANT, so it converts into the
|
|
1197
|
+
# display zone rather than being grounded (#503 S2 D7).
|
|
1198
|
+
# This used to keep only the UTC date part, which named
|
|
1199
|
+
# the wrong civil day west of UTC and disagreed with the
|
|
1200
|
+
# block's own row cell, rendered in the display zone.
|
|
1201
|
+
period_start = _blocks_period_instant(
|
|
1202
|
+
block_dicts[-1].get("block_start_at"))
|
|
1180
1203
|
else:
|
|
1181
1204
|
period_start = _c._share_now_utc()
|
|
1182
1205
|
if until_iso:
|
|
@@ -1184,11 +1207,17 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
1184
1207
|
until_iso, args._resolved_tz,
|
|
1185
1208
|
)
|
|
1186
1209
|
elif block_dicts:
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1210
|
+
# The newest block's END, not its start. A block runs for
|
|
1211
|
+
# five hours and the artifact's rows describe all of it,
|
|
1212
|
+
# so ending the stated period at 13:00 for a block that
|
|
1213
|
+
# runs to 18:00 understated what the artifact covers —
|
|
1214
|
+
# invisible while the period was rendered as a date and
|
|
1215
|
+
# visible the moment the frontmatter carried the full
|
|
1216
|
+
# timestamp (#503 S2 second review N5).
|
|
1217
|
+
newest = block_dicts[0]
|
|
1218
|
+
period_end = _blocks_period_instant(
|
|
1219
|
+
newest.get("five_hour_resets_at")
|
|
1220
|
+
or newest.get("block_start_at"))
|
|
1192
1221
|
else:
|
|
1193
1222
|
period_end = _c._share_now_utc()
|
|
1194
1223
|
# Build a BlocksView from the API-anchored table rows
|
|
@@ -1208,8 +1237,6 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
1208
1237
|
period_end=period_end,
|
|
1209
1238
|
display_tz=display_tz_str,
|
|
1210
1239
|
version=_c._share_resolve_version(),
|
|
1211
|
-
theme=args.theme,
|
|
1212
|
-
reveal_projects=args.reveal_projects,
|
|
1213
1240
|
tz=args._resolved_tz,
|
|
1214
1241
|
)
|
|
1215
1242
|
_c._share_render_and_emit(snap, args)
|
package/bin/_cctally_forecast.py
CHANGED
|
@@ -1039,22 +1039,21 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
1039
1039
|
# fixture goldens don't drift when the harness host's
|
|
1040
1040
|
# wall-clock day rolls past CCTALLY_AS_OF.
|
|
1041
1041
|
now_local = _command_as_of().astimezone(tz)
|
|
1042
|
-
local_tz = now_local.tzinfo
|
|
1043
1042
|
ws_d, we_d = compute_week_bounds(now_local, week_start_name)
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
)
|
|
1043
|
+
# Grounded in the zone the period is LABELLED with, not in
|
|
1044
|
+
# `now_local.tzinfo` (#503 S2 D7). That attribute is the
|
|
1045
|
+
# fixed offset in force right now, so a week that began
|
|
1046
|
+
# under a different DST offset was anchored an hour away
|
|
1047
|
+
# from its own midnight and could state the previous day.
|
|
1048
|
+
ws_dt = c._share_ground_civil_date(ws_d, tz)
|
|
1049
|
+
we_dt = c._share_ground_civil_date(
|
|
1050
|
+
we_d + dt.timedelta(days=1), tz)
|
|
1050
1051
|
snap = c._build_report_snapshot(
|
|
1051
1052
|
c.TrendView(),
|
|
1052
1053
|
period_start=ws_dt,
|
|
1053
1054
|
period_end=we_dt,
|
|
1054
1055
|
display_tz=display_tz_str,
|
|
1055
1056
|
version=c._share_resolve_version(),
|
|
1056
|
-
theme=args.theme,
|
|
1057
|
-
reveal_projects=args.reveal_projects,
|
|
1058
1057
|
)
|
|
1059
1058
|
c._share_render_and_emit(snap, args)
|
|
1060
1059
|
return 0
|
|
@@ -1253,8 +1252,6 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
1253
1252
|
period_end=period_end,
|
|
1254
1253
|
display_tz=display_tz_str,
|
|
1255
1254
|
version=c._share_resolve_version(),
|
|
1256
|
-
theme=args.theme,
|
|
1257
|
-
reveal_projects=args.reveal_projects,
|
|
1258
1255
|
)
|
|
1259
1256
|
c._share_render_and_emit(snap, args)
|
|
1260
1257
|
return 0
|
|
@@ -1442,14 +1439,13 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1442
1439
|
config, getattr(args, "week_start_name", None)
|
|
1443
1440
|
)
|
|
1444
1441
|
ws_date, we_date = compute_week_bounds(now_utc, week_start_name)
|
|
1445
|
-
#
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
)
|
|
1450
|
-
week_end_dt =
|
|
1451
|
-
we_date + dt.timedelta(days=1),
|
|
1452
|
-
)
|
|
1442
|
+
# Grounded in the zone `display_tz_str` names (#503 S2 D7). It
|
|
1443
|
+
# used to be host-local, which disagrees with the label
|
|
1444
|
+
# whenever `display.tz` names another zone.
|
|
1445
|
+
_res_tz = getattr(args, "_resolved_tz", None)
|
|
1446
|
+
week_start_dt = c._share_ground_civil_date(ws_date, _res_tz)
|
|
1447
|
+
week_end_dt = c._share_ground_civil_date(
|
|
1448
|
+
we_date + dt.timedelta(days=1), _res_tz)
|
|
1453
1449
|
# Pass `low_conf=False` + explicit notes: the issue is "no data
|
|
1454
1450
|
# recorded yet," not "thin data." LOW CONF would mislead the
|
|
1455
1451
|
# reader into thinking a projection ran with sparse samples.
|
|
@@ -1458,8 +1454,6 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1458
1454
|
week_end=week_end_dt,
|
|
1459
1455
|
display_tz=display_tz_str,
|
|
1460
1456
|
version=c._share_resolve_version(),
|
|
1461
|
-
theme=args.theme,
|
|
1462
|
-
reveal_projects=args.reveal_projects,
|
|
1463
1457
|
actual_series=[],
|
|
1464
1458
|
projected_series=[],
|
|
1465
1459
|
current_pct=0.0,
|
|
@@ -1564,8 +1558,6 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1564
1558
|
week_end=i.week_end_at,
|
|
1565
1559
|
display_tz=display_tz_str,
|
|
1566
1560
|
version=c._share_resolve_version(),
|
|
1567
|
-
theme=args.theme,
|
|
1568
|
-
reveal_projects=args.reveal_projects,
|
|
1569
1561
|
actual_series=actual_series,
|
|
1570
1562
|
projected_series=projected_series,
|
|
1571
1563
|
current_pct=float(i.p_now),
|
|
@@ -3057,7 +3049,7 @@ def _build_budget_snapshot(
|
|
|
3057
3049
|
period_label = c._share_period_label(
|
|
3058
3050
|
inputs.week_start_at, inputs.week_end_at, tz_label
|
|
3059
3051
|
)
|
|
3060
|
-
title = f"Budget — week of {inputs.week_start_at.
|
|
3052
|
+
title = f"Budget — week of {inputs.week_start_at.date().isoformat()}"
|
|
3061
3053
|
period_spec = _lib_share.PeriodSpec(
|
|
3062
3054
|
start=inputs.week_start_at, end=inputs.week_end_at,
|
|
3063
3055
|
display_tz=tz_label, label=period_label,
|
|
@@ -3082,15 +3074,12 @@ def _build_budget_snapshot(
|
|
|
3082
3074
|
"value": _lib_share.MoneyCell(status.projected_eow_high_usd)}),
|
|
3083
3075
|
)
|
|
3084
3076
|
notes = ("LOW CONF — early in week",) if status.low_confidence else ()
|
|
3085
|
-
subtitle = " · ".join([
|
|
3086
|
-
period_label,
|
|
3087
|
-
args.theme,
|
|
3088
|
-
"real projects" if args.reveal_projects else "projects anonymized",
|
|
3089
|
-
])
|
|
3090
3077
|
return _lib_share.ShareSnapshot(
|
|
3091
3078
|
cmd="budget",
|
|
3092
3079
|
title=title,
|
|
3093
|
-
|
|
3080
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
3081
|
+
# privacy mode; the theme is dropped deliberately.
|
|
3082
|
+
subtitle=None,
|
|
3094
3083
|
period=period_spec,
|
|
3095
3084
|
columns=columns,
|
|
3096
3085
|
rows=rows,
|
|
@@ -3118,22 +3107,17 @@ def _build_budget_no_data_snapshot(args, budget_cfg, now_utc):
|
|
|
3118
3107
|
config, getattr(args, "week_start_name", None)
|
|
3119
3108
|
)
|
|
3120
3109
|
ws_date, we_date = compute_week_bounds(now_utc, week_start_name)
|
|
3121
|
-
#
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
week_end_dt = dt.datetime.combine(
|
|
3125
|
-
we_date + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
|
|
3126
|
-
)
|
|
3110
|
+
# Grounded in the zone `tz_label` names (#503 S2 D7), not host-local.
|
|
3111
|
+
week_start_dt = c._share_ground_civil_date(ws_date, tz)
|
|
3112
|
+
week_end_dt = c._share_ground_civil_date(we_date + dt.timedelta(days=1), tz)
|
|
3127
3113
|
period_label = c._share_period_label(week_start_dt, week_end_dt, tz_label)
|
|
3128
3114
|
target = budget_cfg["weekly_usd"]
|
|
3129
|
-
subtitle = " · ".join([
|
|
3130
|
-
period_label, args.theme,
|
|
3131
|
-
"real projects" if args.reveal_projects else "projects anonymized",
|
|
3132
|
-
])
|
|
3133
3115
|
return _lib_share.ShareSnapshot(
|
|
3134
3116
|
cmd="budget",
|
|
3135
|
-
title=f"Budget — week of {week_start_dt.
|
|
3136
|
-
|
|
3117
|
+
title=f"Budget — week of {week_start_dt.date().isoformat()}",
|
|
3118
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
3119
|
+
# privacy mode; the theme is dropped deliberately.
|
|
3120
|
+
subtitle=None,
|
|
3137
3121
|
period=_lib_share.PeriodSpec(
|
|
3138
3122
|
start=week_start_dt, end=week_end_dt,
|
|
3139
3123
|
display_tz=tz_label, label=period_label,
|
|
@@ -3172,14 +3156,12 @@ def _build_budget_no_budget_snapshot(args):
|
|
|
3172
3156
|
tz = c.resolve_display_tz(args, config)
|
|
3173
3157
|
tz_label = c._share_display_tz_label(tz)
|
|
3174
3158
|
period_label = c._share_period_label(now_utc, now_utc, tz_label)
|
|
3175
|
-
subtitle = " · ".join([
|
|
3176
|
-
period_label, args.theme,
|
|
3177
|
-
"real projects" if args.reveal_projects else "projects anonymized",
|
|
3178
|
-
])
|
|
3179
3159
|
return _lib_share.ShareSnapshot(
|
|
3180
3160
|
cmd="budget",
|
|
3181
3161
|
title="Budget — no budget set",
|
|
3182
|
-
|
|
3162
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
3163
|
+
# privacy mode; the theme is dropped deliberately.
|
|
3164
|
+
subtitle=None,
|
|
3183
3165
|
period=_lib_share.PeriodSpec(
|
|
3184
3166
|
start=now_utc, end=now_utc, display_tz=tz_label, label=period_label,
|
|
3185
3167
|
),
|
package/bin/_cctally_journal.py
CHANGED
|
@@ -5668,6 +5668,27 @@ def _run_stats_ingest_once(
|
|
|
5668
5668
|
if own_conn and conn is not None:
|
|
5669
5669
|
conn.close()
|
|
5670
5670
|
finally:
|
|
5671
|
+
# §9.2 (#496 S6 F23): the routine stats write. The `-wal`/`-shm`
|
|
5672
|
+
# sidecars are materialized by the FIRST write, not by the connect, so
|
|
5673
|
+
# hardening at open time alone would leave a 0644 WAL behind every
|
|
5674
|
+
# ingest cycle — the exact shape of the cache.db defect #150 fixed.
|
|
5675
|
+
# This runs while the ingest lock is still held, so the sidecars it
|
|
5676
|
+
# inspects are the ones this cycle produced.
|
|
5677
|
+
#
|
|
5678
|
+
# Guarded because it is the FIRST statement of this block and the two
|
|
5679
|
+
# lock releases are the last two: anything raised here — including the
|
|
5680
|
+
# import — skips both, and the flocks are fd-scoped, so a long-lived
|
|
5681
|
+
# dashboard process would hold them until it exited. Hardening is
|
|
5682
|
+
# best-effort; releasing the locks is not.
|
|
5683
|
+
try:
|
|
5684
|
+
import _cctally_store
|
|
5685
|
+
_cctally_store._harden_stats_family(_cctally_core.DB_PATH)
|
|
5686
|
+
except Exception as exc: # noqa: BLE001 — never above a lock release
|
|
5687
|
+
print(
|
|
5688
|
+
f"[ingest] could not harden the stats family ({exc}); "
|
|
5689
|
+
"continuing",
|
|
5690
|
+
file=sys.stderr,
|
|
5691
|
+
)
|
|
5671
5692
|
if lock_fd is not None:
|
|
5672
5693
|
_release_ingest_lock(lock_fd)
|
|
5673
5694
|
if maintenance_fd is not None:
|
|
@@ -7055,14 +7076,14 @@ def _record_post_checkpoint_damage(
|
|
|
7055
7076
|
|
|
7056
7077
|
|
|
7057
7078
|
def _binary_version() -> "str | None":
|
|
7058
|
-
"""The running binary's released version, or None when it cannot be read.
|
|
7059
|
-
try:
|
|
7060
|
-
import _lib_changelog
|
|
7079
|
+
"""The running binary's released version, or None when it cannot be read.
|
|
7061
7080
|
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7081
|
+
One implementation, in `_cctally_db`, shared with the incident manifests
|
|
7082
|
+
the quarantine path writes (#496 S6 §4.2).
|
|
7083
|
+
"""
|
|
7084
|
+
import _cctally_db
|
|
7085
|
+
|
|
7086
|
+
return _cctally_db._binary_version()
|
|
7066
7087
|
|
|
7067
7088
|
|
|
7068
7089
|
def _preserve_stats_family_for_cutover(
|
|
@@ -7494,6 +7515,7 @@ def _publish_stats_index_in_place(
|
|
|
7494
7515
|
no incident directory), or `_FALL_BACK` when physical replacement is the
|
|
7495
7516
|
sanctioned response.
|
|
7496
7517
|
"""
|
|
7518
|
+
import _cctally_store
|
|
7497
7519
|
import _lib_stats_publish as sp
|
|
7498
7520
|
|
|
7499
7521
|
try:
|
|
@@ -7607,6 +7629,11 @@ def _publish_stats_index_in_place(
|
|
|
7607
7629
|
conn.close()
|
|
7608
7630
|
except Exception:
|
|
7609
7631
|
pass
|
|
7632
|
+
# §9.2 (#496 S6 F23): the in-place publisher never touches the destination
|
|
7633
|
+
# file's mode — it mutates objects inside it — so a family that was 0644
|
|
7634
|
+
# before the publication is still 0644 after it. The checkpoint above is
|
|
7635
|
+
# also the last thing that can re-materialize a sidecar.
|
|
7636
|
+
_cctally_store._harden_stats_family(destination)
|
|
7610
7637
|
_stats_rebuild_test_pause("rebuild_after_publication_replace")
|
|
7611
7638
|
|
|
7612
7639
|
# Phase 2: validate the bytes that are now live, on a connection that never
|
|
@@ -7780,6 +7807,12 @@ def _publish_rebuilt_stats_index(
|
|
|
7780
7807
|
)
|
|
7781
7808
|
|
|
7782
7809
|
_stats_rebuild_test_pause("rebuild_before_cutover")
|
|
7810
|
+
# §9.2 (#496 S6 F23): the scratch is closed and about to BECOME the
|
|
7811
|
+
# destination, so hardening it here is what makes the replacement private
|
|
7812
|
+
# from the instant it is visible under the live name. `os.replace` carries
|
|
7813
|
+
# the source inode's mode across; a chmod after the rename would leave a
|
|
7814
|
+
# window in which the live index was world-readable.
|
|
7815
|
+
_cctally_store._harden_stats_family(scratch)
|
|
7783
7816
|
os.replace(str(scratch), str(destination))
|
|
7784
7817
|
_fsync_dir(destination.parent)
|
|
7785
7818
|
_stats_rebuild_test_pause("rebuild_after_publication_replace")
|
|
@@ -7787,6 +7820,10 @@ def _publish_rebuilt_stats_index(
|
|
|
7787
7820
|
# Phase 2: validate the bytes that are now live, on a connection that never
|
|
7788
7821
|
# saw them being written.
|
|
7789
7822
|
post_error = validate_published_stats_index(destination, high_water)
|
|
7823
|
+
# The validation opened the family read-only, which materializes sidecars
|
|
7824
|
+
# the removal below deletes; harden the destination itself while they are
|
|
7825
|
+
# still present so neither the main nor a surviving sidecar stays 0644.
|
|
7826
|
+
_cctally_store._harden_stats_family(destination)
|
|
7790
7827
|
# The read-only open above creates a zero-byte WAL and a 32 KiB SHM.
|
|
7791
7828
|
# Remove them so the documented no-post-publication-stale-sidecar end state
|
|
7792
7829
|
# still holds; an empty WAL is consistent with the freshly published main,
|
|
@@ -8949,6 +8986,41 @@ def rebuild_stats_index(
|
|
|
8949
8986
|
high_water: "tuple[str, int] | None" = None,
|
|
8950
8987
|
update_quota_cache: bool = True,
|
|
8951
8988
|
before_swap=None,
|
|
8989
|
+
) -> RebuildResult:
|
|
8990
|
+
"""Rebuild the stats index under a SHARED `artifact-retention.lock` hold.
|
|
8991
|
+
|
|
8992
|
+
#496 S6 §5.3. A rebuild publishes three artifacts that reclamation must not
|
|
8993
|
+
mark while they are being written: the preserved-family incident manifest,
|
|
8994
|
+
the second manifest write `_record_post_checkpoint_damage` performs after
|
|
8995
|
+
the explicit checkpoint, and the rebuild record that names both. The hold
|
|
8996
|
+
spans all three, so no observer ever sees the incident half-described.
|
|
8997
|
+
|
|
8998
|
+
The hold is taken here rather than only at the producer call sites because
|
|
8999
|
+
`db rebuild` and the auto-heal worker are not the only callers — the epoch
|
|
9000
|
+
rebuild and the deferred rebuild reach the same cutover. It is refcounted,
|
|
9001
|
+
so a caller that already holds it pays nothing.
|
|
9002
|
+
|
|
9003
|
+
See `_rebuild_stats_index_locked` for the rebuild itself.
|
|
9004
|
+
"""
|
|
9005
|
+
import _cctally_retention
|
|
9006
|
+
|
|
9007
|
+
with _cctally_retention.retention_shared(label="stats rebuild"):
|
|
9008
|
+
return _rebuild_stats_index_locked(
|
|
9009
|
+
context=context,
|
|
9010
|
+
target_path=target_path,
|
|
9011
|
+
high_water=high_water,
|
|
9012
|
+
update_quota_cache=update_quota_cache,
|
|
9013
|
+
before_swap=before_swap,
|
|
9014
|
+
)
|
|
9015
|
+
|
|
9016
|
+
|
|
9017
|
+
def _rebuild_stats_index_locked(
|
|
9018
|
+
*,
|
|
9019
|
+
context: RebuildContext,
|
|
9020
|
+
target_path=None,
|
|
9021
|
+
high_water: "tuple[str, int] | None" = None,
|
|
9022
|
+
update_quota_cache: bool = True,
|
|
9023
|
+
before_swap=None,
|
|
8952
9024
|
) -> RebuildResult:
|
|
8953
9025
|
"""Build a FRESH stats index from the journal alone (spec §5.4).
|
|
8954
9026
|
|