cctally 1.115.0 → 1.116.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 +16 -0
- package/bin/_cctally_cache.py +85 -4
- package/bin/_cctally_completion.py +80 -12
- package/bin/_cctally_core.py +106 -9
- package/bin/_cctally_dashboard.py +6 -9
- package/bin/_cctally_dashboard_envelope.py +26 -32
- package/bin/_cctally_db.py +85 -4
- package/bin/_cctally_doctor.py +38 -5
- package/bin/_cctally_five_hour.py +83 -1017
- package/bin/_cctally_journal.py +1307 -180
- package/bin/_cctally_journal_repair.py +9 -7
- package/bin/_cctally_oauth_samples.py +280 -0
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_quarantine.py +14 -4
- package/bin/_cctally_quota.py +70 -8
- package/bin/_cctally_quota_model.py +35 -4
- package/bin/_cctally_record.py +414 -123
- package/bin/_cctally_rederive.py +632 -235
- package/bin/_cctally_refresh.py +29 -0
- package/bin/_cctally_statusline.py +984 -47
- package/bin/_cctally_store.py +972 -58
- package/bin/_cctally_tui.py +62 -7
- package/bin/_cctally_update.py +25 -0
- package/bin/_lib_account_protocol.py +201 -18
- package/bin/_lib_clone_copy.py +46 -11
- package/bin/_lib_dashboard_sources.py +57 -15
- package/bin/_lib_detector_access_registry.py +273 -52
- package/bin/_lib_doctor.py +302 -1
- package/bin/_lib_journal.py +145 -25
- package/bin/_lib_journal_router.py +166 -48
- package/bin/_lib_oauth_samples.py +238 -0
- package/bin/_lib_pricing_check.py +0 -12
- package/bin/_lib_provider_activity.py +770 -0
- package/bin/_lib_quarantine.py +3 -2
- package/bin/_lib_quota_model.py +96 -6
- package/bin/_lib_rebuild_progress.py +146 -0
- package/bin/_lib_record.py +22 -0
- package/bin/_lib_rederive.py +299 -3
- package/bin/_lib_snapshot_cache.py +43 -1
- package/bin/_lib_stale_proof.py +708 -0
- package/bin/_lib_statusline_candidates.py +65 -5
- package/bin/_lib_worker_launches.py +315 -0
- package/bin/cctally +6 -11
- package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
- package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
- package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
- package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
- package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +7 -2
- package/bin/_lib_merge_gate.py +0 -452
- package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
package/bin/_lib_doctor.py
CHANGED
|
@@ -479,6 +479,11 @@ class DoctorState:
|
|
|
479
479
|
# descriptor discovery plus one bounded loopback probe per live
|
|
480
480
|
# instance elsewhere. {"mode", "instances": [...]}; None = not gathered.
|
|
481
481
|
dashboard_disk_writes: Optional[dict] = None
|
|
482
|
+
# #997 D10: per detached rebuild worker ("epoch", "heal"), the read-only
|
|
483
|
+
# snapshot `_cctally_store.rebuild_worker_snapshot` gathered (run state,
|
|
484
|
+
# launch ledger, process identities probed before a re-read, pending
|
|
485
|
+
# marker). None = not gathered; the check then reads both as never run.
|
|
486
|
+
rebuild_workers: Optional[dict] = None
|
|
482
487
|
|
|
483
488
|
|
|
484
489
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -1049,7 +1054,7 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
|
|
|
1049
1054
|
# current index as a mismatch; keep this in lockstep with core
|
|
1050
1055
|
# (#496 S5b §6.1). It stays a literal because this kernel is pure and
|
|
1051
1056
|
# must not import `_cctally_core`.
|
|
1052
|
-
epoch =
|
|
1057
|
+
epoch = 1019
|
|
1053
1058
|
mismatch = uv > legacy_head and uv != epoch
|
|
1054
1059
|
return {"user_version": uv, "legacy_head": legacy_head, "epoch": epoch,
|
|
1055
1060
|
"mismatch": mismatch}
|
|
@@ -1105,6 +1110,267 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
|
|
|
1105
1110
|
)
|
|
1106
1111
|
|
|
1107
1112
|
|
|
1113
|
+
# --- #997 D10: the detached rebuild workers -------------------------------
|
|
1114
|
+
#
|
|
1115
|
+
# `rebuild_workers` is gathered read-only by `doctor_gather_state` through
|
|
1116
|
+
# `_cctally_store.rebuild_worker_snapshot` (no worker or admission flock is
|
|
1117
|
+
# ever taken): per worker the run state, the launch ledger, the process
|
|
1118
|
+
# identities probed BEFORE a re-read of both files, and the pending marker.
|
|
1119
|
+
# The kernel classifies it with `_lib_worker_launches` (read-only) — a death is
|
|
1120
|
+
# never inferred from elapsed time.
|
|
1121
|
+
|
|
1122
|
+
#: No progress write for this long reads as "no progress" (spec D10).
|
|
1123
|
+
REBUILD_STALL_SECONDS = 600
|
|
1124
|
+
#: A marker pending this long with no worker running warns.
|
|
1125
|
+
REBUILD_PENDING_SECONDS = 600
|
|
1126
|
+
#: Recovered deaths this recent are listed in `details`.
|
|
1127
|
+
REBUILD_RECENT_DEATH_SECONDS = 7 * 86400
|
|
1128
|
+
#: §5.2's resident-memory bound.
|
|
1129
|
+
REBUILD_MEMORY_BOUND_BYTES = 2 * 1024 ** 3
|
|
1130
|
+
|
|
1131
|
+
_REBUILD_NOUN = {"epoch": "stats.db upgrade rebuild",
|
|
1132
|
+
"heal": "stats.db auto-heal"}
|
|
1133
|
+
_REBUILD_CLEAN = {
|
|
1134
|
+
"epoch": frozenset({"success", "current"}),
|
|
1135
|
+
"heal": frozenset({"success", "no-request", "declined-readable"}),
|
|
1136
|
+
}
|
|
1137
|
+
_REBUILD_RETRYABLE = {
|
|
1138
|
+
"epoch": frozenset(),
|
|
1139
|
+
"heal": frozenset({"maintenance-busy", "ingest-busy"}),
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def rebuild_outcome_class(worker: str, outcome, *, decline_reason=None,
|
|
1144
|
+
finalization=None) -> str:
|
|
1145
|
+
"""``clean`` | ``retryable`` | ``failing`` (spec D10 outcome classes).
|
|
1146
|
+
|
|
1147
|
+
``declined-unconfirmed`` is clean only when the store re-probed healthy
|
|
1148
|
+
(``integrity_check_ok``). A pending finalization never lowers the primary
|
|
1149
|
+
class: it turns a clean outcome retryable and leaves a failing one
|
|
1150
|
+
failing. Any outcome not listed is failing until it is added.
|
|
1151
|
+
"""
|
|
1152
|
+
if worker == "heal" and outcome == "declined-unconfirmed":
|
|
1153
|
+
primary = ("clean" if decline_reason == "integrity_check_ok"
|
|
1154
|
+
else "failing")
|
|
1155
|
+
elif outcome in _REBUILD_CLEAN.get(worker, ()):
|
|
1156
|
+
primary = "clean"
|
|
1157
|
+
elif outcome in _REBUILD_RETRYABLE.get(worker, ()):
|
|
1158
|
+
primary = "retryable"
|
|
1159
|
+
else:
|
|
1160
|
+
primary = "failing"
|
|
1161
|
+
if primary == "clean" and finalization == "pending":
|
|
1162
|
+
return "retryable"
|
|
1163
|
+
return primary
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _rebuild_parse_iso(value) -> "dt.datetime | None":
|
|
1167
|
+
if not isinstance(value, str) or not value:
|
|
1168
|
+
return None
|
|
1169
|
+
try:
|
|
1170
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
1171
|
+
except ValueError:
|
|
1172
|
+
return None
|
|
1173
|
+
if parsed.tzinfo is None:
|
|
1174
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
1175
|
+
return parsed
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _rebuild_age_seconds(now: dt.datetime, value) -> "float | None":
|
|
1179
|
+
parsed = _rebuild_parse_iso(value)
|
|
1180
|
+
if parsed is None:
|
|
1181
|
+
return None
|
|
1182
|
+
if now.tzinfo is None:
|
|
1183
|
+
now = now.replace(tzinfo=dt.timezone.utc)
|
|
1184
|
+
return max(0.0, (now - parsed).total_seconds())
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _rebuild_minutes(seconds) -> int:
|
|
1188
|
+
return int((seconds or 0) // 60)
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _rebuild_ledger_summary(ledger: dict) -> dict:
|
|
1192
|
+
import _lib_worker_launches as wl
|
|
1193
|
+
return {
|
|
1194
|
+
"unresolved": [dict(e) for e in wl.unresolved(ledger)],
|
|
1195
|
+
"counts": dict(ledger.get("counts") or {}),
|
|
1196
|
+
"lastDeathAt": ledger.get("lastDeathAt"),
|
|
1197
|
+
"newest": dict(wl.newest(ledger) or {}) or None,
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def assess_rebuild_worker(worker: str, snapshot, now_utc) -> dict:
|
|
1202
|
+
"""One worker's D10 classification from its read-only snapshot.
|
|
1203
|
+
|
|
1204
|
+
Returns ``{"severity", "warnings": [sentence, ...], "summary", "row",
|
|
1205
|
+
"details"}``: ``warnings`` holds every warn sentence in table order and
|
|
1206
|
+
``summary`` the single sentence that describes the worker.
|
|
1207
|
+
"""
|
|
1208
|
+
import _lib_worker_launches as wl
|
|
1209
|
+
|
|
1210
|
+
noun = _REBUILD_NOUN[worker]
|
|
1211
|
+
snapshot = snapshot or {}
|
|
1212
|
+
state = snapshot.get("state")
|
|
1213
|
+
run = state.get("run") if isinstance(state, dict) else None
|
|
1214
|
+
run = run if isinstance(run, dict) else None
|
|
1215
|
+
identities = snapshot.get("identities") or {}
|
|
1216
|
+
ledger = snapshot.get("ledger") or wl.empty_ledger()
|
|
1217
|
+
# Read-only: settlements the evidence supports, never written back.
|
|
1218
|
+
classified = wl.classify(ledger, state, identities)["ledger"]
|
|
1219
|
+
pending = snapshot.get("pending") or {}
|
|
1220
|
+
warnings: list = []
|
|
1221
|
+
rows: list = []
|
|
1222
|
+
details: dict = {
|
|
1223
|
+
"state": state,
|
|
1224
|
+
"stateUnreadable": bool(snapshot.get("stateUnreadable")),
|
|
1225
|
+
"ledgerUnreadable": bool(snapshot.get("ledgerUnreadable")),
|
|
1226
|
+
"ledger": _rebuild_ledger_summary(classified),
|
|
1227
|
+
"liveness": None,
|
|
1228
|
+
"pending": pending,
|
|
1229
|
+
"recoveredDeaths": [],
|
|
1230
|
+
}
|
|
1231
|
+
if snapshot.get("gatherError"):
|
|
1232
|
+
details["gatherError"] = snapshot["gatherError"]
|
|
1233
|
+
|
|
1234
|
+
if snapshot.get("stateUnreadable"):
|
|
1235
|
+
rows.append("unreadable")
|
|
1236
|
+
warnings.append(f"the {noun} state is unreadable")
|
|
1237
|
+
|
|
1238
|
+
running = run is not None and run.get("status") == "running"
|
|
1239
|
+
running_alive = False
|
|
1240
|
+
if running:
|
|
1241
|
+
live = wl.liveness(run, identities)
|
|
1242
|
+
details["liveness"] = live
|
|
1243
|
+
if live != "gone":
|
|
1244
|
+
running_alive = True
|
|
1245
|
+
n = _rebuild_minutes(_rebuild_age_seconds(now_utc,
|
|
1246
|
+
run.get("startedAt")))
|
|
1247
|
+
idle = _rebuild_age_seconds(
|
|
1248
|
+
now_utc, run.get("progressAt") or run.get("startedAt"))
|
|
1249
|
+
if idle is not None and idle >= REBUILD_STALL_SECONDS:
|
|
1250
|
+
rows.append("stalled")
|
|
1251
|
+
warnings.append(
|
|
1252
|
+
f"a {noun} has been running for {n} minutes with no "
|
|
1253
|
+
f"progress for {_rebuild_minutes(idle)} minutes")
|
|
1254
|
+
else:
|
|
1255
|
+
rows.append("running")
|
|
1256
|
+
warnings.append(f"a {noun} has been running for {n} minutes")
|
|
1257
|
+
|
|
1258
|
+
# The last decisive launch, in ledger (launch) order: a launch not yet
|
|
1259
|
+
# started (unacknowledged, unresolved) and a `busy` loser decide nothing.
|
|
1260
|
+
decisive = None
|
|
1261
|
+
for entry in reversed(classified.get("entries") or []):
|
|
1262
|
+
outcome = entry.get("outcome")
|
|
1263
|
+
if outcome == "busy":
|
|
1264
|
+
continue
|
|
1265
|
+
if not outcome and not (isinstance(entry.get("ack"), dict)
|
|
1266
|
+
and entry["ack"].get("kind") == "started"):
|
|
1267
|
+
continue
|
|
1268
|
+
decisive = entry
|
|
1269
|
+
break
|
|
1270
|
+
finished_run = run if run is not None and run.get("status") == "finished" \
|
|
1271
|
+
else None
|
|
1272
|
+
ok_summary = None
|
|
1273
|
+
if not running_alive:
|
|
1274
|
+
# A state still `running` whose process is confirmed gone is a death:
|
|
1275
|
+
# any newer run would have replaced the slot when it started.
|
|
1276
|
+
died = bool(decisive and decisive.get("outcome") in wl.DEATHS) \
|
|
1277
|
+
or running
|
|
1278
|
+
if died:
|
|
1279
|
+
rows.append("died")
|
|
1280
|
+
warnings.append(f"the last {noun} died without finishing")
|
|
1281
|
+
details["preStart"] = bool(
|
|
1282
|
+
decisive and decisive.get("outcome") == "died-pre-start")
|
|
1283
|
+
details["launchId"] = (decisive or run or {}).get("launchId")
|
|
1284
|
+
elif decisive is not None and decisive.get("outcome") == "spawn-failed":
|
|
1285
|
+
rows.append("spawn-failed")
|
|
1286
|
+
warnings.append(f"the last {noun} failed (spawn-failed)")
|
|
1287
|
+
else:
|
|
1288
|
+
outcome = decline = finalization = peak = None
|
|
1289
|
+
if finished_run is not None and (
|
|
1290
|
+
decisive is None
|
|
1291
|
+
or decisive.get("launchId") == finished_run.get("launchId")):
|
|
1292
|
+
outcome = finished_run.get("outcome")
|
|
1293
|
+
decline = finished_run.get("declineReason")
|
|
1294
|
+
finalization = finished_run.get("finalization")
|
|
1295
|
+
peak = finished_run.get("peakResidentBytes")
|
|
1296
|
+
elif decisive is not None and str(
|
|
1297
|
+
decisive.get("outcome") or "").startswith("finished:"):
|
|
1298
|
+
outcome = decisive["outcome"][len("finished:"):]
|
|
1299
|
+
if outcome is not None:
|
|
1300
|
+
cls = rebuild_outcome_class(
|
|
1301
|
+
worker, outcome, decline_reason=decline,
|
|
1302
|
+
finalization=finalization)
|
|
1303
|
+
details["outcomeClass"] = cls
|
|
1304
|
+
if cls == "failing":
|
|
1305
|
+
rows.append("failed")
|
|
1306
|
+
warnings.append(f"the last {noun} failed ({outcome})")
|
|
1307
|
+
if isinstance(peak, (int, float)) and \
|
|
1308
|
+
peak > REBUILD_MEMORY_BOUND_BYTES:
|
|
1309
|
+
rows.append("memory")
|
|
1310
|
+
warnings.append(
|
|
1311
|
+
f"the last {noun} used {peak / 1024 ** 3:.1f} GiB of "
|
|
1312
|
+
f"memory, above its 2 GiB bound")
|
|
1313
|
+
ok_summary = f"the last {noun} finished ({outcome})"
|
|
1314
|
+
if (pending.get("present") and pending.get("indexPending") is not False):
|
|
1315
|
+
age = _rebuild_age_seconds(now_utc, pending.get("since"))
|
|
1316
|
+
if age is not None and age >= REBUILD_PENDING_SECONDS:
|
|
1317
|
+
rows.append("pending")
|
|
1318
|
+
warnings.append(
|
|
1319
|
+
f"a {noun} has been pending for {_rebuild_minutes(age)} "
|
|
1320
|
+
f"minutes with no worker running")
|
|
1321
|
+
if not wl.has_launch_capacity(classified):
|
|
1322
|
+
# The spawner withholds launches at capacity; the
|
|
1323
|
+
# pending row says why, with no new sentence.
|
|
1324
|
+
details["admission"] = "launch-capacity"
|
|
1325
|
+
details["unresolvedCount"] = len(
|
|
1326
|
+
wl.unresolved(classified))
|
|
1327
|
+
for death in (state or {}).get("deaths") or []:
|
|
1328
|
+
age = _rebuild_age_seconds(now_utc, death.get("detectedAt"))
|
|
1329
|
+
if age is not None and age <= REBUILD_RECENT_DEATH_SECONDS:
|
|
1330
|
+
details["recoveredDeaths"].append(dict(death))
|
|
1331
|
+
if ok_summary is None:
|
|
1332
|
+
ok_summary = f"no {noun} has run"
|
|
1333
|
+
severity = "warn" if warnings else "ok"
|
|
1334
|
+
details["rows"] = rows
|
|
1335
|
+
return {
|
|
1336
|
+
"severity": severity,
|
|
1337
|
+
"warnings": warnings,
|
|
1338
|
+
"summary": warnings[0] if warnings else ok_summary,
|
|
1339
|
+
"row": rows[0] if rows else ("idle" if state is None else "ok"),
|
|
1340
|
+
"details": details,
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _check_db_rebuild_worker(s: DoctorState) -> CheckResult:
|
|
1345
|
+
"""`db.rebuild_worker` (#997 D10): running, stalled, dead, failed or
|
|
1346
|
+
pending stats.db upgrade rebuilds and auto-heals."""
|
|
1347
|
+
return rebuild_worker_check(s.rebuild_workers, s.now_utc)
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def rebuild_worker_check(rebuild_workers, now_utc) -> CheckResult:
|
|
1351
|
+
"""The `db.rebuild_worker` result from the gathered worker snapshots
|
|
1352
|
+
alone. The doctor check and the dashboard's deferred frames
|
|
1353
|
+
(`rebuild_worker_envelope_value`) both build it here, so their wording
|
|
1354
|
+
and precedence cannot drift apart."""
|
|
1355
|
+
snapshots = rebuild_workers or {}
|
|
1356
|
+
assessed = {
|
|
1357
|
+
worker: assess_rebuild_worker(worker, snapshots.get(worker),
|
|
1358
|
+
now_utc)
|
|
1359
|
+
for worker in ("epoch", "heal")
|
|
1360
|
+
}
|
|
1361
|
+
warnings = [w for worker in ("epoch", "heal")
|
|
1362
|
+
for w in assessed[worker]["warnings"]]
|
|
1363
|
+
severity = _max_severity([a["severity"] for a in assessed.values()])
|
|
1364
|
+
summary = "; ".join(warnings) if warnings else "; ".join(
|
|
1365
|
+
assessed[worker]["summary"] for worker in ("epoch", "heal"))
|
|
1366
|
+
return CheckResult(
|
|
1367
|
+
id="db.rebuild_worker", title="Rebuild worker", severity=severity,
|
|
1368
|
+
summary=summary, remediation=None,
|
|
1369
|
+
details={worker: assessed[worker]["details"]
|
|
1370
|
+
for worker in ("epoch", "heal")},
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
|
|
1108
1374
|
def _check_data_latest_snapshot_age(s: DoctorState) -> CheckResult:
|
|
1109
1375
|
if s.latest_snapshot_at is None:
|
|
1110
1376
|
return CheckResult(
|
|
@@ -4547,6 +4813,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
4547
4813
|
("db.cache.file", "_check_db_cache_file"),
|
|
4548
4814
|
("db.integrity", "_check_db_integrity"),
|
|
4549
4815
|
("db.version_ahead", "_check_db_version_ahead"),
|
|
4816
|
+
("db.rebuild_worker", "_check_db_rebuild_worker"),
|
|
4550
4817
|
("db.migrations.applied", "_check_db_migrations_applied"),
|
|
4551
4818
|
("db.migrations.pending", "_check_db_migrations_pending"),
|
|
4552
4819
|
("db.lock_state", "_check_db_lock_state"),
|
|
@@ -5111,6 +5378,40 @@ def fingerprint(report: DoctorReport) -> str:
|
|
|
5111
5378
|
return f"sha1:{h}"
|
|
5112
5379
|
|
|
5113
5380
|
|
|
5381
|
+
def rebuild_worker_aggregate(checks) -> "dict | None":
|
|
5382
|
+
"""#997 D10: the dashboard aggregate's ``rebuild_worker`` value —
|
|
5383
|
+
``{severity, summary}`` from the ``db.rebuild_worker`` result when it is
|
|
5384
|
+
not ok, else ``None`` (also when the check is absent)."""
|
|
5385
|
+
for c in checks:
|
|
5386
|
+
if c.id == "db.rebuild_worker":
|
|
5387
|
+
if c.severity == "ok":
|
|
5388
|
+
return None
|
|
5389
|
+
return {"severity": c.severity, "summary": c.summary}
|
|
5390
|
+
return None
|
|
5391
|
+
|
|
5392
|
+
|
|
5393
|
+
def rebuild_worker_envelope_value(rebuild_workers, now_utc) -> "dict | None":
|
|
5394
|
+
"""#997 D10: exactly the ``rebuild_worker`` value `envelope_block` would
|
|
5395
|
+
carry for these worker snapshots, without the rest of doctor. The
|
|
5396
|
+
dashboard's deferred frames use it while every stats open defers."""
|
|
5397
|
+
return rebuild_worker_aggregate(
|
|
5398
|
+
(rebuild_worker_check(rebuild_workers, now_utc),))
|
|
5399
|
+
|
|
5400
|
+
|
|
5401
|
+
def envelope_block(report: DoctorReport) -> dict:
|
|
5402
|
+
"""The dashboard envelope's small doctor block for ``report``; the TUI
|
|
5403
|
+
precompute and the envelope's inline path both build it here, so the
|
|
5404
|
+
two producers stay byte-identical."""
|
|
5405
|
+
return {
|
|
5406
|
+
"severity": report.overall_severity,
|
|
5407
|
+
"counts": dict(report.counts),
|
|
5408
|
+
"generated_at": _iso_z(report.generated_at),
|
|
5409
|
+
"fingerprint": fingerprint(report),
|
|
5410
|
+
"rebuild_worker": rebuild_worker_aggregate(
|
|
5411
|
+
c for cat in report.categories for c in cat.checks),
|
|
5412
|
+
}
|
|
5413
|
+
|
|
5414
|
+
|
|
5114
5415
|
_GLYPH = {"ok": "✓", "warn": "⚠", "fail": "✗"}
|
|
5115
5416
|
|
|
5116
5417
|
|
package/bin/_lib_journal.py
CHANGED
|
@@ -30,6 +30,7 @@ from __future__ import annotations
|
|
|
30
30
|
import datetime as dt
|
|
31
31
|
import hashlib
|
|
32
32
|
import json
|
|
33
|
+
import sys
|
|
33
34
|
from dataclasses import dataclass
|
|
34
35
|
|
|
35
36
|
import _lib_account_protocol
|
|
@@ -222,6 +223,47 @@ def decode_line(raw: bytes) -> dict | None:
|
|
|
222
223
|
return obj
|
|
223
224
|
|
|
224
225
|
|
|
226
|
+
#: Strings up to this many characters are interned by `compact_retained`.
|
|
227
|
+
#: Keys, kinds, sources, account keys, ids and timestamps fall under it; a
|
|
228
|
+
#: long free-text value is rare enough that sharing it would buy nothing.
|
|
229
|
+
COMPACT_INTERN_MAX_CHARS = 64
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def compact_retained(value):
|
|
233
|
+
"""An equal copy of a decoded record whose keys and string values of at
|
|
234
|
+
most `COMPACT_INTERN_MAX_CHARS` characters are ``sys.intern``ed (#997 D15).
|
|
235
|
+
|
|
236
|
+
CPython's JSON decoder shares keys only within one ``loads`` call, so
|
|
237
|
+
every record a pass retains carries its own copy of every key and every
|
|
238
|
+
repeated short value: 381 k retained records took 0.83 GB decoded and
|
|
239
|
+
0.375 GB compacted on the maintainer's copy. Recursive through ``dict``
|
|
240
|
+
and ``list``; every other value (numbers, booleans, ``None``, long
|
|
241
|
+
strings) is shared unchanged. The result is a NEW record with new
|
|
242
|
+
containers, so equality, hashing, canonical encoding and mutation behave
|
|
243
|
+
exactly as on the plain record; only the object identity of equal strings
|
|
244
|
+
changes, and no consumer compares strings by identity.
|
|
245
|
+
|
|
246
|
+
Apply it immediately after decoding, before any consumer holds a
|
|
247
|
+
reference, so an identity check such as the rederive reader's
|
|
248
|
+
``records[index] is record`` only ever sees the compacted object.
|
|
249
|
+
"""
|
|
250
|
+
return _compact(value)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _compact(value, _intern=sys.intern):
|
|
254
|
+
kind = type(value)
|
|
255
|
+
if kind is dict:
|
|
256
|
+
return {
|
|
257
|
+
(_intern(key) if type(key) is str else key): _compact(item)
|
|
258
|
+
for key, item in value.items()
|
|
259
|
+
}
|
|
260
|
+
if kind is list:
|
|
261
|
+
return [_compact(item) for item in value]
|
|
262
|
+
if kind is str and len(value) <= COMPACT_INTERN_MAX_CHARS:
|
|
263
|
+
return _intern(value)
|
|
264
|
+
return value
|
|
265
|
+
|
|
266
|
+
|
|
225
267
|
# --------------------------------------------------------------------------
|
|
226
268
|
# identity
|
|
227
269
|
# --------------------------------------------------------------------------
|
|
@@ -383,11 +425,29 @@ def effects_payload_digest(payload: dict, refs: dict | None = None) -> str:
|
|
|
383
425
|
|
|
384
426
|
_WRITER_GENERATION = _lib_account_protocol.PROTOCOL_GENERATION
|
|
385
427
|
_WPG = _lib_account_protocol.WRITER_GENERATION_KEY
|
|
428
|
+
#: #797/#823/#840 (spec 4.6 B1): the protocol revision stamp, transport
|
|
429
|
+
#: metadata exactly like ``wpg``.
|
|
430
|
+
_WRITER_REVISION = _lib_account_protocol.PROTOCOL_REVISION
|
|
431
|
+
_WPR = _lib_account_protocol.REVISION_KEY
|
|
432
|
+
#: ``writer_revision`` default: the current revision when the record carries
|
|
433
|
+
#: the current writer generation, none for a previous release's shape.
|
|
434
|
+
_REVISION_DEFAULT = object()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _writer_core(core: dict, writer_generation, writer_revision) -> None:
|
|
438
|
+
if writer_generation is not None:
|
|
439
|
+
core[_WPG] = writer_generation
|
|
440
|
+
if writer_revision is _REVISION_DEFAULT:
|
|
441
|
+
writer_revision = (None if writer_generation is None
|
|
442
|
+
else _WRITER_REVISION)
|
|
443
|
+
if writer_revision is not None:
|
|
444
|
+
core[_WPR] = writer_revision
|
|
386
445
|
|
|
387
446
|
|
|
388
447
|
def make_obs(at: str, src: str, provider: str, payload: dict,
|
|
389
448
|
account: str | None = None, *,
|
|
390
|
-
writer_generation: str | None = _WRITER_GENERATION
|
|
449
|
+
writer_generation: str | None = _WRITER_GENERATION,
|
|
450
|
+
writer_revision=_REVISION_DEFAULT) -> dict:
|
|
391
451
|
"""Build a complete ``obs`` line record (raw capture; canonicalization is
|
|
392
452
|
derivation-time, never baked into the stored line — spec §4.2).
|
|
393
453
|
|
|
@@ -401,35 +461,44 @@ def make_obs(at: str, src: str, provider: str, payload: dict,
|
|
|
401
461
|
writing protocol. It is part of the content-id core, so a current record and
|
|
402
462
|
a previous-release record of the same capture are distinct lines. ``None``
|
|
403
463
|
builds the previous release's exact shape (a validator re-deriving a
|
|
404
|
-
retained pre-stamp record, or a test simulating an old writer).
|
|
464
|
+
retained pre-stamp record, or a test simulating an old writer).
|
|
465
|
+
|
|
466
|
+
``writer_revision`` (#797/#823/#840 spec 4.6 B1) is the top-level ``wpr``
|
|
467
|
+
protocol revision, also part of the content-id core. It defaults to the
|
|
468
|
+
current revision for a current-generation record and to none for a
|
|
469
|
+
previous release's shape; ``None`` with the current generation builds
|
|
470
|
+
the revision-1 (#825) shape."""
|
|
405
471
|
core = {"t": "obs", "at": at, "src": src, "provider": provider, "payload": payload}
|
|
406
472
|
if account is not None:
|
|
407
473
|
core["account"] = account
|
|
408
|
-
|
|
409
|
-
core[_WPG] = writer_generation
|
|
474
|
+
_writer_core(core, writer_generation, writer_revision)
|
|
410
475
|
return {"v": LINE_VERSION, **core, "id": content_id(core)}
|
|
411
476
|
|
|
412
477
|
|
|
413
478
|
def make_op(at: str, src: str, payload: dict, *,
|
|
414
|
-
writer_generation: str | None = _WRITER_GENERATION
|
|
479
|
+
writer_generation: str | None = _WRITER_GENERATION,
|
|
480
|
+
writer_revision=_REVISION_DEFAULT) -> dict:
|
|
415
481
|
"""Build a complete ``op`` (operator record) line — no ``provider``.
|
|
416
|
-
``writer_generation`` as in ``make_obs``."""
|
|
482
|
+
``writer_generation`` and ``writer_revision`` as in ``make_obs``."""
|
|
417
483
|
core = {"t": "op", "at": at, "src": src, "payload": payload}
|
|
418
|
-
|
|
419
|
-
core[_WPG] = writer_generation
|
|
484
|
+
_writer_core(core, writer_generation, writer_revision)
|
|
420
485
|
return {"v": LINE_VERSION, **core, "id": content_id(core)}
|
|
421
486
|
|
|
422
487
|
|
|
423
488
|
def prepare_record(record: dict) -> dict:
|
|
424
489
|
"""The single shared preparation step both append APIs apply (#825/#832
|
|
425
|
-
spec 5.1): return ``record`` carrying the current writer generation
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
490
|
+
spec 5.1): return ``record`` carrying the current writer generation and,
|
|
491
|
+
since #797/#823/#840 (spec 4.6 B1), the current protocol revision.
|
|
492
|
+
|
|
493
|
+
A content-id record (``id`` starting ``o:``) that is not yet fully
|
|
494
|
+
stamped and whose id verifies over its current core is re-identified over
|
|
495
|
+
the stamped core, exactly as the builders compute it. Every other id —
|
|
496
|
+
semantic event ids, bootstrap ids, correction batch ids, a content id
|
|
497
|
+
that does not verify — is preserved. Idempotent: a record carrying both
|
|
498
|
+
stamps is returned unchanged. Only records about to be appended pass
|
|
499
|
+
through here, so a retained record is never restamped."""
|
|
500
|
+
if (_lib_account_protocol.is_stamped(record)
|
|
501
|
+
and _lib_account_protocol.has_current_revision(record)):
|
|
433
502
|
return record
|
|
434
503
|
stamped = _lib_account_protocol.stamp(record)
|
|
435
504
|
rid = record.get("id")
|
|
@@ -448,10 +517,30 @@ def unstamped_record(record: dict) -> dict:
|
|
|
448
517
|
``prepare_record``. Drops the writer-generation stamp and, for a content-id
|
|
449
518
|
record whose id verifies over its stamped core, re-identifies it over the
|
|
450
519
|
unstamped core. Any other id is preserved. For fixtures and tests that
|
|
451
|
-
simulate a pre-stamp writer; never used on a production write path.
|
|
452
|
-
|
|
520
|
+
simulate a pre-stamp writer; never used on a production write path.
|
|
521
|
+
The protocol revision (``wpr``) goes with the stamp."""
|
|
522
|
+
if not isinstance(record, dict) or (
|
|
523
|
+
_WPG not in record and _WPR not in record):
|
|
524
|
+
return record
|
|
525
|
+
out = {k: v for k, v in record.items() if k not in (_WPG, _WPR)}
|
|
526
|
+
rid = record.get("id")
|
|
527
|
+
if (record.get("t") in ("obs", "op") and isinstance(rid, str)
|
|
528
|
+
and rid.startswith("o:")):
|
|
529
|
+
core = {k: v for k, v in record.items() if k not in ("v", "id")}
|
|
530
|
+
if content_id(core) == rid:
|
|
531
|
+
out["id"] = content_id(
|
|
532
|
+
{k: v for k, v in out.items() if k not in ("v", "id")})
|
|
533
|
+
return out
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def prerevision_record(record: dict) -> dict:
|
|
537
|
+
"""The revision-1 (#825 release) shape of ``record``: the current writer
|
|
538
|
+
generation without the protocol revision, re-identified like
|
|
539
|
+
``unstamped_record``. For fixtures and tests that simulate the previous
|
|
540
|
+
release; never used on a production write path."""
|
|
541
|
+
if not isinstance(record, dict) or _WPR not in record:
|
|
453
542
|
return record
|
|
454
|
-
out = {k: v for k, v in record.items() if k !=
|
|
543
|
+
out = {k: v for k, v in record.items() if k != _WPR}
|
|
455
544
|
rid = record.get("id")
|
|
456
545
|
if (record.get("t") in ("obs", "op") and isinstance(rid, str)
|
|
457
546
|
and rid.startswith("o:")):
|
|
@@ -510,11 +599,15 @@ def event_content_hash(record: dict) -> str:
|
|
|
510
599
|
#: The previous release's exact-key repair acknowledgement. The current binary
|
|
511
600
|
#: never emits it: its validator would reject the writer-generation stamp, so
|
|
512
601
|
#: current acknowledgements use the versioned kind the previous release ignores
|
|
513
|
-
#: as an unknown op (#825/#832 spec 5.1). Current readers accept
|
|
602
|
+
#: as an unknown op (#825/#832 spec 5.1). Current readers accept every kind.
|
|
514
603
|
_PROTOCOL_RESOLUTION_KIND = "journal_protocol_resolution"
|
|
515
604
|
_PROTOCOL_RESOLUTION_V2_KIND = _lib_account_protocol.PROTOCOL_RESOLUTION_V2_KIND
|
|
605
|
+
#: #797/#823/#840 (spec 4.6 B5): the previous release's v2 validator rejects
|
|
606
|
+
#: the protocol revision stamp, so the current binary emits this successor.
|
|
607
|
+
PROTOCOL_RESOLUTION_V3_KIND = _lib_account_protocol.PROTOCOL_RESOLUTION_V3_KIND
|
|
516
608
|
PROTOCOL_RESOLUTION_KINDS = frozenset((
|
|
517
|
-
_PROTOCOL_RESOLUTION_KIND, _PROTOCOL_RESOLUTION_V2_KIND
|
|
609
|
+
_PROTOCOL_RESOLUTION_KIND, _PROTOCOL_RESOLUTION_V2_KIND,
|
|
610
|
+
PROTOCOL_RESOLUTION_V3_KIND))
|
|
518
611
|
_PROTOCOL_RESOLUTION_SRC = "journal-repair"
|
|
519
612
|
|
|
520
613
|
|
|
@@ -554,7 +647,7 @@ def make_protocol_resolution(
|
|
|
554
647
|
at=at,
|
|
555
648
|
src=_PROTOCOL_RESOLUTION_SRC,
|
|
556
649
|
payload={
|
|
557
|
-
"kind":
|
|
650
|
+
"kind": PROTOCOL_RESOLUTION_V3_KIND,
|
|
558
651
|
"violations": refs,
|
|
559
652
|
"journal_high_water": {
|
|
560
653
|
"segment": segment,
|
|
@@ -588,7 +681,7 @@ def make_evt(kind: str, id: str, at: str, payload: dict, rev: int = 0) -> dict:
|
|
|
588
681
|
body["kind"] = kind
|
|
589
682
|
return {"v": LINE_VERSION, "t": "evt", "id": id, "rev": rev,
|
|
590
683
|
"at": at, "src": "ingest", "payload": body,
|
|
591
|
-
_WPG: _WRITER_GENERATION}
|
|
684
|
+
_WPG: _WRITER_GENERATION, _WPR: _WRITER_REVISION}
|
|
592
685
|
|
|
593
686
|
|
|
594
687
|
# --------------------------------------------------------------------------
|
|
@@ -627,8 +720,10 @@ def _protocol_violation(
|
|
|
627
720
|
def _validate_protocol_resolution(record: dict) -> dict:
|
|
628
721
|
required = {"v", "t", "id", "at", "src", "payload"}
|
|
629
722
|
# #825/#832: a current record also carries the writer-generation stamp,
|
|
630
|
-
# which is part of its content-id core
|
|
631
|
-
|
|
723
|
+
# which is part of its content-id core; #797/#823/#840 adds the protocol
|
|
724
|
+
# revision (spec 4.6 B5).
|
|
725
|
+
if set(record) not in (required, required | {_WPG},
|
|
726
|
+
required | {_WPG, _WPR}):
|
|
632
727
|
raise JournalProtocolError(
|
|
633
728
|
"journal protocol resolution record shape is invalid"
|
|
634
729
|
)
|
|
@@ -650,6 +745,8 @@ def _validate_protocol_resolution(record: dict) -> dict:
|
|
|
650
745
|
}
|
|
651
746
|
if _WPG in record:
|
|
652
747
|
core[_WPG] = record[_WPG]
|
|
748
|
+
if _WPR in record:
|
|
749
|
+
core[_WPR] = record[_WPR]
|
|
653
750
|
if record.get("id") != content_id(core):
|
|
654
751
|
raise JournalProtocolError(
|
|
655
752
|
"journal protocol resolution record id does not match its content"
|
|
@@ -802,6 +899,7 @@ def make_correction_batch(
|
|
|
802
899
|
"action_count": len(cores),
|
|
803
900
|
"actions_hash": actions_hash,
|
|
804
901
|
_WPG: _WRITER_GENERATION,
|
|
902
|
+
_WPR: _WRITER_REVISION,
|
|
805
903
|
}
|
|
806
904
|
records = [{**marker, "phase": "begin"}]
|
|
807
905
|
for seq, core in enumerate(cores):
|
|
@@ -813,12 +911,34 @@ def make_correction_batch(
|
|
|
813
911
|
"batch": batch_id,
|
|
814
912
|
"seq": seq,
|
|
815
913
|
_WPG: _WRITER_GENERATION,
|
|
914
|
+
_WPR: _WRITER_REVISION,
|
|
816
915
|
}
|
|
817
916
|
)
|
|
818
917
|
records.append({**marker, "phase": "commit"})
|
|
819
918
|
return records
|
|
820
919
|
|
|
821
920
|
|
|
921
|
+
def revision_batch_id(batch_id: str, begins) -> str:
|
|
922
|
+
"""The batch id a retry of ``batch_id`` appends under (#797/#823/#840
|
|
923
|
+
spec 4.6 B1).
|
|
924
|
+
|
|
925
|
+
``begins`` are the retained begin markers already journalled under
|
|
926
|
+
``batch_id``. A retry reuses the id only when no begin exists or one
|
|
927
|
+
carries the current revision stamp. An interrupted batch an earlier
|
|
928
|
+
writer began without that stamp is never restamped: a revision-2 copy of
|
|
929
|
+
its lines would be a marker conflict under the original id (the fold
|
|
930
|
+
hashes markers byte-exactly), and a copy without the stamp would land
|
|
931
|
+
after the revision boundary, where admission excludes it. Its plan is
|
|
932
|
+
therefore authored afresh under a derived id, and the original begin
|
|
933
|
+
stays the inert, incomplete batch it was."""
|
|
934
|
+
begins = list(begins)
|
|
935
|
+
if not begins or any(
|
|
936
|
+
_lib_account_protocol.has_current_revision(begin)
|
|
937
|
+
for begin in begins):
|
|
938
|
+
return batch_id
|
|
939
|
+
return f"{batch_id}:wpr{_lib_account_protocol.PROTOCOL_REVISION}"
|
|
940
|
+
|
|
941
|
+
|
|
822
942
|
def _validate_batch_marker(record: dict) -> dict:
|
|
823
943
|
batch_id = record.get("id")
|
|
824
944
|
if not isinstance(batch_id, str) or not batch_id:
|