cctally 1.79.2 → 1.80.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 +20 -0
- package/bin/_cctally_cache.py +148 -61
- package/bin/_cctally_config.py +23 -6
- package/bin/_cctally_core.py +342 -88
- package/bin/_cctally_dashboard.py +13 -7
- package/bin/_cctally_db.py +361 -36
- package/bin/_cctally_doctor.py +156 -0
- package/bin/_cctally_five_hour.py +19 -7
- package/bin/_cctally_forecast.py +43 -18
- package/bin/_cctally_journal.py +2148 -0
- package/bin/_cctally_milestones.py +116 -26
- package/bin/_cctally_parser.py +21 -3
- package/bin/_cctally_quota.py +195 -76
- package/bin/_cctally_record.py +1596 -1133
- package/bin/_cctally_statusline.py +13 -1
- package/bin/_cctally_store.py +505 -0
- package/bin/_cctally_sync_week.py +139 -2
- package/bin/_lib_doctor.py +209 -12
- package/bin/_lib_journal.py +188 -0
- package/bin/cctally +11 -1
- package/dashboard/static/assets/index-Cc2ykqF_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-CrIlpAlQ.css +0 -1
- /package/dashboard/static/assets/{index-BDkr0bdD.js → index-B3y14l1o.js} +0 -0
|
@@ -43,15 +43,49 @@ from _cctally_core import (
|
|
|
43
43
|
format_local_iso,
|
|
44
44
|
make_week_ref,
|
|
45
45
|
get_latest_usage_for_week,
|
|
46
|
+
now_utc_iso,
|
|
46
47
|
)
|
|
47
48
|
|
|
48
49
|
|
|
49
|
-
def cmd_sync_week(
|
|
50
|
+
def cmd_sync_week(
|
|
51
|
+
args: argparse.Namespace, *, conn=None, as_of: "str | None" = None,
|
|
52
|
+
journal: "tuple | None" = None,
|
|
53
|
+
) -> int:
|
|
54
|
+
"""Compute + persist the week's cost snapshot.
|
|
55
|
+
|
|
56
|
+
Transaction-neutral / capture-time-pure seam (DB journal redesign §5.2.3):
|
|
57
|
+
``conn`` runs the cost-snapshot insert on the caller's connection (no
|
|
58
|
+
internal open/commit/close), and ``as_of`` (ISO-Z) stamps the snapshot's
|
|
59
|
+
``captured_at_utc``. Both defaults keep the legacy own-connection,
|
|
60
|
+
wall-clock behavior — so `record`/`maybe_record_milestone`'s existing
|
|
61
|
+
bare `cmd_sync_week(ns)` calls are byte-identical.
|
|
62
|
+
|
|
63
|
+
Design A (DB journal redesign §5.3, Model-A ``weekly_cost_snapshot``):
|
|
64
|
+
``journal=(ctx, id_base)`` routes the ``insert_cost_snapshot`` THROUGH
|
|
65
|
+
``emit_model_a`` so the computed cost rides a journaled evt
|
|
66
|
+
(``wcs:<id_base>:<week>``) and replay reads it back verbatim rather than
|
|
67
|
+
recomputing from the (pruned) provider JSONL. Threaded by the
|
|
68
|
+
milestone-cost-sync (id_base = the obs line id) and the sync-week op fold
|
|
69
|
+
(id_base = the op line id). Default ``None`` keeps the bare insert.
|
|
70
|
+
|
|
71
|
+
6f writer reroute (Appendix A): the pure-CLI / bare-call path (``conn`` is
|
|
72
|
+
None AND ``journal`` is None — the ``sync-week`` subcommand entry and the
|
|
73
|
+
``report --sync-current`` lazy-sync) no longer computes + inserts on its own
|
|
74
|
+
connection. It appends a ``sync_week`` ``op`` line and runs an AUTHORITATIVE
|
|
75
|
+
ingest — ``_pipeline_sync_week`` re-derives args from the op payload and
|
|
76
|
+
re-enters THIS function with ``conn``+``journal`` set (the inline body below),
|
|
77
|
+
which computes the cost on the cycle connection and journals the
|
|
78
|
+
``weekly_cost_snapshots`` row — then reads that row back for its output. The
|
|
79
|
+
passed-conn / journal callers stay inline and byte-identical."""
|
|
50
80
|
c = _cctally()
|
|
81
|
+
if conn is None and journal is None:
|
|
82
|
+
return _cmd_sync_week_via_ingest(c, args)
|
|
51
83
|
config = c.load_config()
|
|
52
84
|
week_start_name = get_week_start_name(config, args.week_start_name)
|
|
53
85
|
|
|
54
|
-
|
|
86
|
+
own_conn = conn is None
|
|
87
|
+
if own_conn:
|
|
88
|
+
conn = open_db()
|
|
55
89
|
try:
|
|
56
90
|
selection = c.pick_week_selection(
|
|
57
91
|
conn,
|
|
@@ -83,6 +117,9 @@ def cmd_sync_week(args: argparse.Namespace) -> int:
|
|
|
83
117
|
cost_usd=result.cost_usd,
|
|
84
118
|
mode=args.mode,
|
|
85
119
|
project=args.project,
|
|
120
|
+
commit=own_conn,
|
|
121
|
+
as_of=as_of,
|
|
122
|
+
journal=journal,
|
|
86
123
|
)
|
|
87
124
|
|
|
88
125
|
week_ref = make_week_ref(
|
|
@@ -110,6 +147,106 @@ def cmd_sync_week(args: argparse.Namespace) -> int:
|
|
|
110
147
|
"dollarsPerPercent": round(dollars_per_percent, 9) if dollars_per_percent is not None else None,
|
|
111
148
|
}
|
|
112
149
|
|
|
150
|
+
if args.json:
|
|
151
|
+
print(json.dumps(c.stamp_schema_version(payload), indent=2))
|
|
152
|
+
elif not args.quiet:
|
|
153
|
+
print(
|
|
154
|
+
f"Synced week {payload['weekStartDate']}..{payload['weekEndDate']} "
|
|
155
|
+
f"=> ${payload['costUSD']:.6f}"
|
|
156
|
+
)
|
|
157
|
+
if weekly_percent is not None:
|
|
158
|
+
print(f"Latest weekly usage: {weekly_percent:.2f}%")
|
|
159
|
+
if dollars_per_percent is not None:
|
|
160
|
+
print(f"$ per 1% usage: ${dollars_per_percent:.6f}")
|
|
161
|
+
return 0
|
|
162
|
+
finally:
|
|
163
|
+
if own_conn:
|
|
164
|
+
conn.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _cmd_sync_week_via_ingest(c, args: argparse.Namespace) -> int:
|
|
168
|
+
"""CLI / bare-call sync-week (6f writer reroute): append a ``sync_week`` op
|
|
169
|
+
+ AUTHORITATIVE ingest, then read the journaled ``weekly_cost_snapshots`` row
|
|
170
|
+
back for output. The op carries the raw ``sync-week`` args; the
|
|
171
|
+
``_pipeline_sync_week`` hook re-enters ``cmd_sync_week`` with the cycle
|
|
172
|
+
connection to compute + journal the cost (Model-A ``wcs:<op id>:<week>``).
|
|
173
|
+
|
|
174
|
+
The command observes its own write synchronously (authoritative ingest, then
|
|
175
|
+
a fresh read-back). Exit codes stay byte-identical: a genuine compute/insert
|
|
176
|
+
failure propagates out of the authoritative ingest exactly as the old inline
|
|
177
|
+
path let ``compute_week_cost``/``insert_cost_snapshot`` raise, and the CLI
|
|
178
|
+
dispatch maps it to the same exit code."""
|
|
179
|
+
import _cctally_journal as _jr
|
|
180
|
+
import _lib_journal as _lj
|
|
181
|
+
|
|
182
|
+
op = _lj.make_op(
|
|
183
|
+
at=now_utc_iso(),
|
|
184
|
+
src="sync-week",
|
|
185
|
+
payload={
|
|
186
|
+
"kind": "sync_week",
|
|
187
|
+
"week_start": args.week_start,
|
|
188
|
+
"week_end": args.week_end,
|
|
189
|
+
"week_start_name": args.week_start_name,
|
|
190
|
+
"mode": args.mode,
|
|
191
|
+
"offline": args.offline,
|
|
192
|
+
"project": args.project,
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
_jr.append_record(op)
|
|
196
|
+
res = _jr.run_stats_ingest(mode="authoritative")
|
|
197
|
+
# authoritative blocks up to the ingest-lock timeout; on the rare timeout
|
|
198
|
+
# (busy lock) it returns ran=False without consuming the op — retry once so
|
|
199
|
+
# the CLI observes its own write.
|
|
200
|
+
if not res.ran:
|
|
201
|
+
res = _jr.run_stats_ingest(mode="authoritative")
|
|
202
|
+
|
|
203
|
+
conn = open_db()
|
|
204
|
+
try:
|
|
205
|
+
row = conn.execute(
|
|
206
|
+
"SELECT id, week_start_date, week_end_date, week_start_at, "
|
|
207
|
+
" week_end_at, range_start_iso, range_end_iso, cost_usd, mode "
|
|
208
|
+
" FROM weekly_cost_snapshots "
|
|
209
|
+
" WHERE journal_id LIKE ? "
|
|
210
|
+
" ORDER BY id DESC LIMIT 1",
|
|
211
|
+
(f"wcs:{op['id']}:%",),
|
|
212
|
+
).fetchone()
|
|
213
|
+
if row is None:
|
|
214
|
+
# The op did not journal a cost row (only reachable if the ingest
|
|
215
|
+
# never ran — a sustained ingest-lock timeout). Surface it rather
|
|
216
|
+
# than printing a phantom success.
|
|
217
|
+
import sys
|
|
218
|
+
print(
|
|
219
|
+
"sync-week: cost ingest did not run (ingest lock busy); retry",
|
|
220
|
+
file=sys.stderr,
|
|
221
|
+
)
|
|
222
|
+
return 3
|
|
223
|
+
|
|
224
|
+
(row_id, wsd, wed, wsa, wea, r_start, r_end, cost_usd, _mode) = row
|
|
225
|
+
week_ref = make_week_ref(
|
|
226
|
+
week_start_date=wsd,
|
|
227
|
+
week_end_date=wed,
|
|
228
|
+
week_start_at=wsa,
|
|
229
|
+
week_end_at=wea,
|
|
230
|
+
)
|
|
231
|
+
usage_row = get_latest_usage_for_week(conn, week_ref)
|
|
232
|
+
weekly_percent = float(usage_row["weekly_percent"]) if usage_row else None
|
|
233
|
+
dollars_per_percent = (
|
|
234
|
+
cost_usd / weekly_percent if weekly_percent and weekly_percent > 0 else None
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
payload = {
|
|
238
|
+
"id": row_id,
|
|
239
|
+
"weekStartDate": wsd,
|
|
240
|
+
"weekEndDate": wed,
|
|
241
|
+
"weekStartAt": wsa,
|
|
242
|
+
"weekEndAt": wea,
|
|
243
|
+
"rangeStartIso": r_start,
|
|
244
|
+
"rangeEndIso": r_end,
|
|
245
|
+
"costUSD": round(cost_usd, 9),
|
|
246
|
+
"weeklyPercent": weekly_percent,
|
|
247
|
+
"dollarsPerPercent": round(dollars_per_percent, 9) if dollars_per_percent is not None else None,
|
|
248
|
+
}
|
|
249
|
+
|
|
113
250
|
if args.json:
|
|
114
251
|
print(json.dumps(c.stamp_schema_version(payload), indent=2))
|
|
115
252
|
elif not args.quiet:
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -241,6 +241,37 @@ class DoctorState:
|
|
|
241
241
|
# check (WARN on beta+brew, else OK). Defaulted (placed last) so existing
|
|
242
242
|
# constructors stay valid and default to the stable posture.
|
|
243
243
|
update_channel: str = "stable"
|
|
244
|
+
# DB journal redesign §9: the append-only journal legs. All gathered by
|
|
245
|
+
# `doctor_gather_state` (read-only), defaulted (tail) so existing
|
|
246
|
+
# constructors stay valid and a pre-cutover install (no journal dir) reads
|
|
247
|
+
# the always-OK "no journal" posture.
|
|
248
|
+
# * journal_present — the journal/ dir exists (a cut-over install has one;
|
|
249
|
+
# a legacy pre-cutover install does NOT — that is INFO, never FAIL).
|
|
250
|
+
# * journal_appendable — the dir is writable (os.access W_OK); None when
|
|
251
|
+
# absent or the probe errored.
|
|
252
|
+
# * journal_segment_count — number of segments (bootstrap + monthly).
|
|
253
|
+
# * journal_malformed_count / journal_torn_tail_count — mid-file malformed
|
|
254
|
+
# lines (external damage → WARN) and torn final lines (a known crash
|
|
255
|
+
# artifact healed by the next append → INFO). None = not scanned (the
|
|
256
|
+
# scan is `deep`-gated, like the quick_check legs, so the dashboard's
|
|
257
|
+
# per-rebuild gather never reads the whole journal).
|
|
258
|
+
# * journal_cursor_lag_bytes — unconsumed bytes between the stats index
|
|
259
|
+
# cursor and the journal high-water. None when there is no journal /
|
|
260
|
+
# cursor / the DB could not be read.
|
|
261
|
+
# * journal_hw_segment / journal_cursor_segment — the high-water segment
|
|
262
|
+
# and the cursor's segment, for the verbose detail.
|
|
263
|
+
# * journal_heal_incidents — most-recent-first list of the auto-heal
|
|
264
|
+
# artifacts (quarantine/ dirs + logs/<db>-corruption-forensics-*.json);
|
|
265
|
+
# each dict carries {kind, name, age_s}. None = the dirs were unreadable.
|
|
266
|
+
journal_present: bool = False
|
|
267
|
+
journal_appendable: Optional[bool] = None
|
|
268
|
+
journal_segment_count: int = 0
|
|
269
|
+
journal_malformed_count: Optional[int] = None
|
|
270
|
+
journal_torn_tail_count: Optional[int] = None
|
|
271
|
+
journal_cursor_lag_bytes: Optional[int] = None
|
|
272
|
+
journal_hw_segment: Optional[str] = None
|
|
273
|
+
journal_cursor_segment: Optional[str] = None
|
|
274
|
+
journal_heal_incidents: Optional[list] = None
|
|
244
275
|
|
|
245
276
|
|
|
246
277
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -731,29 +762,58 @@ def _check_db_migrations_pending(s: DoctorState) -> CheckResult:
|
|
|
731
762
|
|
|
732
763
|
|
|
733
764
|
def _check_db_version_ahead(s: DoctorState) -> CheckResult:
|
|
734
|
-
"""
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
765
|
+
"""Classify each DB's ``user_version`` versus what this binary expects.
|
|
766
|
+
|
|
767
|
+
stats.db follows the EPOCH model (DB journal redesign §7.1): it is a
|
|
768
|
+
DISPOSABLE index stamped at ``STATS_INDEX_EPOCH`` (injected as ``epoch``
|
|
769
|
+
into ``stats_db_status`` by the gather layer), NOT a versioned migration
|
|
770
|
+
target. Classification:
|
|
771
|
+
* ``uv == epoch`` (a cut-over install) → HEALTHY (steady state)
|
|
772
|
+
* ``uv <= legacy_head`` (pre-cutover, ≤13) → HEALTHY (cuts over on open)
|
|
773
|
+
* ``uv > legacy_head`` AND ``!= epoch`` → §7.1 index MISMATCH: WARN.
|
|
774
|
+
It self-heals by journal REBUILD on the next open (never bricks, unlike
|
|
775
|
+
the retired #145 version-ahead FAIL), so the remediation points at
|
|
776
|
+
`db rebuild --db stats`, NOT the retired `db recover --db stats`.
|
|
777
|
+
|
|
778
|
+
cache.db is unchanged (issue #145): a ``user_version`` past the cache
|
|
779
|
+
registry head auto-heals on the next open → WARN. doctor reads raw
|
|
780
|
+
``user_version`` (no dispatcher), so it reports without healing/bricking.
|
|
738
781
|
"""
|
|
739
|
-
def
|
|
782
|
+
def _eval_stats(status):
|
|
783
|
+
if not status:
|
|
784
|
+
return None
|
|
785
|
+
uv = status.get("user_version", 0) or 0
|
|
786
|
+
legacy_head = status.get("registry_size", 0) or 0 # frozen stats head (13)
|
|
787
|
+
epoch = status.get("epoch")
|
|
788
|
+
if epoch is None:
|
|
789
|
+
# Fallback kept in lockstep with _cctally_core.STATS_INDEX_EPOCH; the
|
|
790
|
+
# gather layer injects the real constant, so this only guards a hand-
|
|
791
|
+
# built DoctorState that omitted it.
|
|
792
|
+
epoch = 1000
|
|
793
|
+
mismatch = uv > legacy_head and uv != epoch
|
|
794
|
+
return {"user_version": uv, "legacy_head": legacy_head, "epoch": epoch,
|
|
795
|
+
"mismatch": mismatch}
|
|
796
|
+
|
|
797
|
+
def _eval_cache(status):
|
|
740
798
|
if not status:
|
|
741
799
|
return None
|
|
742
800
|
uv = status.get("user_version", 0) or 0
|
|
743
801
|
rs = status.get("registry_size", 0) or 0
|
|
744
802
|
return {"user_version": uv, "registry_size": rs, "ahead": uv > rs}
|
|
745
803
|
|
|
746
|
-
stats =
|
|
747
|
-
cache =
|
|
804
|
+
stats = _eval_stats(s.stats_db_status)
|
|
805
|
+
cache = _eval_cache(s.cache_db_status)
|
|
748
806
|
details = {"stats.db": stats, "cache.db": cache}
|
|
749
|
-
|
|
807
|
+
stats_mismatch = bool(stats and stats["mismatch"])
|
|
750
808
|
cache_ahead = bool(cache and cache["ahead"])
|
|
751
809
|
|
|
752
|
-
if
|
|
810
|
+
if stats_mismatch:
|
|
753
811
|
return CheckResult(
|
|
754
|
-
id="db.version_ahead", title="Version ahead", severity="
|
|
755
|
-
summary=f"stats.db
|
|
756
|
-
|
|
812
|
+
id="db.version_ahead", title="Version ahead", severity="warn",
|
|
813
|
+
summary=(f"stats.db index mismatch (v{stats['user_version']} ≠ epoch "
|
|
814
|
+
f"v{stats['epoch']}) — rebuilds from journal"),
|
|
815
|
+
remediation=("Auto-heals by journal rebuild on next command; if it "
|
|
816
|
+
"persists, run `cctally db rebuild --db stats`"),
|
|
757
817
|
details=details,
|
|
758
818
|
)
|
|
759
819
|
if cache_ahead:
|
|
@@ -1858,6 +1918,137 @@ def _check_db_conversations_reclaimable(s: DoctorState) -> CheckResult:
|
|
|
1858
1918
|
)
|
|
1859
1919
|
|
|
1860
1920
|
|
|
1921
|
+
# ── DB journal redesign §9 — append-only journal legs ────────────────────
|
|
1922
|
+
# A monthly segment is MB-scale (§4.5), so a multi-MB unconsumed cursor gap
|
|
1923
|
+
# means no ingest cycle has run for a long stretch. An auto-heal incident within
|
|
1924
|
+
# a week is worth surfacing loudly (the DB corrupted and self-healed).
|
|
1925
|
+
_JOURNAL_CURSOR_LAG_WARN_BYTES = 4 * 1024 * 1024
|
|
1926
|
+
_JOURNAL_HEAL_RECENT_SECONDS = 7 * 24 * 3600
|
|
1927
|
+
|
|
1928
|
+
|
|
1929
|
+
def _check_journal_presence(s: DoctorState) -> CheckResult:
|
|
1930
|
+
"""The journal directory exists and is appendable. A pre-cutover (legacy)
|
|
1931
|
+
install has NO journal yet — that is INFO (OK), never a FAIL."""
|
|
1932
|
+
if not s.journal_present:
|
|
1933
|
+
return CheckResult(
|
|
1934
|
+
id="journal.presence", title="Journal", severity="ok",
|
|
1935
|
+
summary="no journal (pre-cutover install)", remediation=None,
|
|
1936
|
+
details={"present": False},
|
|
1937
|
+
)
|
|
1938
|
+
details = {"present": True, "appendable": s.journal_appendable,
|
|
1939
|
+
"segments": s.journal_segment_count}
|
|
1940
|
+
if s.journal_appendable is False:
|
|
1941
|
+
return CheckResult(
|
|
1942
|
+
id="journal.presence", title="Journal", severity="warn",
|
|
1943
|
+
summary="journal directory not writable",
|
|
1944
|
+
remediation="Fix permissions on ~/.local/share/cctally/journal/",
|
|
1945
|
+
details=details,
|
|
1946
|
+
)
|
|
1947
|
+
return CheckResult(
|
|
1948
|
+
id="journal.presence", title="Journal", severity="ok",
|
|
1949
|
+
summary=f"{s.journal_segment_count} segment(s), writable",
|
|
1950
|
+
remediation=None, details=details,
|
|
1951
|
+
)
|
|
1952
|
+
|
|
1953
|
+
|
|
1954
|
+
def _check_journal_integrity(s: DoctorState) -> CheckResult:
|
|
1955
|
+
"""Mid-file malformed lines are external damage (WARN); a torn final line is
|
|
1956
|
+
a known crash artifact healed by the next append (INFO). The scan is
|
|
1957
|
+
deep-gated — a None count means "not scanned", always OK."""
|
|
1958
|
+
if not s.journal_present:
|
|
1959
|
+
return CheckResult(
|
|
1960
|
+
id="journal.integrity", title="Journal integrity", severity="ok",
|
|
1961
|
+
summary="no journal", remediation=None, details={"present": False},
|
|
1962
|
+
)
|
|
1963
|
+
if s.journal_malformed_count is None:
|
|
1964
|
+
return CheckResult(
|
|
1965
|
+
id="journal.integrity", title="Journal integrity", severity="ok",
|
|
1966
|
+
summary="not scanned", remediation=None, details={"scanned": False},
|
|
1967
|
+
)
|
|
1968
|
+
torn = s.journal_torn_tail_count or 0
|
|
1969
|
+
details = {"malformed": s.journal_malformed_count, "torn_tail": torn}
|
|
1970
|
+
if s.journal_malformed_count > 0:
|
|
1971
|
+
return CheckResult(
|
|
1972
|
+
id="journal.integrity", title="Journal integrity", severity="warn",
|
|
1973
|
+
summary=f"{s.journal_malformed_count} malformed line(s)",
|
|
1974
|
+
remediation=("External damage to a journal segment — inspect "
|
|
1975
|
+
"~/.local/share/cctally/journal/ (every other line stays "
|
|
1976
|
+
"parseable; the ingester skips + counts the bad ones)"),
|
|
1977
|
+
details=details,
|
|
1978
|
+
)
|
|
1979
|
+
if torn > 0:
|
|
1980
|
+
return CheckResult(
|
|
1981
|
+
id="journal.integrity", title="Journal integrity", severity="ok",
|
|
1982
|
+
summary=f"{torn} torn tail (heals on next append)",
|
|
1983
|
+
remediation=None, details=details,
|
|
1984
|
+
)
|
|
1985
|
+
return CheckResult(
|
|
1986
|
+
id="journal.integrity", title="Journal integrity", severity="ok",
|
|
1987
|
+
summary="no malformed lines", remediation=None, details=details,
|
|
1988
|
+
)
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
def _check_journal_index_freshness(s: DoctorState) -> CheckResult:
|
|
1992
|
+
"""The stats index cursor vs. the journal high-water. A large unconsumed gap
|
|
1993
|
+
→ WARN; a small gap or caught-up cursor → OK (with the gap shown)."""
|
|
1994
|
+
if not s.journal_present or s.journal_cursor_lag_bytes is None:
|
|
1995
|
+
return CheckResult(
|
|
1996
|
+
id="journal.index_freshness", title="Journal index", severity="ok",
|
|
1997
|
+
summary="no cursor yet", remediation=None,
|
|
1998
|
+
details={"lag_bytes": None},
|
|
1999
|
+
)
|
|
2000
|
+
lag = s.journal_cursor_lag_bytes
|
|
2001
|
+
details = {"lag_bytes": lag, "hw_segment": s.journal_hw_segment,
|
|
2002
|
+
"cursor_segment": s.journal_cursor_segment,
|
|
2003
|
+
"warn_bytes": _JOURNAL_CURSOR_LAG_WARN_BYTES}
|
|
2004
|
+
if lag == 0:
|
|
2005
|
+
return CheckResult(
|
|
2006
|
+
id="journal.index_freshness", title="Journal index", severity="ok",
|
|
2007
|
+
summary="index caught up", remediation=None, details=details,
|
|
2008
|
+
)
|
|
2009
|
+
if lag > _JOURNAL_CURSOR_LAG_WARN_BYTES:
|
|
2010
|
+
return CheckResult(
|
|
2011
|
+
id="journal.index_freshness", title="Journal index", severity="warn",
|
|
2012
|
+
summary=f"index {lag:,} bytes behind journal",
|
|
2013
|
+
remediation=("Run any cctally command (or `cctally db rebuild --db "
|
|
2014
|
+
"stats`) — the ingester consumes the backlog"),
|
|
2015
|
+
details=details,
|
|
2016
|
+
)
|
|
2017
|
+
return CheckResult(
|
|
2018
|
+
id="journal.index_freshness", title="Journal index", severity="ok",
|
|
2019
|
+
summary=f"index {lag:,} bytes behind (within threshold)",
|
|
2020
|
+
remediation=None, details=details,
|
|
2021
|
+
)
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
def _check_journal_auto_heal(s: DoctorState) -> CheckResult:
|
|
2025
|
+
"""The most recent auto-heal incident (quarantine dir + forensics bundle).
|
|
2026
|
+
INFO listing the latest; WARN when it fired within the last 7 days."""
|
|
2027
|
+
incidents = s.journal_heal_incidents
|
|
2028
|
+
if not incidents:
|
|
2029
|
+
return CheckResult(
|
|
2030
|
+
id="journal.auto_heal", title="Auto-heal", severity="ok",
|
|
2031
|
+
summary="no auto-heal incidents", remediation=None,
|
|
2032
|
+
details={"incidents": 0},
|
|
2033
|
+
)
|
|
2034
|
+
latest = incidents[0]
|
|
2035
|
+
age_s = latest.get("age_s")
|
|
2036
|
+
name = latest.get("name", "?")
|
|
2037
|
+
details = {"incidents": len(incidents), "latest": latest}
|
|
2038
|
+
if age_s is not None and age_s <= _JOURNAL_HEAL_RECENT_SECONDS:
|
|
2039
|
+
return CheckResult(
|
|
2040
|
+
id="journal.auto_heal", title="Auto-heal", severity="warn",
|
|
2041
|
+
summary=f"auto-heal fired recently ({name}, {age_s // 86400}d ago)",
|
|
2042
|
+
remediation=("A DB corrupted and self-healed — inspect the forensics "
|
|
2043
|
+
"bundle in ~/.local/share/cctally/logs/"),
|
|
2044
|
+
details=details,
|
|
2045
|
+
)
|
|
2046
|
+
return CheckResult(
|
|
2047
|
+
id="journal.auto_heal", title="Auto-heal", severity="ok",
|
|
2048
|
+
summary=f"last incident {name}", remediation=None, details=details,
|
|
2049
|
+
)
|
|
2050
|
+
|
|
2051
|
+
|
|
1861
2052
|
# Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
|
|
1862
2053
|
# The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
|
|
1863
2054
|
# fingerprint identity-slice key (spec §5.5). When an evaluator raises,
|
|
@@ -1896,6 +2087,12 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1896
2087
|
("db.reclaimable", "_check_db_reclaimable"),
|
|
1897
2088
|
("db.conversations_reclaimable", "_check_db_conversations_reclaimable"),
|
|
1898
2089
|
)),
|
|
2090
|
+
("journal", "Journal", (
|
|
2091
|
+
("journal.presence", "_check_journal_presence"),
|
|
2092
|
+
("journal.integrity", "_check_journal_integrity"),
|
|
2093
|
+
("journal.index_freshness", "_check_journal_index_freshness"),
|
|
2094
|
+
("journal.auto_heal", "_check_journal_auto_heal"),
|
|
2095
|
+
)),
|
|
1899
2096
|
("data", "Data", (
|
|
1900
2097
|
("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
|
|
1901
2098
|
("data.statusline_pipeline", "_check_statusline_pipeline"),
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Pure journal kernel — line codec, identity, segment naming/order, tail scan.
|
|
2
|
+
|
|
3
|
+
The durable-truth journal (design spec
|
|
4
|
+
docs/superpowers/specs/2026-07-22-db-journal-redesign-design.md §4) stores one
|
|
5
|
+
compact JSON object per line in monthly segments. This module owns everything
|
|
6
|
+
about that format that is *pure*: encoding/decoding a line, deriving the stable
|
|
7
|
+
`id` for every line class, naming and canonically ordering segments, and
|
|
8
|
+
scanning a file's tail for the last complete line (torn-tail repair support).
|
|
9
|
+
|
|
10
|
+
I/O-free by construction — stdlib only (`json`, `hashlib`, `datetime`), no
|
|
11
|
+
imports from `_cctally_*`, no filesystem or lock access. The append/ingest I/O
|
|
12
|
+
lives in `bin/_cctally_journal.py`; the durability discipline that consumes
|
|
13
|
+
`valid_tail_offset`/`journal_high_water` lives there.
|
|
14
|
+
|
|
15
|
+
Line format (spec §4.2), additive-evolution — readers tolerate unknown keys
|
|
16
|
+
and unknown `t` values:
|
|
17
|
+
|
|
18
|
+
{"v":1,"t":"obs","id":"o:…","at":"…Z","src":"…","provider":"…","payload":{…}}
|
|
19
|
+
{"v":1,"t":"op","id":"o:…","at":"…Z","src":"record-credit","payload":{…}}
|
|
20
|
+
{"v":1,"t":"evt","id":"<natural-key>","rev":0,"at":"…Z","src":"ingest","payload":{"kind":…}}
|
|
21
|
+
|
|
22
|
+
- `id`: obs/op carry a content digest over (t, at, src[, provider], payload);
|
|
23
|
+
bootstrap-exported lines use `b:<table>:<rowid>`; evt lines carry their target
|
|
24
|
+
table's full natural key with logical-id FK refs (spec §4.2 FK rule).
|
|
25
|
+
- `rev`: evt revision, default 0; the fold keeps the highest rev per id
|
|
26
|
+
(reserved for the deferred correction subsystem, spec §5.5).
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import datetime as dt
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
|
|
34
|
+
LINE_VERSION = 1
|
|
35
|
+
|
|
36
|
+
SEGMENT_PREFIX = "observations-"
|
|
37
|
+
BOOTSTRAP_PREFIX = "bootstrap-"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------
|
|
41
|
+
# line codec
|
|
42
|
+
# --------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def _canonical_json(obj: dict) -> str:
|
|
45
|
+
"""Deterministic compact JSON: sorted keys, no separator whitespace,
|
|
46
|
+
non-ASCII preserved literally. The single serialization shape used for
|
|
47
|
+
both the on-disk line and the content digest, so an id recomputed from a
|
|
48
|
+
decoded line matches the id computed at write time."""
|
|
49
|
+
return json.dumps(obj, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def encode_line(record: dict) -> bytes:
|
|
53
|
+
"""Canonical compact JSON for ``record`` plus a trailing ``\\n`` (UTF-8)."""
|
|
54
|
+
return _canonical_json(record).encode("utf-8") + b"\n"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def decode_line(raw: bytes) -> dict | None:
|
|
58
|
+
"""Decode one journal line. Returns the dict, or ``None`` on ANY parse or
|
|
59
|
+
shape failure — the line must be a JSON object carrying a string ``t``.
|
|
60
|
+
|
|
61
|
+
``None`` is how the ingester distinguishes a malformed line (skip + count,
|
|
62
|
+
spec §4.4) from a real record; it never raises on bad input."""
|
|
63
|
+
try:
|
|
64
|
+
obj = json.loads(raw)
|
|
65
|
+
except (ValueError, TypeError):
|
|
66
|
+
return None
|
|
67
|
+
if not isinstance(obj, dict):
|
|
68
|
+
return None
|
|
69
|
+
if not isinstance(obj.get("t"), str):
|
|
70
|
+
return None
|
|
71
|
+
return obj
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
# identity
|
|
76
|
+
# --------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def content_id(record_sans_id: dict) -> str:
|
|
79
|
+
"""Content digest id for an obs/op line: ``"o:" + sha256(canonical)[:16]``.
|
|
80
|
+
|
|
81
|
+
``record_sans_id`` is the identity-bearing subset — for obs/op that is
|
|
82
|
+
``{t, at, src[, provider], payload}`` (no ``v``, no ``id``). Stable under
|
|
83
|
+
key insertion order (canonical JSON sorts keys), so re-deriving the id from
|
|
84
|
+
a decoded line reproduces it (spec §4.2)."""
|
|
85
|
+
digest = hashlib.sha256(_canonical_json(record_sans_id).encode("utf-8")).hexdigest()
|
|
86
|
+
return "o:" + digest[:16]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def bootstrap_id(table: str, rowid: int) -> str:
|
|
90
|
+
"""Stable id for a row exported at cutover: ``b:<table>:<legacy rowid>``.
|
|
91
|
+
Stable across cutover re-runs, which is what makes double-fold idempotent
|
|
92
|
+
(spec §8)."""
|
|
93
|
+
return f"b:{table}:{rowid}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def evt_id(kind: str, *parts: object) -> str:
|
|
97
|
+
"""Natural-key id for an evt line: ``"<kind>:" + ":".join(str(p) …)``.
|
|
98
|
+
|
|
99
|
+
Each caller passes its target table's full UNIQUE-constraint components,
|
|
100
|
+
with any DB-assigned integer FK replaced by the *logical id* of the
|
|
101
|
+
referenced record (spec §4.2). e.g.
|
|
102
|
+
``evt_id("pm", week_start_at, reset_segment_logical_id, pct)``."""
|
|
103
|
+
return f"{kind}:" + ":".join(str(p) for p in parts)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------
|
|
107
|
+
# fully-formed line records
|
|
108
|
+
# --------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def make_obs(at: str, src: str, provider: str, payload: dict) -> dict:
|
|
111
|
+
"""Build a complete ``obs`` line record (raw capture; canonicalization is
|
|
112
|
+
derivation-time, never baked into the stored line — spec §4.2)."""
|
|
113
|
+
core = {"t": "obs", "at": at, "src": src, "provider": provider, "payload": payload}
|
|
114
|
+
return {"v": LINE_VERSION, **core, "id": content_id(core)}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def make_op(at: str, src: str, payload: dict) -> dict:
|
|
118
|
+
"""Build a complete ``op`` (operator record) line — no ``provider``."""
|
|
119
|
+
core = {"t": "op", "at": at, "src": src, "payload": payload}
|
|
120
|
+
return {"v": LINE_VERSION, **core, "id": content_id(core)}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def make_evt(kind: str, id: str, at: str, payload: dict, rev: int = 0) -> dict:
|
|
124
|
+
"""Build a complete ``evt`` (derived) line record.
|
|
125
|
+
|
|
126
|
+
The ``id`` is the caller-built natural key (see ``evt_id``); ``kind`` is the
|
|
127
|
+
fold-dispatch family written into ``payload["kind"]`` (spec §5.3). The
|
|
128
|
+
caller's ``payload`` dict is not mutated. ``src`` is always ``"ingest"`` —
|
|
129
|
+
evt lines exist only because the ingester derived them."""
|
|
130
|
+
body = dict(payload)
|
|
131
|
+
body["kind"] = kind
|
|
132
|
+
return {"v": LINE_VERSION, "t": "evt", "id": id, "rev": rev,
|
|
133
|
+
"at": at, "src": "ingest", "payload": body}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --------------------------------------------------------------------------
|
|
137
|
+
# segment naming + canonical order
|
|
138
|
+
# --------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def segment_name(now_utc: dt.datetime) -> str:
|
|
141
|
+
"""``observations-YYYY-MM.jsonl`` for the UTC calendar month of ``now_utc``.
|
|
142
|
+
|
|
143
|
+
A tz-aware datetime is converted to UTC first (spec §4.1: segments are cut
|
|
144
|
+
by the UTC month of the append); a naive datetime is treated as UTC."""
|
|
145
|
+
if now_utc.tzinfo is not None:
|
|
146
|
+
now_utc = now_utc.astimezone(dt.timezone.utc)
|
|
147
|
+
return f"{SEGMENT_PREFIX}{now_utc.year:04d}-{now_utc.month:02d}.jsonl"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def segment_sort_key(name: str) -> tuple:
|
|
151
|
+
"""Canonical segment order key (spec §4.1): bootstrap segments first, then
|
|
152
|
+
observation segments, each class lexicographic by name. Anything else sorts
|
|
153
|
+
last so a stray file can never wedge before real segments."""
|
|
154
|
+
if name.startswith(BOOTSTRAP_PREFIX):
|
|
155
|
+
return (0, name)
|
|
156
|
+
if name.startswith(SEGMENT_PREFIX):
|
|
157
|
+
return (1, name)
|
|
158
|
+
return (2, name)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --------------------------------------------------------------------------
|
|
162
|
+
# torn-tail scan
|
|
163
|
+
# --------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def valid_tail_offset(chunk: bytes, chunk_start: int) -> int:
|
|
166
|
+
"""Absolute file offset just past the last ``\\n`` in ``chunk``.
|
|
167
|
+
|
|
168
|
+
``chunk`` is the file's final ≤64 KiB window; ``chunk_start`` is that
|
|
169
|
+
window's absolute file offset. Used by the appender to ``ftruncate`` a torn
|
|
170
|
+
tail back to the last complete line (spec §4.3). When the window holds no
|
|
171
|
+
newline at all, returns ``chunk_start`` (the whole window is one incomplete
|
|
172
|
+
line — the appender treats a >64 KiB such window as a hard error)."""
|
|
173
|
+
idx = chunk.rfind(b"\n")
|
|
174
|
+
if idx == -1:
|
|
175
|
+
return chunk_start
|
|
176
|
+
return chunk_start + idx + 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------
|
|
180
|
+
# decode helper
|
|
181
|
+
# --------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def iter_decoded(lines):
|
|
184
|
+
"""Yield ``(offset, decode_line(raw))`` for each ``(offset, raw)`` in
|
|
185
|
+
``lines`` — a thin pairing helper so the ingester can count malformed lines
|
|
186
|
+
(``None`` results) while keeping their offsets for diagnostics."""
|
|
187
|
+
for offset, raw in lines:
|
|
188
|
+
yield offset, decode_line(raw)
|
package/bin/cctally
CHANGED
|
@@ -938,12 +938,15 @@ DowngradeDetected = _cctally_db.DowngradeDetected
|
|
|
938
938
|
ProdMigrationRefused = _cctally_db.ProdMigrationRefused
|
|
939
939
|
StatsDbCorruptError = _cctally_db.StatsDbCorruptError
|
|
940
940
|
StatsDbMaintenanceError = _cctally_db.StatsDbMaintenanceError
|
|
941
|
+
StatsEpochMismatchError = _cctally_db.StatsEpochMismatchError
|
|
941
942
|
_is_sqlite_corruption_error = _cctally_db._is_sqlite_corruption_error
|
|
942
943
|
_stats_corruption_guidance = _cctally_db._stats_corruption_guidance
|
|
943
944
|
_STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
|
|
944
945
|
_CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
|
|
946
|
+
_CONVERSATIONS_MIGRATIONS = _cctally_db._CONVERSATIONS_MIGRATIONS
|
|
945
947
|
_make_migration_decorator = _cctally_db._make_migration_decorator
|
|
946
948
|
stats_migration = _cctally_db.stats_migration
|
|
949
|
+
conversations_migration = _cctally_db.conversations_migration
|
|
947
950
|
cache_migration = _cctally_db.cache_migration
|
|
948
951
|
_LEGACY_MARKER_ALIASES_BY_DB = _cctally_db._LEGACY_MARKER_ALIASES_BY_DB
|
|
949
952
|
_bootstrap_rename_legacy_markers = _cctally_db._bootstrap_rename_legacy_markers
|
|
@@ -964,6 +967,7 @@ _db_path_for_label = _cctally_db._db_path_for_label
|
|
|
964
967
|
cmd_db_skip = _cctally_db.cmd_db_skip
|
|
965
968
|
cmd_db_unskip = _cctally_db.cmd_db_unskip
|
|
966
969
|
cmd_db_recover = _cctally_db.cmd_db_recover
|
|
970
|
+
cmd_db_rebuild = _cctally_db.cmd_db_rebuild
|
|
967
971
|
cmd_db_repair = _cctally_db.cmd_db_repair
|
|
968
972
|
cmd_db_backup = _cctally_db.cmd_db_backup
|
|
969
973
|
cmd_db_checkpoint = _cctally_db.cmd_db_checkpoint
|
|
@@ -1058,7 +1062,7 @@ _resolve_prior_5h = _cctally_record._resolve_prior_5h
|
|
|
1058
1062
|
_resolve_active_five_hour_reset_event_id = _cctally_record._resolve_active_five_hour_reset_event_id
|
|
1059
1063
|
_apply_credit = _cctally_record._apply_credit
|
|
1060
1064
|
_fire_in_place_credit = _cctally_record._fire_in_place_credit
|
|
1061
|
-
|
|
1065
|
+
detect_reset_and_credit = _cctally_record.detect_reset_and_credit
|
|
1062
1066
|
_count_stale_replays = _cctally_record._count_stale_replays
|
|
1063
1067
|
_credit_preview_text = _cctally_record._credit_preview_text
|
|
1064
1068
|
_credit_json = _cctally_record._credit_json
|
|
@@ -3257,6 +3261,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
3257
3261
|
except StatsDbMaintenanceError as exc:
|
|
3258
3262
|
eprint(f"cctally: {exc}")
|
|
3259
3263
|
return 3
|
|
3264
|
+
except StatsEpochMismatchError as exc:
|
|
3265
|
+
# A version-mismatched stats.db with no journal to rebuild from — hard
|
|
3266
|
+
# error with guidance (DB journal redesign §7.1). Caught before the
|
|
3267
|
+
# generic DatabaseError so it never renders as a raw traceback.
|
|
3268
|
+
eprint(f"cctally: {exc}")
|
|
3269
|
+
return 3
|
|
3260
3270
|
except sqlite3.DatabaseError as exc:
|
|
3261
3271
|
if _is_sqlite_corruption_error(exc):
|
|
3262
3272
|
eprint(
|