cctally 1.68.0 → 1.69.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 +56 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +644 -107
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +168 -5
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +303 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +118 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +298 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +202 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +324 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
|
@@ -247,39 +247,55 @@ def _looks_like_command_plumbing(text) -> bool:
|
|
|
247
247
|
return bool(text) and _CMD_FAMILY_RE.fullmatch(text) is not None
|
|
248
248
|
|
|
249
249
|
|
|
250
|
-
def
|
|
251
|
-
"""{sid:
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
250
|
+
def _session_ai_titles_map(conn, session_ids):
|
|
251
|
+
"""{sid: ai_title} for sessions carrying a TRUTHY ``conversation_ai_titles``
|
|
252
|
+
row (#193). The indexed lookup that was ``_session_titles_map``'s first step,
|
|
253
|
+
extracted so the fill (first-prompt only) and the rail's live overlay share
|
|
254
|
+
ONE source (#302 Q2-B, so stored-vs-live can never drift).
|
|
255
|
+
|
|
256
|
+
Falsey (empty-string) titles are DROPPED — ``ai_title`` is ``NOT NULL`` but
|
|
257
|
+
not non-empty-constrained, so this preserves the prior ``if at:`` guard
|
|
258
|
+
(Codex P1-3) and never suppresses the first-prompt fallback. Tolerates the
|
|
259
|
+
table being absent (pre-migration / :memory:) by returning {}."""
|
|
260
260
|
if not session_ids:
|
|
261
261
|
return {}
|
|
262
|
-
|
|
263
|
-
# #193: AI title wins when present. Query the dedicated table first; the
|
|
264
|
-
# existing first-prompt scan below fills only sessions WITHOUT one (its
|
|
265
|
-
# ``if sid in titles: continue`` guard skips ai-title sessions for free).
|
|
262
|
+
out = {}
|
|
266
263
|
try:
|
|
267
|
-
|
|
264
|
+
ph = ",".join("?" for _ in session_ids)
|
|
268
265
|
for sid, at in conn.execute(
|
|
269
266
|
f"SELECT session_id, ai_title FROM conversation_ai_titles "
|
|
270
|
-
f"WHERE session_id IN ({
|
|
267
|
+
f"WHERE session_id IN ({ph})", tuple(session_ids)
|
|
271
268
|
).fetchall():
|
|
272
269
|
if at:
|
|
273
|
-
|
|
270
|
+
out[sid] = at
|
|
274
271
|
except sqlite3.OperationalError:
|
|
275
|
-
pass # table absent
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
272
|
+
pass # table absent -> {} (caller falls back to the first-prompt title)
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _session_first_prompt_titles_map(conn, session_ids):
|
|
277
|
+
"""{sid: title} from the first non-marker, non-blank MAIN-session human line
|
|
278
|
+
per session — the expensive windowed ``conversation_messages`` scan #302
|
|
279
|
+
materializes onto the rollup. Windowed to the earliest 12 human rows/session
|
|
280
|
+
(rides idx_conv_session_ts); Python skips system markers / command plumbing /
|
|
281
|
+
compaction / notification / bash-echo / (while reingest is pending) skill
|
|
282
|
+
preamble, then strips the remote-control stamp. A session whose first 12
|
|
283
|
+
human rows are all markers/blank is simply absent (caller falls back).
|
|
284
|
+
|
|
285
|
+
Keeps its OWN first-wins guard (``if sid in titles: continue``, re-localized
|
|
286
|
+
to this helper's result dict): once a sid resolves a first-prompt title, later
|
|
287
|
+
rows in its 12-row window do not overwrite it (Codex P1-3).
|
|
288
|
+
|
|
289
|
+
NOTE (Codex P1.2): the window ranks the full per-session human partition before
|
|
290
|
+
rn<=12 — confirmed index-ordered + bounded by the page (≤200 sessions);
|
|
291
|
+
per-session human counts are modest. Honors ``_reingest_pending(conn)``'s
|
|
292
|
+
skip_skill_titles gate: while 005's reingest is pending a stale ``human`` row
|
|
293
|
+
may actually be an injected skill body, so skip those as title candidates
|
|
294
|
+
(gated on the flag so a genuine post-reingest human prompt starting with the
|
|
295
|
+
preamble stays a normal title — Codex code-review P2)."""
|
|
296
|
+
if not session_ids:
|
|
297
|
+
return {}
|
|
298
|
+
titles = {}
|
|
283
299
|
skip_skill_titles = _reingest_pending(conn)
|
|
284
300
|
ph = ",".join("?" for _ in session_ids)
|
|
285
301
|
rows = conn.execute(
|
|
@@ -295,7 +311,7 @@ def _session_titles_map(conn, session_ids):
|
|
|
295
311
|
).fetchall()
|
|
296
312
|
for sid, text in rows:
|
|
297
313
|
if sid in titles:
|
|
298
|
-
continue # already resolved
|
|
314
|
+
continue # first-wins: already resolved a title
|
|
299
315
|
if _is_system_marker(text) or _looks_like_command_plumbing(text):
|
|
300
316
|
continue
|
|
301
317
|
if (_is_compaction_body(text) or _is_notification_body(text)
|
|
@@ -309,6 +325,21 @@ def _session_titles_map(conn, session_ids):
|
|
|
309
325
|
return titles
|
|
310
326
|
|
|
311
327
|
|
|
328
|
+
def _session_titles_map(conn, session_ids):
|
|
329
|
+
"""{sid: title} — the TRUTHY AI title wins, else the first-prompt title.
|
|
330
|
+
Contract UNCHANGED (the live/degraded rail branch still calls this as-is); now
|
|
331
|
+
single-sources the two seams (``_session_ai_titles_map`` +
|
|
332
|
+
``_session_first_prompt_titles_map``) so the fill (first-prompt only) and the
|
|
333
|
+
rail overlay (AI live) cannot drift (#302). #193: the AI title wins when
|
|
334
|
+
present; the first-prompt scan fills only sessions WITHOUT one."""
|
|
335
|
+
if not session_ids:
|
|
336
|
+
return {}
|
|
337
|
+
titles = dict(_session_ai_titles_map(conn, session_ids))
|
|
338
|
+
for sid, t in _session_first_prompt_titles_map(conn, session_ids).items():
|
|
339
|
+
titles.setdefault(sid, t) # AI title (truthy) wins; else first-prompt
|
|
340
|
+
return titles
|
|
341
|
+
|
|
342
|
+
|
|
312
343
|
def _project_label(cwd) -> str:
|
|
313
344
|
"""Basename of the project cwd (dashboard label posture — no reveal). Falls
|
|
314
345
|
back to the raw path for root-ish cwds, '' when absent."""
|
|
@@ -1065,8 +1096,10 @@ def _model_clause(conn, families):
|
|
|
1065
1096
|
to everything. Family classification goes through ``_chip_for_model`` — the
|
|
1066
1097
|
SAME classifier ``list_conversation_facets`` uses — so a family's facet count
|
|
1067
1098
|
equals the rows its chip yields, and 'any appearance' semantics + correct
|
|
1068
|
-
'other'-exclusion come for free. 'other' is
|
|
1069
|
-
|
|
1099
|
+
'other'-exclusion come for free. 'other' is never emitted by the client, but a
|
|
1100
|
+
hand-crafted models=other would collect unknown-family model ids
|
|
1101
|
+
(``_chip_for_model`` returns 'other' for unrecognized ids); it is simply not a
|
|
1102
|
+
normal selectable family.
|
|
1070
1103
|
|
|
1071
1104
|
The fragment ANDs into any WHERE that has a ``session_id`` column, so the
|
|
1072
1105
|
IDENTICAL string composes into both the ``conversation_sessions`` rollup and
|
|
@@ -1085,9 +1118,18 @@ def _model_clause(conn, families):
|
|
|
1085
1118
|
# Present-but-empty: match nothing (NOT ("", []) which would be unfiltered).
|
|
1086
1119
|
return "session_id IN (SELECT session_id FROM conversation_messages WHERE 0)", []
|
|
1087
1120
|
ph = ",".join("?" for _ in ids)
|
|
1121
|
+
# #301: pin the partial covering index idx_conversation_messages_model_session so
|
|
1122
|
+
# this ?models= filter is an index-only model seek, never a heap-walk. The explicit
|
|
1123
|
+
# `model IS NOT NULL AND model != ''` makes the partial index eligible (SQLite won't
|
|
1124
|
+
# infer it from `model IN (...)`) — a no-op since ids are all non-null/non-empty.
|
|
1125
|
+
# DISTINCT dropped: this is the RHS of `session_id IN (...)`, where duplicates don't
|
|
1126
|
+
# affect membership, so dropping it is output-identical and removes a temp b-tree.
|
|
1127
|
+
# INDEXED BY makes the covering plan deterministic even if the DB has been ANALYZEd.
|
|
1088
1128
|
return (
|
|
1089
|
-
"session_id IN (SELECT
|
|
1090
|
-
"
|
|
1129
|
+
"session_id IN (SELECT session_id FROM conversation_messages "
|
|
1130
|
+
"INDEXED BY idx_conversation_messages_model_session "
|
|
1131
|
+
"WHERE session_id IS NOT NULL AND model IS NOT NULL AND model != '' "
|
|
1132
|
+
"AND model IN (%s))" % ph,
|
|
1091
1133
|
ids,
|
|
1092
1134
|
)
|
|
1093
1135
|
|
|
@@ -1199,17 +1241,21 @@ def _list_session_rows_rollup(conn, order, limit, offset, filters=None,
|
|
|
1199
1241
|
model_clause=("", [])):
|
|
1200
1242
|
"""FAST path: read the pre-aggregated rail rows straight from the
|
|
1201
1243
|
conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
|
|
1202
|
-
``recent`` sort. Returns (session_id, msg_count, started, last_activity
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1244
|
+
``recent`` sort. Returns (session_id, msg_count, started, last_activity,
|
|
1245
|
+
cost_usd, project_label, git_branch, models_json, title) tuples — the 4
|
|
1246
|
+
structural columns PLUS the #302 materialized enrichment the rail displays,
|
|
1247
|
+
riding along on rows already fetched (same WHERE/ORDER BY/LIMIT/OFFSET). The
|
|
1248
|
+
live aggregate yields only the 4 structural columns, so list_conversations
|
|
1249
|
+
unpacks per branch. The optional ``filters`` dict pushes the four-axis browse
|
|
1250
|
+
predicate (date/project/cost/rebuild — all stored columns) into the WHERE
|
|
1251
|
+
BEFORE LIMIT/OFFSET, so ``has_more``/``next_offset`` stay correct over the
|
|
1252
|
+
filtered set. ``model_clause`` (#278 Theme C) is the precomputed model
|
|
1208
1253
|
predicate folded into the same WHERE."""
|
|
1209
1254
|
where, params = _rollup_where(filters or _empty_filters(), model_clause)
|
|
1210
1255
|
return conn.execute(
|
|
1211
1256
|
"SELECT session_id, msg_count, "
|
|
1212
|
-
" started_utc AS started, last_activity_utc AS last_activity "
|
|
1257
|
+
" started_utc AS started, last_activity_utc AS last_activity, "
|
|
1258
|
+
" cost_usd, project_label, git_branch, models_json, title "
|
|
1213
1259
|
"FROM conversation_sessions"
|
|
1214
1260
|
+ where +
|
|
1215
1261
|
" ORDER BY " + order + " LIMIT ? OFFSET ?",
|
|
@@ -1255,16 +1301,22 @@ def list_conversation_facets(conn) -> dict:
|
|
|
1255
1301
|
"""Distinct projects (+ conversation counts) AND per-model-family session
|
|
1256
1302
|
counts for the browse filter multi-selects (spec §2 + #278 Theme C).
|
|
1257
1303
|
|
|
1258
|
-
``projects`` reads the rollup; cheap GROUP BY
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
popover renders a
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
``
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1304
|
+
``projects`` reads the ``conversation_sessions`` rollup; a cheap GROUP BY over
|
|
1305
|
+
the stored ``project_label`` column. Empty/NULL project labels are dropped (a
|
|
1306
|
+
no-cwd session stores '' and a not-yet-filled row stores NULL — neither is a
|
|
1307
|
+
real selectable project). Sorted ascending by label so the popover renders a
|
|
1308
|
+
stable list.
|
|
1309
|
+
|
|
1310
|
+
``models``, by contrast, does NOT touch the rollup — it scans
|
|
1311
|
+
``conversation_messages`` directly. It folds distinct ``(session_id, model)``
|
|
1312
|
+
pairs (where ``session_id IS NOT NULL AND model IS NOT NULL AND model != ''`` —
|
|
1313
|
+
matching the browse/live row sources, so a null-session row can't inflate a
|
|
1314
|
+
count above the filterable sessions) to distinct ``(session_id, family)`` via
|
|
1315
|
+
``_chip_for_model``, then counts sessions per family. Since #301 that DISTINCT
|
|
1316
|
+
is an index-only walk of the partial covering index
|
|
1317
|
+
``idx_conversation_messages_model_session`` on
|
|
1318
|
+
``conversation_messages(model, session_id)`` (a full index->heap walk of the
|
|
1319
|
+
whole table — ~22s cold on a large cache — before the index existed). Fold-then-count, NOT
|
|
1268
1320
|
sum-of-per-model-distinct: a session that used two Opus point-releases counts
|
|
1269
1321
|
ONCE under opus, and an Opus+Haiku session counts under BOTH. Families are
|
|
1270
1322
|
emitted present-only, excluding ``other``, in a fixed display order so the
|
|
@@ -1338,7 +1390,11 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
1338
1390
|
model_clause = _model_clause(conn, filters["models"])
|
|
1339
1391
|
degraded = False
|
|
1340
1392
|
sort_degraded = False
|
|
1341
|
-
|
|
1393
|
+
# Compute the authoritative decision ONCE and reuse it for BOTH the row
|
|
1394
|
+
# source and the enrichment split — the same predicate must key both, or a
|
|
1395
|
+
# race between them could unpack a rollup 9-tuple as a live 4-tuple.
|
|
1396
|
+
authoritative = _rollup_authoritative(conn)
|
|
1397
|
+
if authoritative:
|
|
1342
1398
|
order = _SORTS.get(sort, _SORTS["recent"])
|
|
1343
1399
|
rows = _list_session_rows_rollup(conn, order, limit, offset, filters,
|
|
1344
1400
|
model_clause)
|
|
@@ -1355,25 +1411,58 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
1355
1411
|
has_more = len(rows) > limit
|
|
1356
1412
|
rows = rows[:limit]
|
|
1357
1413
|
session_ids = [r[0] for r in rows]
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1414
|
+
if authoritative:
|
|
1415
|
+
# FAST PATH (#302): cost/project/git_branch/models are materialized on
|
|
1416
|
+
# the rollup rows, and title is the stored STABLE first-prompt title. The
|
|
1417
|
+
# ONLY per-page read besides the rollup rows is the cheap indexed
|
|
1418
|
+
# conversation_ai_titles overlay — NO _session_cost_map /
|
|
1419
|
+
# _session_models_map / _session_latest_meta_map /
|
|
1420
|
+
# _session_first_prompt_titles_map (each an O(messages-on-page)
|
|
1421
|
+
# conversation_messages re-read). By `or`-associativity the emitted dict
|
|
1422
|
+
# is byte-identical to the live branch below: rollup_title is the stored
|
|
1423
|
+
# first-prompt title and project_label is the stored _project_label(cwd),
|
|
1424
|
+
# so `ai or rollup_title or project_label or sid` == the live
|
|
1425
|
+
# `titles.get(sid) or _project_label(cwd) or sid` (AI title wins live too).
|
|
1426
|
+
ai = _session_ai_titles_map(conn, session_ids)
|
|
1427
|
+
conversations = [
|
|
1428
|
+
{
|
|
1429
|
+
"session_id": sid,
|
|
1430
|
+
"title": ai.get(sid) or rollup_title or project_label or sid,
|
|
1431
|
+
"project_label": project_label,
|
|
1432
|
+
"git_branch": git_branch,
|
|
1433
|
+
"started_utc": started,
|
|
1434
|
+
"last_activity_utc": last_activity,
|
|
1435
|
+
"msg_count": msg_count,
|
|
1436
|
+
# cost_usd is already 6dp-rounded at fill; keep the round for safety.
|
|
1437
|
+
"cost_usd": round(cost_usd or 0.0, 6),
|
|
1438
|
+
"models": _json.loads(models_json) if models_json else [],
|
|
1439
|
+
}
|
|
1440
|
+
for (sid, msg_count, started, last_activity, cost_usd,
|
|
1441
|
+
project_label, git_branch, models_json, rollup_title) in rows
|
|
1442
|
+
]
|
|
1443
|
+
else:
|
|
1444
|
+
# LIVE/DEGRADED branch (backfill pending / --no-sync): UNCHANGED — recompute
|
|
1445
|
+
# enrichment at request time via the four retained maps over
|
|
1446
|
+
# conversation_messages. Correct, possibly slower, never an empty rail.
|
|
1447
|
+
costs = _session_cost_map(conn, session_ids)
|
|
1448
|
+
models = _session_models_map(conn, session_ids)
|
|
1449
|
+
# cwd/git_branch as the latest non-null (reader posture), NOT a lexical MAX().
|
|
1450
|
+
meta = _session_latest_meta_map(conn, session_ids)
|
|
1451
|
+
titles = _session_titles_map(conn, session_ids)
|
|
1452
|
+
conversations = [
|
|
1453
|
+
{
|
|
1454
|
+
"session_id": sid,
|
|
1455
|
+
"title": titles.get(sid) or _project_label(meta.get(sid, (None, None))[0]) or sid,
|
|
1456
|
+
"project_label": _project_label(meta.get(sid, (None, None))[0]),
|
|
1457
|
+
"git_branch": meta.get(sid, (None, None))[1],
|
|
1458
|
+
"started_utc": started,
|
|
1459
|
+
"last_activity_utc": last_activity,
|
|
1460
|
+
"msg_count": msg_count,
|
|
1461
|
+
"cost_usd": round(costs.get(sid, 0.0), 6),
|
|
1462
|
+
"models": models.get(sid, []),
|
|
1463
|
+
}
|
|
1464
|
+
for (sid, msg_count, started, last_activity) in rows
|
|
1465
|
+
]
|
|
1377
1466
|
page = {
|
|
1378
1467
|
"next_offset": offset + len(conversations) if has_more else None,
|
|
1379
1468
|
"has_more": has_more,
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -199,6 +199,11 @@ class DoctorState:
|
|
|
199
199
|
# DOCTOR_WAL_WARN_BYTES (2x the WAL cap) — only when the journal_size_limit
|
|
200
200
|
# + forced-checkpoint machinery has genuinely failed to contain the WAL.
|
|
201
201
|
cache_db_wal_bytes: Optional[int] = None
|
|
202
|
+
# #294 S2: root-qualified physical Codex quota freshness, per-root native
|
|
203
|
+
# hook state, and lifecycle activity are gathered by _cctally_doctor.
|
|
204
|
+
codex_quota_windows: Optional[list[dict]] = None
|
|
205
|
+
codex_hook_roots: Optional[list[dict]] = None
|
|
206
|
+
codex_lifecycle_activity_24h: Optional[dict] = None
|
|
202
207
|
|
|
203
208
|
|
|
204
209
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -763,6 +768,200 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
|
|
|
763
768
|
)
|
|
764
769
|
|
|
765
770
|
|
|
771
|
+
def _check_data_codex_quota(s: DoctorState) -> CheckResult:
|
|
772
|
+
"""Report local physical Codex quota evidence without fabricating a window."""
|
|
773
|
+
raw_windows = s.codex_quota_windows or []
|
|
774
|
+
if not raw_windows:
|
|
775
|
+
has_unsafe_corpus = bool(s.codex_jsonl_present)
|
|
776
|
+
return CheckResult(
|
|
777
|
+
id="data.codex_quota", title="Codex quota",
|
|
778
|
+
severity="warn" if has_unsafe_corpus else "ok",
|
|
779
|
+
summary=("no safely interpreted windows" if has_unsafe_corpus
|
|
780
|
+
else "not applicable"),
|
|
781
|
+
remediation=("Run `cctally cache-sync --source codex --rebuild`"
|
|
782
|
+
if has_unsafe_corpus else None),
|
|
783
|
+
details={
|
|
784
|
+
"window_count": 0,
|
|
785
|
+
"latest_capture_at": None,
|
|
786
|
+
"freshness_state": "unavailable",
|
|
787
|
+
"age_seconds": None,
|
|
788
|
+
"stale_after_seconds": None,
|
|
789
|
+
"responsible_identity": None,
|
|
790
|
+
"windows": [],
|
|
791
|
+
},
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
def identity_key(row: dict) -> tuple[str, str, str, str, int]:
|
|
795
|
+
identity = row.get("identity") or {}
|
|
796
|
+
return (
|
|
797
|
+
str(identity.get("source") or ""),
|
|
798
|
+
str(identity.get("source_root_key") or ""),
|
|
799
|
+
str(identity.get("logical_limit_key") or ""),
|
|
800
|
+
str(identity.get("observed_slot") or ""),
|
|
801
|
+
int(identity.get("window_minutes") or 0),
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
freshness_order = {"future": 0, "stale": 1, "unavailable": 2, "fresh": 3}
|
|
805
|
+
windows: list[dict] = []
|
|
806
|
+
for row in sorted(raw_windows, key=identity_key):
|
|
807
|
+
captured_at = row.get("latest_capture_at")
|
|
808
|
+
windows.append({
|
|
809
|
+
"identity": row.get("identity"),
|
|
810
|
+
"latest_capture_at": (_iso_z(captured_at)
|
|
811
|
+
if isinstance(captured_at, dt.datetime)
|
|
812
|
+
else captured_at),
|
|
813
|
+
"freshness_state": row.get("freshness_state") or "unavailable",
|
|
814
|
+
"age_seconds": row.get("age_seconds"),
|
|
815
|
+
"stale_after_seconds": row.get("stale_after_seconds"),
|
|
816
|
+
})
|
|
817
|
+
worst_rank = min(freshness_order.get(row["freshness_state"], 2) for row in windows)
|
|
818
|
+
responsible = next(
|
|
819
|
+
row for row in windows
|
|
820
|
+
if freshness_order.get(row["freshness_state"], 2) == worst_rank
|
|
821
|
+
)
|
|
822
|
+
captures = [
|
|
823
|
+
row.get("latest_capture_at") for row in raw_windows
|
|
824
|
+
if isinstance(row.get("latest_capture_at"), dt.datetime)
|
|
825
|
+
]
|
|
826
|
+
latest_capture_at = _iso_z(max(captures)) if captures else None
|
|
827
|
+
state = responsible["freshness_state"]
|
|
828
|
+
return CheckResult(
|
|
829
|
+
id="data.codex_quota", title="Codex quota",
|
|
830
|
+
severity="ok" if state == "fresh" else "warn",
|
|
831
|
+
summary=f"{len(windows)} window(s); {state}",
|
|
832
|
+
remediation=(None if state == "fresh"
|
|
833
|
+
else "Run `cctally cache-sync --source codex` after Codex activity."),
|
|
834
|
+
details={
|
|
835
|
+
"window_count": len(windows),
|
|
836
|
+
"latest_capture_at": latest_capture_at,
|
|
837
|
+
"freshness_state": state,
|
|
838
|
+
"age_seconds": responsible["age_seconds"],
|
|
839
|
+
"stale_after_seconds": responsible["stale_after_seconds"],
|
|
840
|
+
"responsible_identity": responsible["identity"],
|
|
841
|
+
"windows": windows,
|
|
842
|
+
},
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _check_hooks_codex_installed(s: DoctorState) -> CheckResult:
|
|
847
|
+
"""Summarize every configured Codex root without masking a bad sibling."""
|
|
848
|
+
rows = sorted(
|
|
849
|
+
(row for row in (s.codex_hook_roots or []) if isinstance(row, dict)),
|
|
850
|
+
key=lambda row: str(row.get("source_root_key") or ""),
|
|
851
|
+
)
|
|
852
|
+
states = [
|
|
853
|
+
{"source_root_key": row.get("source_root_key"), "state": row.get("state")}
|
|
854
|
+
for row in rows
|
|
855
|
+
]
|
|
856
|
+
installed_states = {
|
|
857
|
+
"installed_review_required", "installed_trust_unobservable",
|
|
858
|
+
}
|
|
859
|
+
installed = [row for row in rows if row.get("state") in installed_states]
|
|
860
|
+
requires_review = (
|
|
861
|
+
True if any(row.get("state") == "installed_review_required" for row in rows)
|
|
862
|
+
else None if installed
|
|
863
|
+
else False
|
|
864
|
+
)
|
|
865
|
+
trust_state = (
|
|
866
|
+
"not-applicable" if not rows
|
|
867
|
+
else "review-required" if requires_review is True
|
|
868
|
+
else "unobservable" if installed
|
|
869
|
+
else "not-installed"
|
|
870
|
+
)
|
|
871
|
+
unhealthy = any(row.get("state") not in installed_states for row in rows)
|
|
872
|
+
return CheckResult(
|
|
873
|
+
id="hooks.codex_installed", title="Codex hooks installed",
|
|
874
|
+
severity="warn" if unhealthy else "ok",
|
|
875
|
+
summary=("not applicable" if not rows
|
|
876
|
+
else f"{len(installed)}/{len(rows)} root(s) installed"),
|
|
877
|
+
remediation=("Run `cctally setup`, then review the handler in Codex /hooks."
|
|
878
|
+
if unhealthy else None),
|
|
879
|
+
details={
|
|
880
|
+
"root_count": len(rows),
|
|
881
|
+
"installed_root_count": len(installed),
|
|
882
|
+
"states": states,
|
|
883
|
+
"requires_review": requires_review,
|
|
884
|
+
"trust_state": trust_state,
|
|
885
|
+
},
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _check_hooks_codex_recent_activity(s: DoctorState) -> CheckResult:
|
|
890
|
+
"""Aggregate 24-hour lifecycle records only for installed root handlers."""
|
|
891
|
+
installed_states = {
|
|
892
|
+
"installed_review_required", "installed_trust_unobservable",
|
|
893
|
+
}
|
|
894
|
+
installed_keys = sorted(
|
|
895
|
+
str(row.get("source_root_key"))
|
|
896
|
+
for row in (s.codex_hook_roots or [])
|
|
897
|
+
if isinstance(row, dict) and row.get("state") in installed_states
|
|
898
|
+
and row.get("source_root_key")
|
|
899
|
+
)
|
|
900
|
+
if not installed_keys:
|
|
901
|
+
return CheckResult(
|
|
902
|
+
id="hooks.codex_recent_activity", title="Codex recent activity",
|
|
903
|
+
severity="ok", summary="not applicable",
|
|
904
|
+
remediation=None,
|
|
905
|
+
details={
|
|
906
|
+
"activity_state": "not-applicable",
|
|
907
|
+
"last_tick_at": None,
|
|
908
|
+
"age_seconds": None,
|
|
909
|
+
"success_count_24h": 0,
|
|
910
|
+
"error_count_24h": 0,
|
|
911
|
+
"responsible_root_key": None,
|
|
912
|
+
"roots": [],
|
|
913
|
+
},
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
activity = s.codex_lifecycle_activity_24h or {}
|
|
917
|
+
roots: list[dict] = []
|
|
918
|
+
for key in installed_keys:
|
|
919
|
+
row = activity.get(key) if isinstance(activity, dict) else None
|
|
920
|
+
row = row if isinstance(row, dict) else {}
|
|
921
|
+
last_tick_at = row.get("last_tick_at")
|
|
922
|
+
if isinstance(last_tick_at, dt.datetime):
|
|
923
|
+
age_seconds = max(0, int((s.now_utc - last_tick_at).total_seconds()))
|
|
924
|
+
tick_wire = _iso_z(last_tick_at)
|
|
925
|
+
else:
|
|
926
|
+
age_seconds = None
|
|
927
|
+
tick_wire = None
|
|
928
|
+
if tick_wire is None:
|
|
929
|
+
state = "never"
|
|
930
|
+
elif age_seconds is not None and age_seconds > 24 * 3600:
|
|
931
|
+
state = "stale"
|
|
932
|
+
else:
|
|
933
|
+
state = "recent"
|
|
934
|
+
roots.append({
|
|
935
|
+
"source_root_key": key,
|
|
936
|
+
"activity_state": state,
|
|
937
|
+
"last_tick_at": tick_wire,
|
|
938
|
+
"age_seconds": age_seconds,
|
|
939
|
+
"success_count_24h": int(row.get("success_count_24h") or 0),
|
|
940
|
+
"error_count_24h": int(row.get("error_count_24h") or 0),
|
|
941
|
+
})
|
|
942
|
+
worst_order = {"never": 0, "stale": 1, "recent": 2}
|
|
943
|
+
worst_rank = min(worst_order[row["activity_state"]] for row in roots)
|
|
944
|
+
responsible = next(
|
|
945
|
+
row for row in roots if worst_order[row["activity_state"]] == worst_rank
|
|
946
|
+
)
|
|
947
|
+
return CheckResult(
|
|
948
|
+
id="hooks.codex_recent_activity", title="Codex recent activity",
|
|
949
|
+
severity="ok" if responsible["activity_state"] == "recent" else "warn",
|
|
950
|
+
summary=f"{len(roots)} installed root(s); {responsible['activity_state']}",
|
|
951
|
+
remediation=(None if responsible["activity_state"] == "recent"
|
|
952
|
+
else "Trigger Codex activity, then verify `cctally setup` hooks."),
|
|
953
|
+
details={
|
|
954
|
+
"activity_state": responsible["activity_state"],
|
|
955
|
+
"last_tick_at": responsible["last_tick_at"],
|
|
956
|
+
"age_seconds": responsible["age_seconds"],
|
|
957
|
+
"success_count_24h": responsible["success_count_24h"],
|
|
958
|
+
"error_count_24h": responsible["error_count_24h"],
|
|
959
|
+
"responsible_root_key": responsible["source_root_key"],
|
|
960
|
+
"roots": roots,
|
|
961
|
+
},
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
|
|
766
965
|
def _check_data_forked_buckets(s: DoctorState) -> CheckResult:
|
|
767
966
|
"""Invariant: for every row with ``week_start_at IS NOT NULL``,
|
|
768
967
|
``week_start_date == substr(week_start_at, 1, 10)``.
|
|
@@ -1393,6 +1592,8 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1393
1592
|
("hooks.installed", "_check_hooks_installed"),
|
|
1394
1593
|
("hooks.recent_activity_24h", "_check_hooks_recent_activity_24h"),
|
|
1395
1594
|
("hooks.last_fire_age", "_check_hooks_last_fire_age"),
|
|
1595
|
+
("hooks.codex_installed", "_check_hooks_codex_installed"),
|
|
1596
|
+
("hooks.codex_recent_activity", "_check_hooks_codex_recent_activity"),
|
|
1396
1597
|
)),
|
|
1397
1598
|
("auth", "Auth", (
|
|
1398
1599
|
("oauth.token_present", "_check_oauth_token_present"),
|
|
@@ -1411,6 +1612,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1411
1612
|
("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
|
|
1412
1613
|
("data.cache_sync_state", "_check_data_cache_sync_state"),
|
|
1413
1614
|
("data.codex_cache", "_check_data_codex_cache"),
|
|
1615
|
+
("data.codex_quota", "_check_data_codex_quota"),
|
|
1414
1616
|
("data.parse_health", "_check_data_parse_health"),
|
|
1415
1617
|
("data.forked_buckets", "_check_data_forked_buckets"),
|
|
1416
1618
|
("data.post_credit_milestones", "_check_data_post_credit_milestones"),
|