cctally 1.60.0 → 1.61.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.
@@ -0,0 +1,988 @@
1
+ """Snapshot-rebuild cache infrastructure for the dashboard/TUI (#268).
2
+
3
+ Pure, unit-testable cache primitives for the per-tick `DataSnapshot`
4
+ rebuild. The dashboard's background sync thread rebuilds the whole
5
+ snapshot every `--sync-interval`; on a large instance a from-scratch
6
+ rebuild re-aggregates the entire history each tick and pegs a CPU core.
7
+ This module holds the cheap change-signals + immutable-aggregate caches
8
+ that let the rebuild recompute only the current/dirty slice and serve
9
+ immutable past periods from memory.
10
+
11
+ Design spec: docs/superpowers/specs/2026-07-04-dashboard-rebuild-perf-design.md
12
+
13
+ What lives here (M0):
14
+ - `SnapshotSignature` + `compute_signature` — a composite data-version
15
+ signature over EVERY table the cached surfaces read (spec §3). Cheap
16
+ `MAX(id)` b-tree descents + a `(count, max-rowid)` change-signal over
17
+ the reset-event tables, plus a monotonic generation counter. When the
18
+ signature is unchanged the rebuild can take the idle path.
19
+ - `new_min_timestamp` — the per-builder timestamp watermark (spec §3,
20
+ Codex F1): the earliest EVENT time among genuinely-new rows, so a
21
+ late/backfilled entry (new `id`, OLD `timestamp_utc`) forces recompute
22
+ of the affected PAST bucket, not just the current one.
23
+ - `bump_generation` / `current_generation` — a monotonic counter bumped
24
+ by any path that deletes/rewrites history in place (orphan prune,
25
+ `cache-sync --rebuild`); part of the signature so a deletion that
26
+ leaves `MAX(id)` unchanged still invalidates (spec §7, Codex F4).
27
+ - `BucketCache` — immutable per-past-bucket `BucketUsage` cache for the
28
+ Group A calendar builders (daily / monthly / weekly), spec §5.1.
29
+ - `SessionCache` — immutable per-session aggregate cache over the FULL
30
+ window for the Group B sessions builder, spec §5.2 / Codex F5.
31
+
32
+ Design invariants (spec §7):
33
+ - Every cached value is treated as IMMUTABLE — callers store finished
34
+ aggregates and never mutate them in place (SSE client threads read the
35
+ published snapshot concurrently).
36
+ - These functions take explicit `sqlite3.Connection` objects and plain
37
+ values; no dashboard/TUI imports, no DB opening of their own.
38
+
39
+ `session_entries` / `codex_session_entries` live in `cache.db`;
40
+ `weekly_usage_snapshots` / `weekly_cost_snapshots` / `week_reset_events`
41
+ / `weekly_credit_floors` live in `stats.db` (bin/_cctally_db.py,
42
+ bin/_cctally_core.py). `compute_signature` takes both conns for that
43
+ reason.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import datetime as dt
48
+ import os
49
+ import sqlite3
50
+ import threading
51
+ from typing import TYPE_CHECKING, Callable, NamedTuple
52
+
53
+ from _cctally_core import parse_iso_datetime
54
+
55
+ if TYPE_CHECKING: # type hints only — no runtime coupling to the aggregators
56
+ from _lib_aggregators import BucketUsage, ClaudeSessionUsage
57
+
58
+
59
+ # === Task 0.1 — composite data-version signature ===========================
60
+
61
+
62
+ class SnapshotSignature(NamedTuple):
63
+ """Cheap composite change-signal over every source table (spec §3).
64
+
65
+ Equality is value-equality (NamedTuple), so an unchanged signature
66
+ across two ticks means no cached surface's inputs moved → idle path.
67
+ """
68
+
69
+ max_entry_id: int
70
+ max_wus_id: int
71
+ max_wcs_id: int
72
+ reset_sig: tuple[int, int]
73
+ max_codex_id: int
74
+ generation: int
75
+
76
+
77
+ def _max_id(conn: sqlite3.Connection, table: str) -> int:
78
+ """`MAX(id)` on an autoincrement table, 0 on empty or missing table.
79
+
80
+ A single rowid b-tree descent (O(1)-ish). Returns 0 on a fresh /
81
+ partially-migrated DB where the table does not yet exist, so the
82
+ signature never raises.
83
+ """
84
+ try:
85
+ row = conn.execute(f"SELECT COALESCE(MAX(id), 0) FROM {table}").fetchone()
86
+ return int(row[0])
87
+ except sqlite3.Error:
88
+ return 0
89
+
90
+
91
+ def _reset_sig(conn: sqlite3.Connection) -> tuple[int, int]:
92
+ """Change-signal over the two reset-event tables combined (spec §3).
93
+
94
+ A credit / reset re-shapes a PAST weekly bucket with NO new
95
+ `session_entries` row, so the composite signature must cover it.
96
+ Uses `(COUNT(*), MAX(rowid))` over `week_reset_events` +
97
+ `weekly_credit_floors`: the count catches inserts, the max-rowid
98
+ catches the (rare) case where a delete+insert keeps the count level.
99
+ `rowid` aliases the tables' `INTEGER PRIMARY KEY id`. Returns (0, 0)
100
+ on a fresh DB where the tables are absent.
101
+ """
102
+ try:
103
+ row = conn.execute(
104
+ "SELECT (SELECT COUNT(*) FROM week_reset_events)"
105
+ " + (SELECT COUNT(*) FROM weekly_credit_floors),"
106
+ " (SELECT COALESCE(MAX(rowid), 0) FROM week_reset_events)"
107
+ " + (SELECT COALESCE(MAX(rowid), 0) FROM weekly_credit_floors)"
108
+ ).fetchone()
109
+ return (int(row[0]), int(row[1]))
110
+ except sqlite3.Error:
111
+ return (0, 0)
112
+
113
+
114
+ def compute_signature(
115
+ cache_conn: sqlite3.Connection,
116
+ stats_conn: sqlite3.Connection,
117
+ *,
118
+ generation: int,
119
+ ) -> SnapshotSignature:
120
+ """Composite data-version signature across cache.db + stats.db (spec §3).
121
+
122
+ ``cache_conn`` reads ``session_entries`` / ``codex_session_entries``
123
+ (cache.db); ``stats_conn`` reads ``weekly_usage_snapshots`` /
124
+ ``weekly_cost_snapshots`` / the reset-event tables (stats.db).
125
+ ``generation`` is the current cache-generation counter, folded in so
126
+ an in-place history deletion (which need not lower any ``MAX(id)``)
127
+ still advances the signature.
128
+ """
129
+ return SnapshotSignature(
130
+ max_entry_id=_max_id(cache_conn, "session_entries"),
131
+ max_wus_id=_max_id(stats_conn, "weekly_usage_snapshots"),
132
+ max_wcs_id=_max_id(stats_conn, "weekly_cost_snapshots"),
133
+ reset_sig=_reset_sig(stats_conn),
134
+ max_codex_id=_max_id(cache_conn, "codex_session_entries"),
135
+ generation=int(generation),
136
+ )
137
+
138
+
139
+ # === Task 0.2 — new-entry timestamp watermark ==============================
140
+
141
+
142
+ def new_min_timestamp(
143
+ cache_conn: sqlite3.Connection,
144
+ last_seen_max_id: int,
145
+ ) -> "dt.datetime | None":
146
+ """Earliest EVENT time among genuinely-new session_entries rows.
147
+
148
+ Returns ``MIN(timestamp_utc)`` over rows with ``id > last_seen_max_id``
149
+ as an aware UTC datetime, or ``None`` when there are no such rows.
150
+
151
+ This is the per-builder dirty-bucket watermark (spec §3, Codex F1).
152
+ ``session_entries.id`` is INGEST order, not event time — a resumed or
153
+ late-ingested file produces a NEW ``id`` carrying an OLD
154
+ ``timestamp_utc``. So when the signature advances, the affected time
155
+ window starts at the earliest event time among the new rows, which may
156
+ reach back into a PAST calendar bucket; each builder recomputes every
157
+ one of its buckets whose window ends after this watermark and serves
158
+ strictly-older buckets from cache.
159
+
160
+ Returns ``None`` on a missing table (fresh DB) so callers can treat it
161
+ as "no new rows".
162
+ """
163
+ try:
164
+ row = cache_conn.execute(
165
+ "SELECT MIN(timestamp_utc) FROM session_entries WHERE id > ?",
166
+ (last_seen_max_id,),
167
+ ).fetchone()
168
+ except sqlite3.Error:
169
+ return None
170
+ if row is None or row[0] is None:
171
+ return None
172
+ # Parse via the repo's canonical ISO helper (handles both `...Z` and
173
+ # `...+00:00` stored forms), then normalize to a UTC-tzinfo instant so
174
+ # downstream comparisons against UTC bucket boundaries are exact.
175
+ parsed = parse_iso_datetime(row[0], "session_entries.timestamp_utc")
176
+ return parsed.astimezone(dt.timezone.utc)
177
+
178
+
179
+ # === Task 0.3 — cache-generation counter ===================================
180
+ #
181
+ # A monotonic counter bumped by any path that deletes/rewrites history in
182
+ # place (orphan prune, `cache-sync --rebuild`). Folded into the composite
183
+ # signature (§3) so a deletion that leaves MAX(id) unchanged still
184
+ # invalidates the caches (spec §7, Codex F4). Guarded by a lock: the
185
+ # counter is bumped from the sync thread and read during signature compute
186
+ # on the same thread, but the lock makes any future off-thread bump safe.
187
+
188
+ _GENERATION_LOCK = threading.Lock()
189
+ _GENERATION = 0
190
+
191
+
192
+ def bump_generation() -> int:
193
+ """Monotonically advance the cache-generation counter; return the new value."""
194
+ global _GENERATION
195
+ with _GENERATION_LOCK:
196
+ _GENERATION += 1
197
+ return _GENERATION
198
+
199
+
200
+ def current_generation() -> int:
201
+ """Return the current cache-generation counter."""
202
+ with _GENERATION_LOCK:
203
+ return _GENERATION
204
+
205
+
206
+ # === Task 0.4 — Group A bucket cache holder ================================
207
+
208
+
209
+ class BucketCache:
210
+ """Immutable per-past-bucket `BucketUsage` cache for the calendar builders.
211
+
212
+ Stores the raw immutable aggregate (a `BucketUsage`) per past bucket,
213
+ keyed by ``(builder_key, bucket_label)`` where ``builder_key`` is one
214
+ of ``"daily"`` / ``"monthly"`` / ``"weekly"`` and ``bucket_label`` is
215
+ that builder's bucket identifier (``"2026-06-30"`` daily, ``"2026-06"``
216
+ monthly, the SubWeek key for weekly).
217
+
218
+ Spec §5.1: the cache holds the RAW aggregate, never the final
219
+ presentation row, and values are treated IMMUTABLE — a recomputed
220
+ bucket is put whole (never mutated in place), so an SSE client thread
221
+ reading a previously-published snapshot's rows can never observe a
222
+ torn value (spec §7 / Codex F7).
223
+ """
224
+
225
+ def __init__(self) -> None:
226
+ self._store: "dict[tuple[str, str], BucketUsage]" = {}
227
+
228
+ def get(self, builder_key: str, bucket_label: str) -> "BucketUsage | None":
229
+ """Return the cached aggregate for this bucket, or None on a miss."""
230
+ return self._store.get((builder_key, bucket_label))
231
+
232
+ def put(self, builder_key: str, bucket_label: str, bucket: "BucketUsage") -> None:
233
+ """Store the (immutable) aggregate for this bucket."""
234
+ self._store[(builder_key, bucket_label)] = bucket
235
+
236
+ def drop_from(
237
+ self, builder_key: str, predicate: "Callable[[str], bool]"
238
+ ) -> None:
239
+ """Evict cached buckets for ``builder_key`` whose label matches ``predicate``.
240
+
241
+ Used to invalidate the dirty tail: pass a predicate that is True for
242
+ every bucket label at/after the watermark (or otherwise known dirty).
243
+ Only the given ``builder_key``'s namespace is touched.
244
+ """
245
+ doomed = [
246
+ key
247
+ for key in self._store
248
+ if key[0] == builder_key and predicate(key[1])
249
+ ]
250
+ for key in doomed:
251
+ del self._store[key]
252
+
253
+ def clear(self) -> None:
254
+ """Drop every cached bucket across all builders (full invalidation)."""
255
+ self._store.clear()
256
+
257
+
258
+ # === Task 0.5 — Group B session cache holder ===============================
259
+
260
+
261
+ class SessionCache:
262
+ """Immutable per-session aggregate cache over the FULL sessions window.
263
+
264
+ Holds ALL sessions in the builder's window (spec §5.2 / Codex F5),
265
+ keyed by the resolved session identity — NOT just the visible top 100.
266
+ Sorting/truncating for the 100-row view is done over ``get_all()`` each
267
+ tick, so a session that was previously below the cut can promote into
268
+ view once it gets new activity; caching only the visible slice would
269
+ make that impossible.
270
+
271
+ Values are aggregated ``ClaudeSessionUsage`` rows, treated immutable: a
272
+ changed session is fully re-aggregated and ``put`` whole (a resumed /
273
+ straddling session re-aggregates from its entire entry set, so there is
274
+ no split-row bug), never mutated in place (spec §7 / Codex F7).
275
+ """
276
+
277
+ def __init__(self) -> None:
278
+ self._store: "dict[str, ClaudeSessionUsage]" = {}
279
+
280
+ def get_all(self) -> "dict[str, ClaudeSessionUsage]":
281
+ """Return a shallow COPY of every cached session, keyed by identity.
282
+
283
+ A copy so a caller's sort/truncate over the candidate set can never
284
+ mutate the module-level store (the immutable-cache discipline). The
285
+ aggregate values are shared (they are themselves immutable).
286
+ """
287
+ return dict(self._store)
288
+
289
+ def put(self, session_key: str, session: "ClaudeSessionUsage") -> None:
290
+ """Store (or replace) the aggregate for one session identity."""
291
+ self._store[session_key] = session
292
+
293
+ def drop(self, session_keys: "set[str]") -> None:
294
+ """Remove the given session identities; absent keys are ignored."""
295
+ for key in session_keys:
296
+ self._store.pop(key, None)
297
+
298
+ def clear(self) -> None:
299
+ """Drop every cached session (full invalidation)."""
300
+ self._store.clear()
301
+
302
+
303
+ # === Task 2.1 — Group A cached-bucket recompute helper =====================
304
+ #
305
+ # The shared per-past-bucket aggregate cache for the three calendar
306
+ # builders (daily / monthly / weekly), plus the two entry points the
307
+ # dashboard builders call:
308
+ #
309
+ # - ``cached_buckets`` — the PURE per-bucket assembly loop: recompute the
310
+ # current + caller-marked-dirty buckets whole (from a timestamp-ordered
311
+ # fetch, spec §5.1 / Codex F3), serve clean past buckets from the
312
+ # ``BucketCache``, and recompute (cold-miss) any label the cache lacks.
313
+ # - ``build_cached_group_a`` — the STATEFUL wrapper: each Group A builder
314
+ # is self-caching and independently byte-correct (M2 key decision — no
315
+ # dependency on the not-yet-built M5 dispatch). It tracks THIS builder's
316
+ # own last-seen ``(MAX(session_entries.id), extra_signature)`` alongside
317
+ # the builder's ``BucketCache`` namespace, derives the dirty predicate
318
+ # from the new-entry timestamp watermark (``new_min_timestamp``), and
319
+ # full-invalidates the namespace on an ``extra_signature`` change (weekly
320
+ # snapshot/reset legs, or the daily/monthly display-tz flip) or a
321
+ # ``MAX(id)`` regression (cache.db rebuilt). Cold, warm, and invalidated
322
+ # ticks are all byte-identical to a from-scratch aggregation.
323
+ #
324
+ # Module-level state follows the ``_PROJECTS_ENV_MEMO`` precedent (spec §7):
325
+ # a single shared ``BucketCache`` namespaced by ``builder_key`` and a
326
+ # per-builder last-seen dict. Every cached value is an IMMUTABLE
327
+ # ``BucketUsage``; the builders build FRESH presentation rows over the
328
+ # assembled list each tick and never mutate a cached aggregate (Codex F7).
329
+
330
+ _GROUP_A_CACHE = BucketCache()
331
+ _GROUP_A_LAST_SEEN: "dict[str, dict]" = {}
332
+
333
+
334
+ def group_a_cache() -> BucketCache:
335
+ """Return the shared Group A ``BucketCache`` (daily/monthly/weekly)."""
336
+ return _GROUP_A_CACHE
337
+
338
+
339
+ def reset_group_a_state() -> None:
340
+ """Clear the Group A bucket cache AND every builder's last-seen state.
341
+
342
+ Test hook + the M5 orphan-prune / ``cache-sync --rebuild`` invalidation
343
+ entry point: after history is deleted or rewritten in place, drop every
344
+ cached bucket and reset the watermarks so the next rebuild recomputes
345
+ from scratch (spec §7 / Codex F4).
346
+ """
347
+ _GROUP_A_CACHE.clear()
348
+ _GROUP_A_LAST_SEEN.clear()
349
+
350
+
351
+ def cached_buckets(
352
+ builder_key: str,
353
+ *,
354
+ cache: BucketCache,
355
+ all_bucket_labels: "list[str]",
356
+ current_label: "str | None",
357
+ dirty_predicate: "Callable[[str], bool]",
358
+ fetch_bucket_entries: "Callable[[str], list]",
359
+ aggregate_one: "Callable[[str, list], object | None]",
360
+ ) -> "list[object]":
361
+ """Assemble one Group A builder's per-bucket aggregates (spec §5.1).
362
+
363
+ For each label in ``all_bucket_labels`` (caller order — pass oldest→newest
364
+ so the assembled list matches ``_aggregate_*``'s ascending-key output):
365
+
366
+ - If the label is ``current_label`` (the open bucket) or ``dirty_predicate``
367
+ returns True (the watermark reached it, or a forced recompute), recompute
368
+ it WHOLE via ``aggregate_one(label, fetch_bucket_entries(label))`` — a
369
+ timestamp-ordered fetch, so ``_aggregate_buckets`` first-seen model order
370
+ reproduces the full-history pass byte-for-byte (Codex F3).
371
+ - Otherwise serve the cached ``BucketUsage``; on a cache MISS (cold start /
372
+ post-invalidation) recompute it the same way.
373
+
374
+ ``aggregate_one`` returns ``None`` for a label with no data (a gap
375
+ day/month/week): the bucket is omitted from the result and any stale cache
376
+ entry for that label is evicted. The returned list therefore contains only
377
+ buckets-with-data — exactly what ``_aggregate_daily/_monthly/_weekly`` over
378
+ the full entry set returns — in ``all_bucket_labels`` order.
379
+
380
+ Values are stored/served as-is (immutable ``BucketUsage``); this loop never
381
+ mutates a bucket in place (spec §7).
382
+ """
383
+ result: "list[object]" = []
384
+ for label in all_bucket_labels:
385
+ recompute = (label == current_label) or dirty_predicate(label)
386
+ bucket = None
387
+ if not recompute:
388
+ bucket = cache.get(builder_key, label)
389
+ if bucket is None: # forced recompute OR cold miss
390
+ bucket = aggregate_one(label, fetch_bucket_entries(label))
391
+ if bucket is None:
392
+ # Gap bucket (no data): drop any stale cache entry, omit from output.
393
+ cache.drop_from(builder_key, lambda lbl, _t=label: lbl == _t)
394
+ else:
395
+ cache.put(builder_key, label, bucket)
396
+ result.append(bucket)
397
+ return result
398
+
399
+
400
+ def build_cached_group_a(
401
+ builder_key: str,
402
+ *,
403
+ cache_conn: sqlite3.Connection,
404
+ all_bucket_labels: "list[str]",
405
+ current_label: "str | None",
406
+ bucket_end_of: "Callable[[str], dt.datetime | None]",
407
+ fetch_bucket_entries: "Callable[[str], list]",
408
+ aggregate_one: "Callable[[str, list], object | None]",
409
+ extra_signature: object = None,
410
+ ) -> "list[object]":
411
+ """Stateful Group A assembly: invalidation + watermark + ``cached_buckets``.
412
+
413
+ ``cache_conn`` is a ``cache.db`` connection (reads
414
+ ``MAX(session_entries.id)`` + the new-entry watermark). ``bucket_end_of``
415
+ maps a label to an aware-UTC datetime that is at-or-after that bucket's
416
+ window end — a bucket is watermark-dirty when its end is after
417
+ ``new_min_timestamp`` (over-estimating the end only over-marks buckets
418
+ dirty, which is safe: it never serves stale past data). ``extra_signature``
419
+ is any hashable value whose change forces a full namespace invalidation
420
+ (the weekly snapshot/reset legs, or the daily/monthly display-tz label) —
421
+ e.g. a weekly bucket still transitioning through a credit rides the
422
+ weekly builder's ``extra_signature`` full-invalidate rather than a
423
+ per-label recompute flag.
424
+
425
+ Returns the assembled ``BucketUsage`` list (cache hits for clean past
426
+ labels, fresh recompute for current + dirty), in ``all_bucket_labels``
427
+ order, and updates this builder's last-seen state.
428
+ """
429
+ cur_max_id = _max_id(cache_conn, "session_entries")
430
+ state = _GROUP_A_LAST_SEEN.get(builder_key)
431
+ full_invalidate = state is not None and (
432
+ state.get("extra") != extra_signature
433
+ or cur_max_id < int(state.get("max_id", 0))
434
+ )
435
+ if full_invalidate:
436
+ _GROUP_A_CACHE.drop_from(builder_key, lambda _lbl: True)
437
+
438
+ if state is None or full_invalidate:
439
+ # Cold start OR post-invalidation: recompute every label. (Cold already
440
+ # cache-misses; being explicit also covers the boundary/signature-shift
441
+ # case where a stale label could otherwise collide with a fresh window.)
442
+ def dirty(_label: str) -> bool:
443
+ return True
444
+ else:
445
+ new_min_ts = new_min_timestamp(cache_conn, int(state.get("max_id", 0)))
446
+
447
+ def dirty(label: str) -> bool:
448
+ if new_min_ts is None:
449
+ return False
450
+ end = bucket_end_of(label)
451
+ return end is not None and end > new_min_ts
452
+
453
+ buckets = cached_buckets(
454
+ builder_key,
455
+ cache=_GROUP_A_CACHE,
456
+ all_bucket_labels=all_bucket_labels,
457
+ current_label=current_label,
458
+ dirty_predicate=dirty,
459
+ fetch_bucket_entries=fetch_bucket_entries,
460
+ aggregate_one=aggregate_one,
461
+ )
462
+ _GROUP_A_LAST_SEEN[builder_key] = {"max_id": cur_max_id, "extra": extra_signature}
463
+ return buckets
464
+
465
+
466
+ # === Task 3.1 — changed-session resolution (join + filename-stem fallback) ==
467
+
468
+
469
+ def affected_session_keys(
470
+ cache_conn: sqlite3.Connection,
471
+ last_seen_max_id: int,
472
+ ) -> "set[str]":
473
+ """Resolved session identities for entries with ``id > last_seen`` (spec §5.2).
474
+
475
+ Mirrors ``_aggregate_claude_sessions`` grouping EXACTLY: identity is
476
+ ``session_files.session_id`` when the ``LEFT JOIN`` on ``source_path``
477
+ yields a non-null id, else the filename-stem of ``source_path``
478
+ (``os.path.splitext(os.path.basename(path))[0]``) — the same fallback
479
+ the aggregator applies when ``entry.session_id is None``
480
+ (``bin/_lib_aggregators.py``). ``<synthetic>``-model rows are skipped
481
+ (the aggregator skips them before the fallback), so a purely-synthetic
482
+ new row contributes no key.
483
+
484
+ ``session_entries`` has NO ``session_id`` column — identity comes from
485
+ the join to ``session_files`` — so the returned keys key IDENTICALLY to
486
+ the aggregator's session grouping (Codex F5). Returns an empty set on a
487
+ missing table (fresh / partially-migrated DB) so callers never raise.
488
+ """
489
+ try:
490
+ rows = cache_conn.execute(
491
+ "SELECT se.source_path, sf.session_id "
492
+ "FROM session_entries se "
493
+ "LEFT JOIN session_files sf ON sf.path = se.source_path "
494
+ "WHERE se.id > ? AND se.model != '<synthetic>'",
495
+ (last_seen_max_id,),
496
+ ).fetchall()
497
+ except sqlite3.Error:
498
+ return set()
499
+ keys: "set[str]" = set()
500
+ for source_path, session_id in rows:
501
+ if session_id is not None:
502
+ keys.add(session_id)
503
+ else:
504
+ keys.add(os.path.splitext(os.path.basename(source_path))[0])
505
+ return keys
506
+
507
+
508
+ # === Task 3.2 — Group B session aggregate cache over the FULL window =======
509
+ #
510
+ # Module-level state following the Group A precedent (spec §7): a single
511
+ # shared ``SessionCache`` holding ALL sessions in the 365-day window (NOT
512
+ # just the visible top 100 — Codex F5), plus this builder's own last-seen
513
+ # ``MAX(session_entries.id)`` watermark. Every cached value is an immutable
514
+ # ``ClaudeSessionUsage``; the builder builds FRESH ``TuiSessionRow`` objects
515
+ # from the assembled list each tick and never mutates a cached aggregate
516
+ # (Codex F7). Mutated ONLY on the sync thread (single-writer, like Group A).
517
+
518
+ _SESSION_CACHE = SessionCache()
519
+ _SESSION_LAST_SEEN: "dict" = {}
520
+
521
+
522
+ def session_cache() -> SessionCache:
523
+ """Return the shared Group B ``SessionCache`` (all sessions in-window)."""
524
+ return _SESSION_CACHE
525
+
526
+
527
+ def reset_session_cache_state() -> None:
528
+ """Clear the session cache AND its last-seen watermark (full invalidation).
529
+
530
+ Test hook + the M5 orphan-prune / ``cache-sync --rebuild`` invalidation
531
+ entry point: after history is deleted or rewritten in place, drop every
532
+ cached session and reset the watermark so the next rebuild re-aggregates
533
+ the whole window from scratch (spec §7 / Codex F4).
534
+ """
535
+ _SESSION_CACHE.clear()
536
+ _SESSION_LAST_SEEN.clear()
537
+
538
+
539
+ def build_cached_sessions(
540
+ *,
541
+ cache_conn: sqlite3.Connection,
542
+ aggregate_all: "Callable[[], list]",
543
+ reaggregate: "Callable[[int, set], list]",
544
+ extra_signature: object = None,
545
+ ) -> "list":
546
+ """Stateful Group B assembly: cold full-aggregate / warm affected-only (spec §5.2).
547
+
548
+ ``cache_conn`` is a ``cache.db`` connection (reads
549
+ ``MAX(session_entries.id)`` + the affected-session set). ``aggregate_all``
550
+ returns the full ``list[ClaudeSessionUsage]`` for the window (cold path).
551
+ ``reaggregate(last_seen, affected_keys)`` returns the re-aggregated
552
+ ``ClaudeSessionUsage`` for exactly the sessions touched since ``last_seen``
553
+ (warm path) — a straddling/resumed session re-aggregates WHOLE from its
554
+ own full in-window entry set, so no split-row bug. ``extra_signature`` is
555
+ any hashable whose change forces a full cold rebuild.
556
+
557
+ Cold when: no prior state, ``extra_signature`` changed, or a
558
+ ``MAX(id)`` regression (cache.db rebuilt). Warm otherwise, re-aggregating
559
+ only the affected sessions and updating their cache rows in place of the
560
+ stale ones. Returns the FULL cached session list (UNSORTED — the caller
561
+ window-filters, sorts by ``last_activity`` desc, and truncates to the
562
+ view limit, which is what preserves correct eviction/**promotion** at the
563
+ 100-row boundary, Codex F5).
564
+
565
+ Each returned/stored value is an immutable ``ClaudeSessionUsage`` keyed by
566
+ its resolved ``session_id`` (which the aggregator sets to the stem for
567
+ fallback sessions), so the cache keys match ``affected_session_keys``.
568
+ """
569
+ cur_max_id = _max_id(cache_conn, "session_entries")
570
+ state = _SESSION_LAST_SEEN
571
+ cold = (
572
+ not state
573
+ or state.get("extra") != extra_signature
574
+ or cur_max_id < int(state.get("max_id", 0))
575
+ )
576
+ if cold:
577
+ _SESSION_CACHE.clear()
578
+ for sess in aggregate_all():
579
+ _SESSION_CACHE.put(sess.session_id, sess)
580
+ else:
581
+ last_seen = int(state.get("max_id", 0))
582
+ if cur_max_id > last_seen:
583
+ affected = affected_session_keys(cache_conn, last_seen)
584
+ if affected:
585
+ for sess in reaggregate(last_seen, affected):
586
+ _SESSION_CACHE.put(sess.session_id, sess)
587
+ _SESSION_LAST_SEEN.clear()
588
+ _SESSION_LAST_SEEN["max_id"] = cur_max_id
589
+ _SESSION_LAST_SEEN["extra"] = extra_signature
590
+ return list(_SESSION_CACHE.get_all().values())
591
+
592
+
593
+ # === Task 4.2 — doctor payload TTL memo (spec §6) ==========================
594
+ #
595
+ # The dashboard envelope used to re-fork the `security` keychain subprocess
596
+ # (via `doctor_gather_state`) once PER SSE CLIENT PER TICK. §6 moves the
597
+ # doctor gather onto the sync-thread `DataSnapshot` (precomputed once per
598
+ # rebuild). This short-TTL memo further guards against back-to-back WARM
599
+ # rebuilds re-forking `security` every tick — the keychain/symlink/log state
600
+ # it reads changes rarely. The `compute` callable is INJECTED so this module
601
+ # stays decoupled from the doctor I/O layer (no `_cctally_doctor` import).
602
+ # The lazy `GET /api/doctor` endpoint deliberately does NOT route through
603
+ # this memo — an explicit user refresh must be live.
604
+
605
+ DOCTOR_MEMO_TTL_S = 30.0
606
+
607
+ _DOCTOR_MEMO_LOCK = threading.Lock()
608
+ _DOCTOR_MEMO: "dict" = {}
609
+
610
+
611
+ def doctor_payload_memo(
612
+ now_utc: dt.datetime,
613
+ runtime_bind: "str | None",
614
+ *,
615
+ ttl_s: float,
616
+ compute: "Callable[[dt.datetime, str | None], dict]",
617
+ ) -> dict:
618
+ """Return the doctor envelope payload, recomputing via ``compute`` only
619
+ when the memo is cold — never computed, older than ``ttl_s``, a clock
620
+ regression (``now_utc`` before the cached instant), or a ``runtime_bind``
621
+ change (the bind feeds ``safety.dashboard_bind``).
622
+
623
+ ``compute(now_utc, runtime_bind) -> dict`` is the injected
624
+ gather→checks→envelope-dict step; it runs OUTSIDE the lock so the
625
+ `security` subprocess fork never serializes other readers. In practice
626
+ only the sync thread calls this, so the (harmless) double-compute a
627
+ concurrent caller could trigger never happens.
628
+ """
629
+ with _DOCTOR_MEMO_LOCK:
630
+ cached = _DOCTOR_MEMO
631
+ computed_at = cached.get("computed_at")
632
+ fresh = (
633
+ bool(cached)
634
+ and cached.get("runtime_bind") == runtime_bind
635
+ and computed_at is not None
636
+ and now_utc >= computed_at
637
+ and (now_utc - computed_at).total_seconds() < ttl_s
638
+ )
639
+ if fresh:
640
+ return cached["payload"]
641
+ payload = compute(now_utc, runtime_bind)
642
+ with _DOCTOR_MEMO_LOCK:
643
+ _DOCTOR_MEMO.clear()
644
+ _DOCTOR_MEMO["payload"] = payload
645
+ _DOCTOR_MEMO["computed_at"] = now_utc
646
+ _DOCTOR_MEMO["runtime_bind"] = runtime_bind
647
+ return payload
648
+
649
+
650
+ def reset_doctor_memo() -> None:
651
+ """Drop the memoized doctor payload (test hook + M5 invalidation entry)."""
652
+ with _DOCTOR_MEMO_LOCK:
653
+ _DOCTOR_MEMO.clear()
654
+
655
+
656
+ # === Task 5.1 — idle-path dispatch state (last signature + snapshot) ========
657
+ #
658
+ # The dashboard sync-thread rebuild computes the composite ``SnapshotSignature``
659
+ # at the top of every tick; when it is UNCHANGED versus the last published
660
+ # rebuild AND no wall-clock day/week/month boundary has rolled over, the rebuild
661
+ # takes the IDLE path (spec §3): it reuses the last published snapshot's heavy
662
+ # period/session rows and re-patches only the time-derived fields, skipping ALL
663
+ # re-aggregation — so an idle dashboard sits near 0% CPU. This module holds that
664
+ # last ``(signature, snapshot)`` pair.
665
+ #
666
+ # Sync-thread-only, single-writer — same discipline as the Group A / session
667
+ # caches (spec §7). The snapshot is stored as an OPAQUE object: this module
668
+ # never introspects it, keeping the "no dashboard/TUI import" design (the caller
669
+ # in bin/_cctally_tui.py owns the ``DataSnapshot`` type and all patching).
670
+
671
+ _LAST_DISPATCH_KEY: object = None
672
+ _LAST_PUBLISHED_SNAPSHOT: object = None
673
+
674
+
675
+ def dispatch_state() -> "tuple[object, object]":
676
+ """Return the ``(last dispatch key, last published snapshot)`` pair (spec §3).
677
+
678
+ ``(None, None)`` before the first rebuild or after a reset. The dispatch key
679
+ is the caller's opaque hashable — the composite ``SnapshotSignature`` bundled
680
+ with a render key (resolved display-tz + config) covering the render inputs
681
+ the DB signature does not; the caller compares it whole. The snapshot is an
682
+ opaque ``DataSnapshot`` reference the caller owns.
683
+ """
684
+ return (_LAST_DISPATCH_KEY, _LAST_PUBLISHED_SNAPSHOT)
685
+
686
+
687
+ def store_dispatch_state(dispatch_key: object, snapshot: object) -> None:
688
+ """Record the dispatch key + published snapshot for the next idle short-circuit.
689
+
690
+ Called once per rebuild (idle or full) so the next tick compares against the
691
+ key the just-published snapshot was built from.
692
+ """
693
+ global _LAST_DISPATCH_KEY, _LAST_PUBLISHED_SNAPSHOT
694
+ _LAST_DISPATCH_KEY = dispatch_key
695
+ _LAST_PUBLISHED_SNAPSHOT = snapshot
696
+
697
+
698
+ def reset_dispatch_state() -> None:
699
+ """Drop the idle-path ``(key, snapshot)`` memo (test hook + isolation).
700
+
701
+ A fresh process starts with no memo; tests reset it between rebuilds so a
702
+ prior test's leftover snapshot can't be idle-served under a matching key. Not
703
+ part of the M5.2 prune invalidation — the generation bump (a signature leg)
704
+ already forces the next rebuild off the idle path.
705
+ """
706
+ global _LAST_DISPATCH_KEY, _LAST_PUBLISHED_SNAPSHOT
707
+ _LAST_DISPATCH_KEY = None
708
+ _LAST_PUBLISHED_SNAPSHOT = None
709
+
710
+
711
+ # === #269 M0 — shared per-weekref immutable-cost cache (B1 trend + B3 forecast)
712
+ #
713
+ # A closed subscription week's cost is IMMUTABLE, so it is computed once and
714
+ # reused until a signal invalidates it (spec §4). Both `build_trend_view`'s
715
+ # reset-event weeks (`_compute_cost_for_weekref`) and forecast's trailing-4-week
716
+ # fallback (`_select_dollars_per_percent` → `_sum_cost_for_range`) call the same
717
+ # per-closed-week cost primitive from two sites; one shared cache keyed by the
718
+ # week's `(week_start_at, week_end_at)` boundaries serves both. The OPEN (current)
719
+ # week is never cached — it is decided per call from `week_end_at > now_utc`, so a
720
+ # just-closed week caches on the next tick and the newly-opened week always
721
+ # recomputes (no `_snapshot_period_rolled_over` dependence).
722
+ #
723
+ # Module-level state follows the Group A / session-cache precedent (spec §7): a
724
+ # plain dict of immutable floats + a per-cache last-seen dict, mutated only on the
725
+ # dashboard sync thread (single-writer). Every cached value is an immutable float;
726
+ # each rebuild builds FRESH trend / forecast presentation objects and never
727
+ # mutates a cached value (Codex F7).
728
+
729
+ _WEEKREF_COST_CACHE: dict = {} # {(week_start_iso, week_end_iso): cost_usd}
730
+ _WEEKREF_COST_LAST_SEEN: dict = {} # {"max_id": int, "reset_sig": tuple}
731
+
732
+
733
+ def _weekref_key(week_start_at, week_end_at):
734
+ """Canonical UTC-ISO key for a subscription week's cost.
735
+
736
+ Normalizes both boundaries to UTC before serializing, so two callers that
737
+ resolve the same physical week in different tzinfos key identically.
738
+ """
739
+ return (
740
+ week_start_at.astimezone(dt.timezone.utc).isoformat(),
741
+ week_end_at.astimezone(dt.timezone.utc).isoformat(),
742
+ )
743
+
744
+
745
+ def reset_weekref_cost_state():
746
+ """Clear the weekref-cost cache + its watermark (full invalidation).
747
+
748
+ Called from the orphan-prune site (a prune deletes ``session_entries``
749
+ possibly WITHOUT lowering ``MAX(id)``, so the reconcile's max-id-regression
750
+ check cannot catch it — the explicit clear must) and as a test hook.
751
+ """
752
+ _WEEKREF_COST_CACHE.clear()
753
+ _WEEKREF_COST_LAST_SEEN.clear()
754
+
755
+
756
+ def cached_weekref_cost(*, week_start_at, week_end_at, now_utc, compute):
757
+ """Get-or-compute a subscription week's cost (spec §4).
758
+
759
+ The OPEN week (``week_end_at > now_utc``) is always recomputed and never
760
+ cached — open-vs-closed is decided per call so a just-closed week caches on
761
+ the next tick and the newly-opened week always recomputes. A CLOSED week is
762
+ served from ``_WEEKREF_COST_CACHE`` on a hit, else computed via ``compute``
763
+ and stored. ``compute`` is the caller's from-scratch closure
764
+ (``_compute_cost_for_weekref`` for B1, ``_sum_cost_for_range`` for B3), so
765
+ the returned float is bit-identical to today's.
766
+ """
767
+ if week_end_at > now_utc:
768
+ return compute()
769
+ key = _weekref_key(week_start_at, week_end_at)
770
+ hit = _WEEKREF_COST_CACHE.get(key)
771
+ if hit is not None: # 0.0 is a legitimate cached value, not a miss
772
+ return hit
773
+ val = compute()
774
+ _WEEKREF_COST_CACHE[key] = val
775
+ return val
776
+
777
+
778
+ def reconcile_weekref_cache(cache_conn, *, max_entry_id, reset_sig):
779
+ """Once-per-non-idle-rebuild invalidation for the weekref-cost cache (spec §4).
780
+
781
+ Driven by ``_tui_build_snapshot`` after the idle-path check, before the
782
+ builders run, using the already-computed dispatch-signature legs
783
+ (``max_entry_id`` + ``reset_sig`` are passed in — no extra query for those):
784
+
785
+ - Cold (no last-seen): record last-seen, return — no eviction.
786
+ - ``reset_sig`` changed OR ``max_entry_id < last_seen`` (cache.db rebuilt
787
+ out-of-process): full ``clear()``. A credit/reset re-shapes a past week's
788
+ cost; reset events are rare, so a conservative full clear is correct and
789
+ cheap. A max-id regression means the ids no longer map to the same rows.
790
+ - ``max_entry_id > last_seen``: evict cached weeks whose ``week_end_at`` is
791
+ ``>= new_min_timestamp(cache_conn, last_seen)`` — a genuinely-new row
792
+ could fall inside them (F1 late-ingest). The bound is ``>=``, NOT ``>``,
793
+ because ``_sum_cost_for_range`` / ``iter_entries`` sum an inclusive
794
+ ``[start, end]`` window, so a row whose timestamp lands exactly on
795
+ ``week_end_at`` belongs to that week (Codex-1). Over-eviction is byte-safe
796
+ (forces a recompute). Normally ``wm`` is recent and no closed week drops.
797
+
798
+ Idempotent within a tick: after the first call updates last-seen, a later
799
+ call in the same tick sees no delta (``max_entry_id == last_seen``,
800
+ ``reset_sig`` unchanged) and no-ops — never re-running the watermark query.
801
+
802
+ Connection lifecycle (Codex-4): the ``new_min_timestamp`` watermark query is
803
+ the only use of ``cache_conn`` and runs only on the
804
+ ``max_entry_id > last_seen`` branch; the caller passes a short-lived cache
805
+ connection, opened for that query.
806
+ """
807
+ ls = _WEEKREF_COST_LAST_SEEN
808
+ if not ls: # cold
809
+ ls["max_id"] = max_entry_id
810
+ ls["reset_sig"] = reset_sig
811
+ return
812
+ if reset_sig != ls["reset_sig"] or max_entry_id < ls["max_id"]:
813
+ _WEEKREF_COST_CACHE.clear() # reset/credit, or cache.db rebuilt
814
+ ls["max_id"] = max_entry_id
815
+ ls["reset_sig"] = reset_sig
816
+ return
817
+ if max_entry_id > ls["max_id"]:
818
+ wm = new_min_timestamp(cache_conn, ls["max_id"])
819
+ if wm is not None:
820
+ for key in list(_WEEKREF_COST_CACHE):
821
+ # key = (start_iso, end_iso); inclusive [start,end] window, so
822
+ # evict when the week's end >= the earliest new event time.
823
+ if dt.datetime.fromisoformat(key[1]) >= wm:
824
+ del _WEEKREF_COST_CACHE[key]
825
+ ls["max_id"] = max_entry_id
826
+ ls["reset_sig"] = reset_sig
827
+
828
+
829
+ # === #269 M4 — projects-envelope per-(project, week) incremental cache =======
830
+ #
831
+ # `_build_projects_envelope` re-iterates all ~190K window entries every warm
832
+ # tick, doing a per-entry `_resolve_project_key` + cost + per-(project, week)
833
+ # aggregation. At scale that builder DOMINATES the warm rebuild (spec §13). A
834
+ # CLOSED week's per-project aggregate is IMMUTABLE, so cache it and recompute
835
+ # only the CURRENT week each warm tick (spec §14 Win 2).
836
+ #
837
+ # Storage (opaque — this module never introspects the aggregate object, keeping
838
+ # the "no dashboard/TUI import" design, exactly like `BucketCache`):
839
+ # - `_PROJECTS_ENV_WEEK_CACHE`: `{(bucket_path, week_iso) -> agg}` per closed
840
+ # week. The dashboard packs a `(cost_usd, sessions_count, first_seen,
841
+ # last_seen, first_order, first_id, first_key)` record; here it is opaque.
842
+ # - `_PROJECTS_ENV_WEEK_TOTALS`: `{week_iso -> total_cost}` cached as its OWN
843
+ # entry-order aggregate (spec §14(a) — never re-summed from project buckets,
844
+ # which would re-associate the float sum). ALSO the "week computed" registry:
845
+ # a week is a cache HIT iff its `week_iso` is present here (an empty computed
846
+ # week is stored with total 0.0 and no bucket rows, so cold empty weeks are
847
+ # not re-queried every tick).
848
+ # - `_PROJECTS_ENV_LAST_SEEN`: `{max_id, max_wus_id, sf_sig}` this cache last
849
+ # reconciled against.
850
+ #
851
+ # Single-writer (sync thread only), immutable values, fresh presentation each
852
+ # tick — the Group A / weekref discipline (spec §6, Codex F7).
853
+
854
+ _PROJECTS_ENV_WEEK_CACHE: dict = {} # {(bucket_path, week_iso): agg}
855
+ _PROJECTS_ENV_WEEK_TOTALS: dict = {} # {week_iso: total_cost} (also the registry)
856
+ _PROJECTS_ENV_LAST_SEEN: dict = {} # {"max_id", "max_wus_id", "sf_sig"}
857
+
858
+
859
+ def projects_env_week_key(week_start):
860
+ """Canonical UTC-ISO key for a Monday-anchored subscription week start.
861
+
862
+ The dashboard resolves week starts as aware-UTC datetimes; normalizing to
863
+ UTC before serializing keeps the key stable and parseable back by
864
+ ``reconcile_projects_env_cache`` (for the ``week_end`` watermark compare).
865
+ """
866
+ return week_start.astimezone(dt.timezone.utc).isoformat()
867
+
868
+
869
+ def reset_projects_env_state():
870
+ """Clear the projects-envelope week cache + totals + watermark.
871
+
872
+ Called from the orphan-prune site (a prune deletes ``session_entries``
873
+ possibly WITHOUT lowering ``MAX(id)``, so the reconcile's regression check
874
+ cannot catch it — the explicit clear must) and as a test hook.
875
+ """
876
+ _PROJECTS_ENV_WEEK_CACHE.clear()
877
+ _PROJECTS_ENV_WEEK_TOTALS.clear()
878
+ _PROJECTS_ENV_LAST_SEEN.clear()
879
+
880
+
881
+ def session_files_sig(cache_conn) -> "tuple[int, int]":
882
+ """`(COUNT(*), COALESCE(MAX(rowid), 0))` over ``session_files`` (Codex-M4 P2).
883
+
884
+ ``sync_cache`` lazily backfills ``session_files.session_id`` /
885
+ ``project_path`` for OLD entries — moving a closed week's row from
886
+ ``(unknown)`` to a project, or changing a per-week session count — WITHOUT
887
+ advancing ``MAX(session_entries.id)`` / ``MAX(weekly_usage_snapshots.id)``.
888
+ So the envelope cache keys this cheap change-signal and full-clears when it
889
+ moves. Returns ``(0, 0)`` on a missing table (fresh DB) so callers never
890
+ raise.
891
+ """
892
+ try:
893
+ row = cache_conn.execute(
894
+ "SELECT COUNT(*), COALESCE(MAX(rowid), 0) FROM session_files"
895
+ ).fetchone()
896
+ return (int(row[0]), int(row[1]))
897
+ except sqlite3.Error:
898
+ return (0, 0)
899
+
900
+
901
+ def projects_env_week_get(week_iso):
902
+ """Return ``(buckets_by_bucket_path, total_cost)`` for a cached week, or
903
+ ``None`` on a miss.
904
+
905
+ ``_PROJECTS_ENV_WEEK_TOTALS`` is the presence registry: a week is a HIT iff
906
+ its key is present (an empty computed week is stored with total 0.0 and no
907
+ bucket rows, so it is a HIT with an empty bucket map). The bucket rows are
908
+ reassembled by scanning ``_PROJECTS_ENV_WEEK_CACHE`` for this week's keys —
909
+ the distinct-bucket_path count is small (project count), so the scan is
910
+ cheap.
911
+ """
912
+ if week_iso not in _PROJECTS_ENV_WEEK_TOTALS:
913
+ return None
914
+ total = _PROJECTS_ENV_WEEK_TOTALS[week_iso]
915
+ by_bp = {
916
+ bp: agg
917
+ for (bp, wk), agg in _PROJECTS_ENV_WEEK_CACHE.items()
918
+ if wk == week_iso
919
+ }
920
+ return by_bp, total
921
+
922
+
923
+ def projects_env_week_put(week_iso, buckets_by_bp, total) -> None:
924
+ """Store one CLOSED week's per-bucket aggregates + entry-order total.
925
+
926
+ ``total`` is registered even when ``buckets_by_bp`` is empty, so a
927
+ computed-empty week is a HIT (not a perpetual miss). Values are stored
928
+ as-is (immutable); this never mutates a stored aggregate in place.
929
+ """
930
+ _PROJECTS_ENV_WEEK_TOTALS[week_iso] = total
931
+ for bp, agg in buckets_by_bp.items():
932
+ _PROJECTS_ENV_WEEK_CACHE[(bp, week_iso)] = agg
933
+
934
+
935
+ def reconcile_projects_env_cache(cache_conn, *, max_entry_id, max_wus_id, sf_sig):
936
+ """Once-per-non-idle-rebuild invalidation for the projects-envelope cache.
937
+
938
+ Driven by ``_tui_build_snapshot`` after the idle-path check, before the
939
+ envelope builder runs, using the dispatch-signature legs (``max_entry_id``,
940
+ ``max_wus_id``) + a ``session_files`` signal (``sf_sig``) passed in:
941
+
942
+ - Cold (no last-seen): record last-seen, return — no eviction.
943
+ - ``max_wus_id`` changed (attribution denominator), ``sf_sig`` changed
944
+ (attribution backfill, Codex-M4 P2), OR ``max_entry_id < last_seen``
945
+ (cache.db rebuilt): full clear. Conservative but byte-safe (recompute).
946
+ - ``max_entry_id > last_seen``: evict cached weeks (and their bucket rows +
947
+ week total) whose ``week_end (= parse(week_iso) + 7d)`` is
948
+ ``>= new_min_timestamp(cache_conn, last_seen)`` — a genuinely-new row
949
+ could fall inside them (F1 late-ingest). The bound is ``>=`` (Codex-1);
950
+ over-eviction is byte-safe.
951
+
952
+ Idempotent within a tick: after the first call updates last-seen, a later
953
+ call with the same signature sees no delta and no-ops (never re-running the
954
+ watermark query). The short-lived ``cache_conn`` is used only for the
955
+ watermark query on the ``max_entry_id > last_seen`` branch (Codex-4).
956
+ """
957
+ ls = _PROJECTS_ENV_LAST_SEEN
958
+ if not ls: # cold
959
+ ls["max_id"] = max_entry_id
960
+ ls["max_wus_id"] = max_wus_id
961
+ ls["sf_sig"] = sf_sig
962
+ return
963
+ if (
964
+ max_wus_id != ls["max_wus_id"]
965
+ or sf_sig != ls["sf_sig"]
966
+ or max_entry_id < ls["max_id"]
967
+ ):
968
+ _PROJECTS_ENV_WEEK_CACHE.clear()
969
+ _PROJECTS_ENV_WEEK_TOTALS.clear()
970
+ ls["max_id"] = max_entry_id
971
+ ls["max_wus_id"] = max_wus_id
972
+ ls["sf_sig"] = sf_sig
973
+ return
974
+ if max_entry_id > ls["max_id"]:
975
+ wm = new_min_timestamp(cache_conn, ls["max_id"])
976
+ if wm is not None:
977
+ dirty = [
978
+ wk
979
+ for wk in list(_PROJECTS_ENV_WEEK_TOTALS)
980
+ if dt.datetime.fromisoformat(wk) + dt.timedelta(days=7) >= wm
981
+ ]
982
+ for wk in dirty:
983
+ _PROJECTS_ENV_WEEK_TOTALS.pop(wk, None)
984
+ for key in [k for k in _PROJECTS_ENV_WEEK_CACHE if k[1] == wk]:
985
+ del _PROJECTS_ENV_WEEK_CACHE[key]
986
+ ls["max_id"] = max_entry_id
987
+ ls["max_wus_id"] = max_wus_id
988
+ ls["sf_sig"] = sf_sig