cctally 1.79.1 → 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.
@@ -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(args: argparse.Namespace) -> int:
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
- conn = open_db()
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:
@@ -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
- """FAIL/WARN when a DB's user_version exceeds the running binary's
735
- registry head (issue #145). stats.db ahead bricks commands (FAIL);
736
- cache.db ahead auto-heals on the next open (WARN). doctor reads raw
737
- user_version (no dispatcher), so it can report without healing/bricking.
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 _eval(status):
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 = _eval(s.stats_db_status)
747
- cache = _eval(s.cache_db_status)
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
- stats_ahead = bool(stats and stats["ahead"])
807
+ stats_mismatch = bool(stats and stats["mismatch"])
750
808
  cache_ahead = bool(cache and cache["ahead"])
751
809
 
752
- if stats_ahead:
810
+ if stats_mismatch:
753
811
  return CheckResult(
754
- id="db.version_ahead", title="Version ahead", severity="fail",
755
- summary=f"stats.db ahead (v{stats['user_version']} > known v{stats['registry_size']})",
756
- remediation="Run `cctally db recover --db stats --yes` (or restore from backup)",
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
- _force_clear_credit = _cctally_record._force_clear_credit
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(