cctally 1.88.2 → 1.89.1
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 +32 -0
- package/bin/_cctally_cache.py +827 -37
- package/bin/_cctally_config.py +125 -0
- package/bin/_cctally_core.py +86 -2
- package/bin/_cctally_dashboard_cache_report.py +31 -0
- package/bin/_cctally_dashboard_conversation.py +26 -7
- package/bin/_cctally_dashboard_sources.py +51 -0
- package/bin/_cctally_db.py +626 -0
- package/bin/_cctally_doctor.py +84 -1
- package/bin/_cctally_journal.py +2 -1
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_quota.py +1358 -112
- package/bin/_cctally_record.py +249 -9
- package/bin/_cctally_setup.py +14 -5
- package/bin/_cctally_store.py +16 -1
- package/bin/_cctally_tui.py +16 -2
- package/bin/_cctally_update.py +9 -2
- package/bin/_lib_background_mcp.py +168 -0
- package/bin/_lib_cache_report.py +19 -2
- package/bin/_lib_codex_conversation.py +8 -0
- package/bin/_lib_codex_conversation_query.py +8 -7
- package/bin/_lib_conversation.py +105 -5
- package/bin/_lib_conversation_dispatch.py +15 -4
- package/bin/_lib_conversation_query.py +294 -2
- package/bin/_lib_dashboard_sources.py +5 -1
- package/bin/_lib_doctor.py +202 -1
- package/bin/_lib_jsonl.py +12 -0
- package/bin/_lib_quota_alert_axes.py +188 -0
- package/bin/_lib_quota_ledger.py +274 -0
- package/bin/_lib_snapshot_cache.py +36 -0
- package/bin/cctally +6 -3
- package/dashboard/static/assets/index-BgoYXdus.js +92 -0
- package/dashboard/static/assets/index-Ub8vwz1M.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-B0ZCsoxI.css +0 -1
- package/dashboard/static/assets/index-Bvp8mxtz.js +0 -92
package/bin/_cctally_config.py
CHANGED
|
@@ -331,9 +331,23 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
331
331
|
"budget.codex.accounts",
|
|
332
332
|
"telemetry.enabled",
|
|
333
333
|
"conversation.retention_days",
|
|
334
|
+
"codex.hook.ingest_budget_seconds",
|
|
334
335
|
)
|
|
335
336
|
|
|
336
337
|
|
|
338
|
+
#: Wall-clock budget the native Codex hook gives its ingest leg (public #5
|
|
339
|
+
#: spec §4). It applies to the HOOK path only — an explicit `cctally cache-sync`
|
|
340
|
+
#: still runs to completion.
|
|
341
|
+
CODEX_HOOK_INGEST_BUDGET_DEFAULT_SECONDS = 5.0
|
|
342
|
+
|
|
343
|
+
#: Codex kills a hook at 30 seconds. The budget is capped strictly BELOW that,
|
|
344
|
+
#: not merely validated positive: a configured 60 would guarantee the timeout
|
|
345
|
+
#: this whole change exists to remove, and the cap leaves room for the rest of
|
|
346
|
+
#: the tick (the reconcile, the budget evaluation, interpreter startup).
|
|
347
|
+
CODEX_HOOK_TIMEOUT_SECONDS = 30.0
|
|
348
|
+
CODEX_HOOK_INGEST_BUDGET_MAX_SECONDS = 20.0
|
|
349
|
+
|
|
350
|
+
|
|
337
351
|
_CODEX_BUDGET_LEAF_PREFIX = "budget.codex."
|
|
338
352
|
|
|
339
353
|
_DEFAULT_CONVERSATION_RETENTION_DAYS = 90
|
|
@@ -374,6 +388,62 @@ def _validate_retention_days_value(raw: object) -> int:
|
|
|
374
388
|
return value
|
|
375
389
|
|
|
376
390
|
|
|
391
|
+
def _validate_codex_hook_ingest_budget_value(raw: object) -> float:
|
|
392
|
+
"""Validate a ``config set`` value for ``codex.hook.ingest_budget_seconds``.
|
|
393
|
+
|
|
394
|
+
Positive is not enough. Codex kills a hook at
|
|
395
|
+
``CODEX_HOOK_TIMEOUT_SECONDS``, so a value at or above
|
|
396
|
+
``CODEX_HOOK_INGEST_BUDGET_MAX_SECONDS`` is rejected outright rather than
|
|
397
|
+
silently clamped: an operator who typed 60 asked for exactly the timeout
|
|
398
|
+
this budget exists to prevent, and a clamp would hide that.
|
|
399
|
+
"""
|
|
400
|
+
if isinstance(raw, bool):
|
|
401
|
+
raise ValueError(
|
|
402
|
+
"codex.hook.ingest_budget_seconds must be a positive number")
|
|
403
|
+
if isinstance(raw, str):
|
|
404
|
+
try:
|
|
405
|
+
value = float(raw.strip())
|
|
406
|
+
except (TypeError, ValueError):
|
|
407
|
+
raise ValueError(
|
|
408
|
+
"codex.hook.ingest_budget_seconds must be a positive number, "
|
|
409
|
+
f"got {raw!r}")
|
|
410
|
+
elif isinstance(raw, (int, float)):
|
|
411
|
+
value = float(raw)
|
|
412
|
+
else:
|
|
413
|
+
raise ValueError(
|
|
414
|
+
"codex.hook.ingest_budget_seconds must be a positive number")
|
|
415
|
+
if not (value > 0) or value != value or value in (float("inf"),):
|
|
416
|
+
raise ValueError(
|
|
417
|
+
"codex.hook.ingest_budget_seconds must be > 0")
|
|
418
|
+
if value >= CODEX_HOOK_INGEST_BUDGET_MAX_SECONDS:
|
|
419
|
+
raise ValueError(
|
|
420
|
+
"codex.hook.ingest_budget_seconds must be < "
|
|
421
|
+
f"{CODEX_HOOK_INGEST_BUDGET_MAX_SECONDS:g} (Codex kills a hook at "
|
|
422
|
+
f"{CODEX_HOOK_TIMEOUT_SECONDS:g}s)")
|
|
423
|
+
return value
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def resolve_codex_hook_ingest_budget(config: dict) -> float:
|
|
427
|
+
"""The effective hook ingest budget in seconds.
|
|
428
|
+
|
|
429
|
+
Any malformed or out-of-range persisted value degrades to the default
|
|
430
|
+
rather than raising — a hand-edited config must never be able to make the
|
|
431
|
+
hook itself fail, and the default is the safe direction here.
|
|
432
|
+
"""
|
|
433
|
+
default = CODEX_HOOK_INGEST_BUDGET_DEFAULT_SECONDS
|
|
434
|
+
block = config.get("codex") if isinstance(config, dict) else None
|
|
435
|
+
if not isinstance(block, dict):
|
|
436
|
+
return default
|
|
437
|
+
hook = block.get("hook")
|
|
438
|
+
if not isinstance(hook, dict):
|
|
439
|
+
return default
|
|
440
|
+
try:
|
|
441
|
+
return _validate_codex_hook_ingest_budget_value(
|
|
442
|
+
hook.get("ingest_budget_seconds"))
|
|
443
|
+
except ValueError:
|
|
444
|
+
return default
|
|
445
|
+
|
|
446
|
+
|
|
377
447
|
def resolve_retention_days(config: dict) -> int:
|
|
378
448
|
"""Effective conversation transcript retention in days (F8).
|
|
379
449
|
|
|
@@ -981,6 +1051,10 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
981
1051
|
# Effective transcript-retention window in days (default 180; 0 = keep
|
|
982
1052
|
# forever). Malformed persisted data surfaces the safe default (F8).
|
|
983
1053
|
return resolve_retention_days(config)
|
|
1054
|
+
if key == "codex.hook.ingest_budget_seconds":
|
|
1055
|
+
# Wall-clock budget for the native Codex hook's ingest leg (public #5).
|
|
1056
|
+
# Malformed or out-of-range persisted data surfaces the default.
|
|
1057
|
+
return resolve_codex_hook_ingest_budget(config)
|
|
984
1058
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
985
1059
|
# Defaults mirror `_is_update_check_due` (True / 24 hours).
|
|
986
1060
|
# Hand-edited junk surfaces as the default — matches dashboard.bind.
|
|
@@ -1623,6 +1697,41 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1623
1697
|
rendered = str(normalized)
|
|
1624
1698
|
print(f"{key}={rendered}")
|
|
1625
1699
|
return 0
|
|
1700
|
+
if key == "codex.hook.ingest_budget_seconds":
|
|
1701
|
+
# Validate first; rejection short-circuits before lock acquisition.
|
|
1702
|
+
try:
|
|
1703
|
+
budget = _validate_codex_hook_ingest_budget_value(raw)
|
|
1704
|
+
except ValueError as exc:
|
|
1705
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
1706
|
+
return 2
|
|
1707
|
+
with config_writer_lock():
|
|
1708
|
+
config = _load_config_unlocked()
|
|
1709
|
+
existing = config.get("codex")
|
|
1710
|
+
if existing is not None and not isinstance(existing, dict):
|
|
1711
|
+
print(
|
|
1712
|
+
"cctally: codex config error: codex must be an object",
|
|
1713
|
+
file=sys.stderr,
|
|
1714
|
+
)
|
|
1715
|
+
return 2
|
|
1716
|
+
block = dict(existing or {})
|
|
1717
|
+
hook = block.get("hook")
|
|
1718
|
+
if hook is not None and not isinstance(hook, dict):
|
|
1719
|
+
print(
|
|
1720
|
+
"cctally: codex config error: codex.hook must be an object",
|
|
1721
|
+
file=sys.stderr,
|
|
1722
|
+
)
|
|
1723
|
+
return 2
|
|
1724
|
+
hook = dict(hook or {})
|
|
1725
|
+
hook["ingest_budget_seconds"] = budget
|
|
1726
|
+
block["hook"] = hook
|
|
1727
|
+
config["codex"] = block
|
|
1728
|
+
save_config(config)
|
|
1729
|
+
if getattr(args, "emit_json", False):
|
|
1730
|
+
print(json.dumps(
|
|
1731
|
+
{"codex": {"hook": {"ingest_budget_seconds": budget}}}, indent=2))
|
|
1732
|
+
else:
|
|
1733
|
+
print(f"{key}={budget:g}")
|
|
1734
|
+
return 0
|
|
1626
1735
|
if key == "conversation.retention_days":
|
|
1627
1736
|
# Validate first; rejection short-circuits before lock acquisition (F8).
|
|
1628
1737
|
try:
|
|
@@ -2159,6 +2268,22 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
2159
2268
|
save_config(config)
|
|
2160
2269
|
# idempotent: silent on missing key
|
|
2161
2270
|
return 0
|
|
2271
|
+
if key == "codex.hook.ingest_budget_seconds":
|
|
2272
|
+
# Mirror the conversation.retention_days branch: drop the leaf, then
|
|
2273
|
+
# prune an empty `hook` and an empty `codex` so config.json stays tidy.
|
|
2274
|
+
with config_writer_lock():
|
|
2275
|
+
config = _load_config_unlocked()
|
|
2276
|
+
block = config.get("codex")
|
|
2277
|
+
hook = block.get("hook") if isinstance(block, dict) else None
|
|
2278
|
+
if isinstance(hook, dict) and "ingest_budget_seconds" in hook:
|
|
2279
|
+
del hook["ingest_budget_seconds"]
|
|
2280
|
+
if not hook:
|
|
2281
|
+
block.pop("hook", None)
|
|
2282
|
+
if not block:
|
|
2283
|
+
config.pop("codex", None)
|
|
2284
|
+
save_config(config)
|
|
2285
|
+
# idempotent: silent on missing key
|
|
2286
|
+
return 0
|
|
2162
2287
|
if key == "conversation.retention_days":
|
|
2163
2288
|
# Mirror the display.tz branch: drop the leaf; if the `conversation`
|
|
2164
2289
|
# block ends up empty, drop it too. Next get resolves the 180 default.
|
package/bin/_cctally_core.py
CHANGED
|
@@ -349,7 +349,22 @@ STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
|
|
|
349
349
|
# 1003 -> 1004 (#410 Task B): pair the public journal cursor with the exact
|
|
350
350
|
# prefix atomically applied to the materialized index. A cursor-only hand edit
|
|
351
351
|
# can no longer skip an already-durable event and make its natural key look new.
|
|
352
|
-
|
|
352
|
+
# 1004 -> 1005 (public #5): the incremental Codex quota projection. Adds the
|
|
353
|
+
# reverse map + composable per-group digest on `quota_window_blocks` and the
|
|
354
|
+
# `quota_projection_ledger_state` row (change-ledger watermark, interpretation
|
|
355
|
+
# version, and the two alert axes that are not functions of window dirtiness).
|
|
356
|
+
# It is an epoch bump and NOT a stats migration because the registry is frozen
|
|
357
|
+
# AND because an epoch-current open returns before any schema work — an
|
|
358
|
+
# `add_column_if_missing` would never run on an upgraded install, so the column
|
|
359
|
+
# would simply never appear. Every upgrading install therefore rebuilds stats.db
|
|
360
|
+
# from the journal on first open; that is the documented resolution for an epoch
|
|
361
|
+
# mismatch and a real one-time cost, not a free change.
|
|
362
|
+
# 1005 -> 1006 (public #5, I2 review): the periodic verification. Adds
|
|
363
|
+
# `quota_projection_ledger_state.last_full_pass_at`, the deadline a time-based
|
|
364
|
+
# full pass is measured against. Same mechanical reason as 1005 — an
|
|
365
|
+
# epoch-current open returns before any schema work — so it is a second bump
|
|
366
|
+
# rather than an amendment to the first.
|
|
367
|
+
STATS_INDEX_EPOCH = 1006
|
|
353
368
|
LEGACY_STATS_HEAD = 13
|
|
354
369
|
|
|
355
370
|
|
|
@@ -1248,6 +1263,21 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1248
1263
|
generation TEXT NOT NULL,
|
|
1249
1264
|
orphaned_at TEXT,
|
|
1250
1265
|
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1266
|
+
-- public #5: the REVERSE MAP. `physical_group_key` records the
|
|
1267
|
+
-- physical window this block was materialized from, so the
|
|
1268
|
+
-- generation sweep can be scoped to the groups a bounded pass
|
|
1269
|
+
-- actually re-materialized instead of to whole roots.
|
|
1270
|
+
-- `physical_group_digest` is that group's contribution to its
|
|
1271
|
+
-- root's physical signature: the root value is a digest over the
|
|
1272
|
+
-- root's sorted (group key, group digest) pairs, which makes it
|
|
1273
|
+
-- ASSOCIATIVE. A bounded pass recomputes only the dirty groups'
|
|
1274
|
+
-- digests and re-derives the root value from the stored set —
|
|
1275
|
+
-- O(groups), 608 on the real store, against O(observations) at
|
|
1276
|
+
-- 211K. Hanging it off the blocks is what makes it self-maintaining:
|
|
1277
|
+
-- a group swept to nothing loses its blocks and drops out of the
|
|
1278
|
+
-- composition with them.
|
|
1279
|
+
physical_group_key TEXT,
|
|
1280
|
+
physical_group_digest TEXT,
|
|
1251
1281
|
UNIQUE(source, source_root_key, account_key, logical_limit_key,
|
|
1252
1282
|
observed_slot, window_minutes, resets_at_utc)
|
|
1253
1283
|
);
|
|
@@ -1319,6 +1349,46 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1319
1349
|
PRIMARY KEY(source_root_key, account_key)
|
|
1320
1350
|
);
|
|
1321
1351
|
|
|
1352
|
+
-- public #5: everything the incremental projector needs to know about
|
|
1353
|
+
-- its own last pass, keyed by provider source. One row, read once per
|
|
1354
|
+
-- reconcile.
|
|
1355
|
+
--
|
|
1356
|
+
-- `watermark_seq` is the highest `quota_window_change_log.seq` this
|
|
1357
|
+
-- index has consumed. It is written INSIDE the same stats transaction
|
|
1358
|
+
-- as the projection it describes, so the two commit or roll back
|
|
1359
|
+
-- together; a crash therefore replays a ledger range rather than
|
|
1360
|
+
-- skipping one, which is safe because re-materializing a group is
|
|
1361
|
+
-- idempotent. (Writing it after the commit would need a second stats
|
|
1362
|
+
-- transaction, and `run_stats_ingest` is the sole stats writer.)
|
|
1363
|
+
--
|
|
1364
|
+
-- `interpretation_version` invalidates the mechanism itself: a
|
|
1365
|
+
-- classification change alters interpreted keys with no row mutation
|
|
1366
|
+
-- for the ledger to observe, so a bump queues one complete pass.
|
|
1367
|
+
--
|
|
1368
|
+
-- `alerts_enabled` / `next_evaluation_at_utc` are the two alert axes
|
|
1369
|
+
-- that are not functions of window dirtiness — a delivery-gate
|
|
1370
|
+
-- transition, and a future-clocked observation that becomes eligible
|
|
1371
|
+
-- when wall time passes it with no row mutation at all.
|
|
1372
|
+
--
|
|
1373
|
+
-- `last_full_pass_at` is the periodic verification's deadline. Two
|
|
1374
|
+
-- cases a scoped sweep structurally cannot see — a block whose physical
|
|
1375
|
+
-- group is absent from the cache entirely, and a milestone on a historic
|
|
1376
|
+
-- root no longer active — are otherwise repairable only by an
|
|
1377
|
+
-- interpretation bump, a rebuild or a burst overflow, none of which
|
|
1378
|
+
-- happen on a normal install. Every full pass stamps it, whatever
|
|
1379
|
+
-- triggered it, so the deadline is satisfied by whichever caller reaches
|
|
1380
|
+
-- it first and the bound on staleness is one interval rather than
|
|
1381
|
+
-- forever. NULL means "never verified", which reads as due.
|
|
1382
|
+
CREATE TABLE IF NOT EXISTS quota_projection_ledger_state (
|
|
1383
|
+
source TEXT NOT NULL,
|
|
1384
|
+
watermark_seq INTEGER NOT NULL DEFAULT 0,
|
|
1385
|
+
interpretation_version INTEGER NOT NULL DEFAULT 0,
|
|
1386
|
+
alerts_enabled INTEGER,
|
|
1387
|
+
next_evaluation_at_utc TEXT,
|
|
1388
|
+
last_full_pass_at TEXT,
|
|
1389
|
+
PRIMARY KEY(source)
|
|
1390
|
+
);
|
|
1391
|
+
|
|
1322
1392
|
CREATE TABLE IF NOT EXISTS quota_alert_arming (
|
|
1323
1393
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1324
1394
|
source TEXT NOT NULL,
|
|
@@ -1361,6 +1431,20 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1361
1431
|
"quota_threshold_events", "quota_alert_arming"):
|
|
1362
1432
|
add_column_if_missing(
|
|
1363
1433
|
conn, _tbl, "account_key", "TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1434
|
+
# public #5 backstop, and NOT redundant with the CREATE above. An
|
|
1435
|
+
# epoch-MISMATCHED index resolves by rebuild and gets the fresh CREATE;
|
|
1436
|
+
# a LEGACY index (`user_version <= LEGACY_STATS_HEAD`) takes the
|
|
1437
|
+
# in-place cutover, where `CREATE TABLE IF NOT EXISTS` is a no-op over
|
|
1438
|
+
# the table it already has — so the reverse map would never appear and
|
|
1439
|
+
# every reconcile after the cutover would fail on `no such column`.
|
|
1440
|
+
for _col in ("physical_group_key", "physical_group_digest"):
|
|
1441
|
+
add_column_if_missing(conn, "quota_window_blocks", _col, "TEXT")
|
|
1442
|
+
# Same seam, epoch 1006: a LEGACY index that already took the epoch-1005
|
|
1443
|
+
# cutover carries `quota_projection_ledger_state` WITHOUT the periodic
|
|
1444
|
+
# verification's deadline, and `CREATE TABLE IF NOT EXISTS` above is a
|
|
1445
|
+
# no-op over the table it already has.
|
|
1446
|
+
add_column_if_missing(
|
|
1447
|
+
conn, "quota_projection_ledger_state", "last_full_pass_at", "TEXT")
|
|
1364
1448
|
|
|
1365
1449
|
|
|
1366
1450
|
def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
@@ -2299,7 +2383,7 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
2299
2383
|
# Schema-apply compatibility for legacy/test-mode paths that reach this
|
|
2300
2384
|
# DDL with a pre-pair cursor row. A released epoch-1003 index does NOT
|
|
2301
2385
|
# use this as an upgrade shortcut: the epoch mismatch rebuilds it into
|
|
2302
|
-
# the complete epoch
|
|
2386
|
+
# the complete current-epoch schema first.
|
|
2303
2387
|
conn.execute(
|
|
2304
2388
|
"UPDATE journal_cursor "
|
|
2305
2389
|
"SET applied_segment = segment, applied_offset = offset "
|
|
@@ -135,6 +135,12 @@ class CacheReportDailyRow:
|
|
|
135
135
|
net_usd: float
|
|
136
136
|
anomaly_triggered: bool
|
|
137
137
|
anomaly_reasons: tuple[str, ...]
|
|
138
|
+
# #443 S1. `anomaly_unevaluated` = the predicates the classifier declined
|
|
139
|
+
# to run for this row; `observed` is False only for the builder's
|
|
140
|
+
# synthetic today row. Both default so their absence reproduces pre-S1
|
|
141
|
+
# rendering exactly.
|
|
142
|
+
anomaly_unevaluated: tuple[str, ...] = ()
|
|
143
|
+
observed: bool = True
|
|
138
144
|
|
|
139
145
|
|
|
140
146
|
@dataclass(frozen=True)
|
|
@@ -161,6 +167,9 @@ class CacheReportTodaySpotlight:
|
|
|
161
167
|
anomaly_triggered: bool
|
|
162
168
|
anomaly_reasons: tuple[str, ...]
|
|
163
169
|
baseline_daily_row_count: int
|
|
170
|
+
# #443 S1 — see CacheReportDailyRow.
|
|
171
|
+
anomaly_unevaluated: tuple[str, ...] = ()
|
|
172
|
+
observed: bool = True
|
|
164
173
|
|
|
165
174
|
|
|
166
175
|
def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict | None":
|
|
@@ -189,6 +198,8 @@ def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict |
|
|
|
189
198
|
"anomaly_triggered": cr.today.anomaly_triggered,
|
|
190
199
|
"anomaly_reasons": list(cr.today.anomaly_reasons),
|
|
191
200
|
"baseline_daily_row_count": cr.today.baseline_daily_row_count,
|
|
201
|
+
"anomaly_unevaluated": list(cr.today.anomaly_unevaluated),
|
|
202
|
+
"observed": cr.today.observed,
|
|
192
203
|
},
|
|
193
204
|
"days": [
|
|
194
205
|
{
|
|
@@ -203,6 +214,8 @@ def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict |
|
|
|
203
214
|
"net_usd": d.net_usd,
|
|
204
215
|
"anomaly_triggered": d.anomaly_triggered,
|
|
205
216
|
"anomaly_reasons": list(d.anomaly_reasons),
|
|
217
|
+
"anomaly_unevaluated": list(d.anomaly_unevaluated),
|
|
218
|
+
"observed": d.observed,
|
|
206
219
|
}
|
|
207
220
|
for d in cr.days
|
|
208
221
|
],
|
|
@@ -319,6 +332,12 @@ def _cache_report_empty(
|
|
|
319
332
|
):
|
|
320
333
|
"""The empty (no in-window entries) ``CacheReportSnapshot`` — factored so the
|
|
321
334
|
warm and cold builder paths share one ``is_empty`` return (#272 §6)."""
|
|
335
|
+
crk = _cache_report_load_kernel()
|
|
336
|
+
# #443 S1 — the dataclass defaults would claim ``observed=True`` with an
|
|
337
|
+
# empty ``anomaly_unevaluated`` for a day that was definitionally never
|
|
338
|
+
# measured or classified. That is the fabricating default this session
|
|
339
|
+
# removes everywhere else, so it is stated explicitly here too even though
|
|
340
|
+
# the empty branch short-circuits before any of it renders.
|
|
322
341
|
empty_today = CacheReportTodaySpotlight(
|
|
323
342
|
date=today_iso,
|
|
324
343
|
cache_hit_percent=0.0,
|
|
@@ -328,6 +347,8 @@ def _cache_report_empty(
|
|
|
328
347
|
anomaly_triggered=False,
|
|
329
348
|
anomaly_reasons=(),
|
|
330
349
|
baseline_daily_row_count=0,
|
|
350
|
+
anomaly_unevaluated=tuple(crk.CACHE_ANOMALY_PREDICATES),
|
|
351
|
+
observed=False,
|
|
331
352
|
)
|
|
332
353
|
return CacheReportSnapshot(
|
|
333
354
|
window_days=window_days,
|
|
@@ -555,6 +576,10 @@ def build_cache_report_snapshot(
|
|
|
555
576
|
anomaly_triggered=False,
|
|
556
577
|
anomaly_reasons=(),
|
|
557
578
|
baseline_daily_row_count=baseline_daily_row_count,
|
|
579
|
+
# This row never reaches the classifier, so nothing was evaluated
|
|
580
|
+
# for it and nothing was measured behind it (#443 S1 F1/F2).
|
|
581
|
+
anomaly_unevaluated=tuple(crk.CACHE_ANOMALY_PREDICATES),
|
|
582
|
+
observed=False,
|
|
558
583
|
)
|
|
559
584
|
else:
|
|
560
585
|
today_spotlight = CacheReportTodaySpotlight(
|
|
@@ -568,6 +593,8 @@ def build_cache_report_snapshot(
|
|
|
568
593
|
anomaly_triggered=today_row.anomaly_triggered,
|
|
569
594
|
anomaly_reasons=tuple(today_row.anomaly_reasons),
|
|
570
595
|
baseline_daily_row_count=baseline_daily_row_count,
|
|
596
|
+
anomaly_unevaluated=tuple(today_row.anomaly_unevaluated),
|
|
597
|
+
observed=True,
|
|
571
598
|
)
|
|
572
599
|
|
|
573
600
|
# Daily rows — newest first, capped at ``window_days``.
|
|
@@ -615,6 +642,8 @@ def build_cache_report_snapshot(
|
|
|
615
642
|
net_usd=0.0,
|
|
616
643
|
anomaly_triggered=False,
|
|
617
644
|
anomaly_reasons=(),
|
|
645
|
+
anomaly_unevaluated=tuple(crk.CACHE_ANOMALY_PREDICATES),
|
|
646
|
+
observed=False,
|
|
618
647
|
)
|
|
619
648
|
)
|
|
620
649
|
days_newest_first.extend(
|
|
@@ -630,6 +659,8 @@ def build_cache_report_snapshot(
|
|
|
630
659
|
net_usd=r.net_usd,
|
|
631
660
|
anomaly_triggered=r.anomaly_triggered,
|
|
632
661
|
anomaly_reasons=tuple(r.anomaly_reasons),
|
|
662
|
+
anomaly_unevaluated=tuple(r.anomaly_unevaluated),
|
|
663
|
+
observed=True,
|
|
633
664
|
)
|
|
634
665
|
for r in raw_days_newest_first
|
|
635
666
|
)
|
|
@@ -1090,16 +1090,35 @@ def _handle_get_conversation_payload_impl(handler, path: str) -> None:
|
|
|
1090
1090
|
handler._respond_json(400, {"error": "bad request"})
|
|
1091
1091
|
return
|
|
1092
1092
|
cq = handler._conversation_query()
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1093
|
+
mode = which
|
|
1094
|
+
if which == "result":
|
|
1095
|
+
# A backgrounded-MCP result lives in an attachment record, not in the
|
|
1096
|
+
# placeholder tool_result a public which='result' lookup finds first.
|
|
1097
|
+
# Resolve the internal carrier here; the response the client receives
|
|
1098
|
+
# still carries which:"result", so the client contract is unchanged and
|
|
1099
|
+
# `background_result` is never an accepted input value.
|
|
1100
|
+
ok, resolved = handler._run_conversation_query(
|
|
1101
|
+
lambda conn: cq.locate_result_payload(conn, session_id, tool_use_id),
|
|
1102
|
+
"/api/conversation/payload")
|
|
1103
|
+
if not ok:
|
|
1104
|
+
return
|
|
1105
|
+
mode, loc = resolved
|
|
1106
|
+
if mode == "background_gone":
|
|
1107
|
+
# A KNOWN background placeholder whose notification row is gone or
|
|
1108
|
+
# unresolvable — 410, distinct from the 404 an unknown id returns.
|
|
1109
|
+
handler._respond_json(410, {"error": "source no longer available"})
|
|
1110
|
+
return
|
|
1111
|
+
else:
|
|
1112
|
+
ok, loc = handler._run_conversation_query(
|
|
1113
|
+
lambda conn: cq.locate_tool_payload(
|
|
1114
|
+
conn, session_id, tool_use_id, which),
|
|
1115
|
+
"/api/conversation/payload")
|
|
1116
|
+
if not ok:
|
|
1117
|
+
return
|
|
1099
1118
|
if loc is None:
|
|
1100
1119
|
handler._respond_json(404, {"error": "not found"})
|
|
1101
1120
|
return
|
|
1102
|
-
payload = cq.
|
|
1121
|
+
payload = cq.read_located_payload(loc, tool_use_id, mode)
|
|
1103
1122
|
if payload is None:
|
|
1104
1123
|
handler._respond_json(410, {"error": "source no longer available"})
|
|
1105
1124
|
return
|
|
@@ -3188,6 +3188,50 @@ def _claude_accounts_wire(
|
|
|
3188
3188
|
return cards
|
|
3189
3189
|
|
|
3190
3190
|
|
|
3191
|
+
def _codex_ingest_backlog_wire(
|
|
3192
|
+
cache_conn: sqlite3.Connection,
|
|
3193
|
+
) -> "dict[str, object] | None":
|
|
3194
|
+
"""The hook's budgeted-ingest backlog, or ``None`` when there is none.
|
|
3195
|
+
|
|
3196
|
+
Public #5 spec §5. Additive and OMITTED ENTIRELY at zero, so the normal
|
|
3197
|
+
payload stays byte-identical for every install whose Codex history is
|
|
3198
|
+
already ingested — which is nearly all of them, nearly all the time.
|
|
3199
|
+
|
|
3200
|
+
It deliberately does NOT touch ``availability`` or ``freshness``. Those are
|
|
3201
|
+
read by a long and explicitly non-exhaustive list of gates, and degrading a
|
|
3202
|
+
shared signal for a domain-local condition is a failure this project has
|
|
3203
|
+
already hit; the client renders "history still loading" from this field
|
|
3204
|
+
without any shared axis moving.
|
|
3205
|
+
|
|
3206
|
+
Any read or shape failure degrades to ``None``. The record is a health
|
|
3207
|
+
signal, not evidence, and a hand-edited one must never be able to fail the
|
|
3208
|
+
envelope build.
|
|
3209
|
+
"""
|
|
3210
|
+
try:
|
|
3211
|
+
row = cache_conn.execute(
|
|
3212
|
+
"SELECT value FROM cache_meta WHERE key = ? LIMIT 1",
|
|
3213
|
+
("codex_ingest_backlog",),
|
|
3214
|
+
).fetchone()
|
|
3215
|
+
except sqlite3.DatabaseError:
|
|
3216
|
+
return None
|
|
3217
|
+
if not row or not row[0]:
|
|
3218
|
+
return None
|
|
3219
|
+
try:
|
|
3220
|
+
record = json.loads(str(row[0]))
|
|
3221
|
+
files = int(record["files"])
|
|
3222
|
+
pending_bytes = int(record["bytes"])
|
|
3223
|
+
except (ValueError, TypeError, KeyError):
|
|
3224
|
+
return None
|
|
3225
|
+
if files <= 0:
|
|
3226
|
+
return None
|
|
3227
|
+
since = record.get("since")
|
|
3228
|
+
return {
|
|
3229
|
+
"files": files,
|
|
3230
|
+
"bytes": pending_bytes,
|
|
3231
|
+
"since": None if since is None else str(since),
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
|
|
3191
3235
|
def build_codex_source_state(
|
|
3192
3236
|
context: DashboardReadContext,
|
|
3193
3237
|
*,
|
|
@@ -3217,6 +3261,7 @@ def build_codex_source_state(
|
|
|
3217
3261
|
context,
|
|
3218
3262
|
)
|
|
3219
3263
|
projection_incoherent = not coherence.coherent
|
|
3264
|
+
ingest_backlog = _codex_ingest_backlog_wire(context.cache_conn)
|
|
3220
3265
|
# The cache reader's established report surface treats the ``now`` instant
|
|
3221
3266
|
# as inclusive. The qualified adapter is half-open, so extend only its
|
|
3222
3267
|
# query/result boundary by one microsecond and keep all live budget sums
|
|
@@ -3681,6 +3726,12 @@ def build_codex_source_state(
|
|
|
3681
3726
|
"projected_thresholds": context.codex_quota_projected_thresholds,
|
|
3682
3727
|
},
|
|
3683
3728
|
"cache_report": cache_report,
|
|
3729
|
+
# public #5 §5: additive, omitted at zero (the `cycle_freshness`
|
|
3730
|
+
# precedent). Never emitted as an empty object — the absence IS the
|
|
3731
|
+
# "nothing owed" state, which is what keeps the normal payload
|
|
3732
|
+
# byte-identical.
|
|
3733
|
+
**({"ingest_backlog": ingest_backlog}
|
|
3734
|
+
if ingest_backlog is not None else {}),
|
|
3684
3735
|
},
|
|
3685
3736
|
domain_freshness={
|
|
3686
3737
|
"hero": (
|