cctally 1.109.0 → 1.111.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 +52 -0
- package/README.md +6 -6
- package/bin/_cctally_cache.py +62 -13
- package/bin/_cctally_core.py +8 -1
- package/bin/_cctally_dashboard.py +422 -195
- package/bin/_cctally_dashboard_conversation.py +231 -40
- package/bin/_cctally_dashboard_envelope.py +48 -32
- package/bin/_cctally_dashboard_sources.py +503 -109
- package/bin/_cctally_db.py +44 -0
- package/bin/_cctally_diagnosis_sources.py +181 -15
- package/bin/_cctally_doctor.py +15 -3
- package/bin/_cctally_forecast.py +25 -12
- package/bin/_cctally_journal.py +59 -32
- package/bin/_cctally_milestone_history.py +65 -9
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_record.py +50 -12
- package/bin/_cctally_rederive.py +899 -30
- package/bin/_cctally_source_analytics.py +567 -133
- package/bin/_cctally_transcript.py +27 -2
- package/bin/_cctally_tui.py +39 -6
- package/bin/_cctally_update.py +420 -14
- package/bin/_lib_cache_report.py +7 -10
- package/bin/_lib_codex_conversation_query.py +286 -63
- package/bin/_lib_codex_metadata.py +207 -0
- package/bin/_lib_conversation_query.py +254 -37
- package/bin/_lib_doctor.py +16 -2
- package/bin/_lib_pricing.py +80 -276
- package/bin/_lib_rederive.py +903 -2
- package/bin/_lib_view_models.py +5 -0
- package/bin/cctally +18 -5
- package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
- package/dashboard/static/assets/{DoctorModal-BZGXOlc2.js → DoctorModal-DFY2iPDf.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-KbO_KWa7.js → ProjectsDrillPanel-DAv39uSo.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BrOPLVRL.js → SourceDetailModal-DSAWo2Fp.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CYgEFAi4.js → UpdateModal-R_9dOVnh.js} +3 -3
- package/dashboard/static/assets/index-COhxe3vU.css +1 -0
- package/dashboard/static/assets/index-DdU62sov.js +13 -0
- package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
- package/dashboard/static/assets/{useKeymap-DQx_A1MB.js → useKeymap-CW7Up0Ay.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +2 -1
- package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +0 -72
- package/dashboard/static/assets/ModalRoot-C_fvugTl.js +0 -1
- package/dashboard/static/assets/index-Cj_l7T7u.css +0 -1
- package/dashboard/static/assets/index-DLVwACUM.js +0 -13
- package/dashboard/static/assets/outlineNavigation-DXX-RjKl.js +0 -9
|
@@ -29,14 +29,21 @@ from _cctally_quota import (
|
|
|
29
29
|
load_codex_quota_projection_certificate,
|
|
30
30
|
)
|
|
31
31
|
from _cctally_source_analytics import (
|
|
32
|
+
CodexProjectMetadataHealth,
|
|
33
|
+
CodexThreadMetadataInventory,
|
|
32
34
|
QualifiedMetadataUnavailable,
|
|
33
35
|
RootedCodexAccountingEntry,
|
|
34
36
|
has_cached_codex_accounting_entries,
|
|
35
37
|
load_cached_rooted_codex_accounting_entries,
|
|
36
38
|
load_codex_project_metadata_health,
|
|
39
|
+
load_codex_thread_metadata_inventory,
|
|
37
40
|
load_qualified_codex_entries,
|
|
38
41
|
probe_codex_detail_metadata_health,
|
|
39
42
|
)
|
|
43
|
+
from _lib_codex_metadata import (
|
|
44
|
+
codex_metadata_is_malformed,
|
|
45
|
+
decode_codex_project_metadata,
|
|
46
|
+
)
|
|
40
47
|
import _lib_log
|
|
41
48
|
import _lib_accounts
|
|
42
49
|
import _lib_perf
|
|
@@ -2351,6 +2358,8 @@ def _codex_cache_report_wire(
|
|
|
2351
2358
|
entries: Iterable[object],
|
|
2352
2359
|
*,
|
|
2353
2360
|
metadata: Mapping[tuple[str, str], Mapping[str, object]],
|
|
2361
|
+
by_accounting_identity: "Mapping[tuple[str, str, str], Mapping[str, object]] | None" = None,
|
|
2362
|
+
withhold_projects: bool = False,
|
|
2354
2363
|
now_utc: dt.datetime,
|
|
2355
2364
|
display_tz_name: str | None,
|
|
2356
2365
|
speed: str,
|
|
@@ -2376,15 +2385,14 @@ def _codex_cache_report_wire(
|
|
|
2376
2385
|
cutoff = now_utc - dt.timedelta(days=window_days)
|
|
2377
2386
|
bucket_tz = crk._resolve_bucket_tz(display_tz)
|
|
2378
2387
|
|
|
2379
|
-
def _tiered_cost(tokens: int,
|
|
2388
|
+
def _tiered_cost(tokens: int, long_context: bool,
|
|
2389
|
+
pricing: Mapping[str, object], base: str, above: str) -> float:
|
|
2380
2390
|
if tokens <= 0:
|
|
2381
2391
|
return 0.0
|
|
2382
2392
|
base_rate = float(pricing.get(base, 0.0) or 0.0)
|
|
2383
2393
|
above_rate = pricing.get(above)
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
return threshold * base_rate + (tokens - threshold) * float(above_rate)
|
|
2387
|
-
return tokens * base_rate
|
|
2394
|
+
rate = float(above_rate) if long_context and above_rate is not None else base_rate
|
|
2395
|
+
return tokens * rate
|
|
2388
2396
|
|
|
2389
2397
|
def _wrap_entry(entry: object) -> object | None:
|
|
2390
2398
|
timestamp = getattr(entry, "timestamp", None)
|
|
@@ -2394,23 +2402,34 @@ def _codex_cache_report_wire(
|
|
|
2394
2402
|
input_tokens = int(getattr(entry, "input_tokens", 0))
|
|
2395
2403
|
cached_tokens = min(input_tokens, int(getattr(entry, "cached_input_tokens", 0)))
|
|
2396
2404
|
uncached_tokens = max(0, input_tokens - cached_tokens)
|
|
2405
|
+
long_context = input_tokens > int(c.CODEX_TIERED_THRESHOLD)
|
|
2397
2406
|
pricing, _is_fallback = c._resolve_codex_pricing(model)
|
|
2398
2407
|
pricing = pricing or {}
|
|
2399
2408
|
uncached_counterfactual = _tiered_cost(
|
|
2400
|
-
cached_tokens, pricing,
|
|
2409
|
+
cached_tokens, long_context, pricing,
|
|
2401
2410
|
"input_cost_per_token", "input_cost_per_token_above_272k_tokens",
|
|
2402
2411
|
)
|
|
2403
2412
|
cached_actual = _tiered_cost(
|
|
2404
|
-
cached_tokens, pricing,
|
|
2413
|
+
cached_tokens, long_context, pricing,
|
|
2405
2414
|
"cache_read_input_token_cost", "cache_read_input_token_cost_above_272k_tokens",
|
|
2406
2415
|
)
|
|
2407
2416
|
multiplier = c._codex_fast_multiplier(model) if speed == "fast" else 1.0
|
|
2408
2417
|
saved = max(0.0, uncached_counterfactual - cached_actual) * multiplier
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2418
|
+
# #845 §4.4: in the partial generation the lookup is by accounting
|
|
2419
|
+
# identity, so one rollout path hosting a healthy and a malformed
|
|
2420
|
+
# conversation does not publish one decision for both. An entry the
|
|
2421
|
+
# identity map leaves unattributed lands in this fold's existing
|
|
2422
|
+
# `(unknown)` bucket, which is its not-attributed bucket rather than a
|
|
2423
|
+
# project.
|
|
2424
|
+
if by_accounting_identity is not None:
|
|
2425
|
+
item_metadata = by_accounting_identity.get(
|
|
2426
|
+
_codex_accounting_identity(entry)) or {}
|
|
2427
|
+
else:
|
|
2428
|
+
identity = (
|
|
2429
|
+
str(getattr(entry, "source_root_key", "") or ""),
|
|
2430
|
+
str(getattr(entry, "source_path", "") or ""),
|
|
2431
|
+
)
|
|
2432
|
+
item_metadata = metadata.get(identity) or {}
|
|
2414
2433
|
project = (
|
|
2415
2434
|
str(getattr(entry, "project_label", "") or "").strip()
|
|
2416
2435
|
or str(item_metadata.get("project_label") or "").strip()
|
|
@@ -2776,6 +2795,12 @@ def _codex_cache_report_wire(
|
|
|
2776
2795
|
|
|
2777
2796
|
by_project = _finish_breakdown("project")
|
|
2778
2797
|
by_model = _finish_breakdown("model")
|
|
2798
|
+
if withhold_projects:
|
|
2799
|
+
# #845 §4.5. The conversation-metadata read failed, so no entry has a
|
|
2800
|
+
# project identity at all. Publishing an empty collection says that;
|
|
2801
|
+
# converting every entry to `(unknown)` would report a bucket the
|
|
2802
|
+
# reader cannot distinguish from a real absence of attribution.
|
|
2803
|
+
by_project = ()
|
|
2779
2804
|
seven = days[:7]
|
|
2780
2805
|
saved_total = stable_sum(float(row["saved_usd"]) for row in days)
|
|
2781
2806
|
wasted_total = stable_sum(float(row["wasted_usd"]) for row in days)
|
|
@@ -2844,9 +2869,127 @@ _CODEX_FILE_ALIAS_SQL = (
|
|
|
2844
2869
|
)
|
|
2845
2870
|
|
|
2846
2871
|
|
|
2872
|
+
@dataclass(frozen=True)
|
|
2873
|
+
class _CodexConversationMetadataRead:
|
|
2874
|
+
"""The typed result of the conversation-metadata read (#845 §4.5).
|
|
2875
|
+
|
|
2876
|
+
``metadata`` is the legacy path map, keyed ``(source_root_key,
|
|
2877
|
+
source_path)`` and built exactly as it was. ``by_accounting_identity`` is
|
|
2878
|
+
the per-conversation map of §4.4, keyed ``(source_root_key,
|
|
2879
|
+
conversation_key or "", source_path)`` and materialized only for the
|
|
2880
|
+
partial generation. ``error`` distinguishes a store with no Codex
|
|
2881
|
+
conversations, which returns two empty maps and ``None``, from a read that
|
|
2882
|
+
failed, which returns two empty maps and the exception: without it the two
|
|
2883
|
+
were byte-identical afterwards and the second published as the first.
|
|
2884
|
+
"""
|
|
2885
|
+
|
|
2886
|
+
metadata: "Mapping[tuple[str, str], Mapping[str, object]]"
|
|
2887
|
+
by_accounting_identity: "Mapping[tuple[str, str, str], Mapping[str, object]]"
|
|
2888
|
+
error: "BaseException | None" = None
|
|
2889
|
+
|
|
2890
|
+
|
|
2891
|
+
def _codex_accounting_identity(entry: object) -> tuple[str, str, str]:
|
|
2892
|
+
"""The identity map's key for one accounting entry, with no fallback.
|
|
2893
|
+
|
|
2894
|
+
The exact key for every entry: a sentinel fallback between this key and
|
|
2895
|
+
the path-keyed one would silently attribute a refused entry through a
|
|
2896
|
+
neighbour that shares its path.
|
|
2897
|
+
"""
|
|
2898
|
+
return (
|
|
2899
|
+
str(getattr(entry, "source_root_key", "") or ""),
|
|
2900
|
+
str(getattr(entry, "conversation_key", "") or ""),
|
|
2901
|
+
str(getattr(entry, "source_path", "") or ""),
|
|
2902
|
+
)
|
|
2903
|
+
|
|
2904
|
+
|
|
2905
|
+
def _codex_identity_map(
|
|
2906
|
+
cache_conn: sqlite3.Connection,
|
|
2907
|
+
accounting_entries: "Iterable[object]",
|
|
2908
|
+
inventory: "CodexThreadMetadataInventory | None" = None,
|
|
2909
|
+
) -> dict[tuple[str, str, str], dict[str, object]]:
|
|
2910
|
+
"""Decide project attribution per accounting identity (§4.4).
|
|
2911
|
+
|
|
2912
|
+
The decision order is the qualified reader's, so the map, the counter and
|
|
2913
|
+
the reader agree on every rooted entry. First the reader's two refusals,
|
|
2914
|
+
which precede any metadata: an empty ``conversation_key``, whatever its
|
|
2915
|
+
path's alias winner holds, and a nonempty key with neither a thread row nor
|
|
2916
|
+
an alias winner. A refused item carries ``None`` for both fields; no
|
|
2917
|
+
resolver is called and the predicate is not consulted.
|
|
2918
|
+
|
|
2919
|
+
Every other entry is classified through ``codex_metadata_is_malformed``
|
|
2920
|
+
with its direct row and its path's inherited winner, and a malformed one
|
|
2921
|
+
carries ``None`` for both fields too. Otherwise the entry is attributed
|
|
2922
|
+
exactly as the qualified reader would attribute it: the merged fields
|
|
2923
|
+
``cwd = direct.cwd or inherited.cwd`` and ``git_json = direct.git_json or
|
|
2924
|
+
inherited.git_json``, with the same ``inherited`` operand the decision
|
|
2925
|
+
used. An alias-only identity therefore attributes through the inventory's
|
|
2926
|
+
ordering-only winner rather than through the path map's native-id
|
|
2927
|
+
inheritance, so an unrelated malformed row can never relabel or remove a
|
|
2928
|
+
healthy project row.
|
|
2929
|
+
|
|
2930
|
+
The reader's first refusal, an empty ``source_root_key``, is not mirrored
|
|
2931
|
+
here: the rooted loader raises for such a row before any entry reaches this
|
|
2932
|
+
map (§4.6 rule 3).
|
|
2933
|
+
"""
|
|
2934
|
+
from _cctally_cache import _codex_conversation_project_attribution
|
|
2935
|
+
|
|
2936
|
+
if inventory is None:
|
|
2937
|
+
inventory = load_codex_thread_metadata_inventory(cache_conn)
|
|
2938
|
+
decided: dict[tuple[str, str, str], dict[str, object]] = {}
|
|
2939
|
+
for entry in accounting_entries:
|
|
2940
|
+
identity = _codex_accounting_identity(entry)
|
|
2941
|
+
if identity in decided:
|
|
2942
|
+
continue
|
|
2943
|
+
root_key, conversation_key, source_path = identity
|
|
2944
|
+
direct = inventory.direct_by_conversation.get((root_key, conversation_key))
|
|
2945
|
+
inherited = inventory.inherited_by_path.get((root_key, source_path))
|
|
2946
|
+
refused = not conversation_key or (direct is None and inherited is None)
|
|
2947
|
+
if refused or codex_metadata_is_malformed(
|
|
2948
|
+
direct if conversation_key else None, inherited,
|
|
2949
|
+
):
|
|
2950
|
+
decided[identity] = {"project_key": None, "project_label": None}
|
|
2951
|
+
continue
|
|
2952
|
+
cwd = (direct.cwd if direct is not None else None) or (
|
|
2953
|
+
inherited.cwd if inherited is not None else None)
|
|
2954
|
+
git_json = (direct.git_json if direct is not None else None) or (
|
|
2955
|
+
inherited.git_json if inherited is not None else None)
|
|
2956
|
+
project_key, project_label = _codex_conversation_project_attribution(
|
|
2957
|
+
root_key, cwd, git_json)
|
|
2958
|
+
decided[identity] = {
|
|
2959
|
+
"project_key": project_key, "project_label": project_label,
|
|
2960
|
+
}
|
|
2961
|
+
return decided
|
|
2962
|
+
|
|
2963
|
+
|
|
2964
|
+
def codex_unattributed_paths(
|
|
2965
|
+
accounting_entries: "Iterable[object]",
|
|
2966
|
+
by_accounting_identity: "Mapping[tuple[str, str, str], Mapping[str, object]]",
|
|
2967
|
+
) -> "frozenset[tuple[str, str]]":
|
|
2968
|
+
"""The ``(root, path)`` set whose session rows must publish NULL projects.
|
|
2969
|
+
|
|
2970
|
+
Computed per caller from the entries that built THAT session view — the
|
|
2971
|
+
parent over every fallback entry, each account child over its own
|
|
2972
|
+
partition — so a child whose entries carry no unattributed identity on a
|
|
2973
|
+
shared path keeps that path's session project while a child whose entries
|
|
2974
|
+
do receives NULL. A session row's single project field cannot represent a
|
|
2975
|
+
mixed path.
|
|
2976
|
+
"""
|
|
2977
|
+
unattributed: set[tuple[str, str]] = set()
|
|
2978
|
+
for entry in accounting_entries:
|
|
2979
|
+
identity = _codex_accounting_identity(entry)
|
|
2980
|
+
item = by_accounting_identity.get(identity)
|
|
2981
|
+
if item is None or not item.get("project_key"):
|
|
2982
|
+
unattributed.add((identity[0], identity[2]))
|
|
2983
|
+
return frozenset(unattributed)
|
|
2984
|
+
|
|
2985
|
+
|
|
2847
2986
|
def _codex_conversation_metadata(
|
|
2848
2987
|
cache_conn: sqlite3.Connection,
|
|
2849
|
-
|
|
2988
|
+
*,
|
|
2989
|
+
accounting_entries: "Iterable[object]" = (),
|
|
2990
|
+
build_identity_map: bool = False,
|
|
2991
|
+
inventory: "CodexThreadMetadataInventory | None" = None,
|
|
2992
|
+
) -> _CodexConversationMetadataRead:
|
|
2850
2993
|
"""Read task short names and cached project metadata by rooted rollout.
|
|
2851
2994
|
|
|
2852
2995
|
``state_5.sqlite.threads.title`` is Codex's persisted user-facing task name.
|
|
@@ -2866,7 +3009,9 @@ def _codex_conversation_metadata(
|
|
|
2866
3009
|
") "
|
|
2867
3010
|
"SELECT t.source_root_key, t.source_path, t.native_thread_id, "
|
|
2868
3011
|
"e.session_id AS accounting_session_id, "
|
|
2869
|
-
"t.cwd
|
|
3012
|
+
"CAST(t.cwd AS BLOB) AS cwd_blob, "
|
|
3013
|
+
"CAST(t.git_json AS BLOB) AS git_json_blob, "
|
|
3014
|
+
"a.started_at, t.last_seen_utc "
|
|
2870
3015
|
"FROM codex_conversation_threads AS t "
|
|
2871
3016
|
"LEFT JOIN accounting AS a "
|
|
2872
3017
|
"ON a.source_root_key=t.source_root_key AND a.source_path=t.source_path "
|
|
@@ -2874,15 +3019,27 @@ def _codex_conversation_metadata(
|
|
|
2874
3019
|
"ORDER BY t.last_seen_utc DESC, t.conversation_key DESC"
|
|
2875
3020
|
))
|
|
2876
3021
|
from _cctally_cache import _codex_conversation_project_attribution
|
|
3022
|
+
|
|
3023
|
+
def _attribution(root_key, cwd_blob, git_json_blob):
|
|
3024
|
+
# #845 §4.1. The two columns arrive as BLOBs; a thread whose
|
|
3025
|
+
# resolver path reaches an undecodable field carries no attribution
|
|
3026
|
+
# at all rather than the `(unassigned)` its decoded `None` fields
|
|
3027
|
+
# would otherwise produce.
|
|
3028
|
+
decoded = decode_codex_project_metadata(cwd_blob, git_json_blob)
|
|
3029
|
+
if codex_metadata_is_malformed(decoded, None):
|
|
3030
|
+
return (None, None)
|
|
3031
|
+
return _codex_conversation_project_attribution(
|
|
3032
|
+
root_key, decoded.cwd, decoded.git_json)
|
|
3033
|
+
|
|
2877
3034
|
rows = tuple(
|
|
2878
3035
|
(
|
|
2879
3036
|
root_key, source_path, native_thread_id, accounting_session_id,
|
|
2880
|
-
*
|
|
3037
|
+
*_attribution(root_key, cwd_blob, git_json_blob),
|
|
2881
3038
|
first_seen_at,
|
|
2882
3039
|
)
|
|
2883
3040
|
for (
|
|
2884
3041
|
root_key, source_path, native_thread_id, accounting_session_id,
|
|
2885
|
-
|
|
3042
|
+
cwd_blob, git_json_blob, first_seen_at, _last_seen_at,
|
|
2886
3043
|
) in core_rows
|
|
2887
3044
|
)
|
|
2888
3045
|
file_aliases = tuple(cache_conn.execute(_CODEX_FILE_ALIAS_SQL))
|
|
@@ -2981,9 +3138,16 @@ def _codex_conversation_metadata(
|
|
|
2981
3138
|
"project_label": (inherited or {}).get("project_label"),
|
|
2982
3139
|
"started_at": started_at or (inherited or {}).get("started_at"),
|
|
2983
3140
|
}
|
|
2984
|
-
except sqlite3.Error:
|
|
2985
|
-
return {}
|
|
2986
|
-
|
|
3141
|
+
except sqlite3.Error as exc:
|
|
3142
|
+
return _CodexConversationMetadataRead({}, {}, exc)
|
|
3143
|
+
by_identity: dict[tuple[str, str, str], dict[str, object]] = {}
|
|
3144
|
+
if build_identity_map:
|
|
3145
|
+
try:
|
|
3146
|
+
by_identity = _codex_identity_map(
|
|
3147
|
+
cache_conn, accounting_entries, inventory)
|
|
3148
|
+
except sqlite3.Error as exc:
|
|
3149
|
+
return _CodexConversationMetadataRead({}, {}, exc)
|
|
3150
|
+
return _CodexConversationMetadataRead(metadata, by_identity, None)
|
|
2987
3151
|
|
|
2988
3152
|
|
|
2989
3153
|
def _session_wire(
|
|
@@ -2991,7 +3155,17 @@ def _session_wire(
|
|
|
2991
3155
|
*,
|
|
2992
3156
|
metadata: Mapping[tuple[str, str], Mapping[str, object]] | None = None,
|
|
2993
3157
|
private_labels: dict[str, str] | None = None,
|
|
3158
|
+
unattributed_paths: "frozenset[tuple[str, str]]" = frozenset(),
|
|
2994
3159
|
) -> dict[str, object]:
|
|
3160
|
+
"""Fold one session view; ``unattributed_paths`` nulls a row's project.
|
|
3161
|
+
|
|
3162
|
+
#845 §4.4: a row's project fields are NULL exactly when its
|
|
3163
|
+
``(source_root_key, source_path)`` is in that set, because the row's single
|
|
3164
|
+
project field cannot represent a path that hosts one healthy and one
|
|
3165
|
+
malformed conversation. Otherwise they come from the path map as they do
|
|
3166
|
+
today, so a healthy path's session row is unchanged in the partial
|
|
3167
|
+
generation. Titles and ``started_at`` always come from the path map.
|
|
3168
|
+
"""
|
|
2995
3169
|
rows = []
|
|
2996
3170
|
for row in view.rows:
|
|
2997
3171
|
# The Codex session aggregator intentionally splits equal relative
|
|
@@ -3023,7 +3197,15 @@ def _session_wire(
|
|
|
3023
3197
|
if row_metadata and row_metadata.get("title") is not None
|
|
3024
3198
|
else None
|
|
3025
3199
|
)
|
|
3026
|
-
|
|
3200
|
+
unattributed = (
|
|
3201
|
+
str(row.codex_root or ""), str(row.session_id_path),
|
|
3202
|
+
) in unattributed_paths
|
|
3203
|
+
project = (
|
|
3204
|
+
""
|
|
3205
|
+
if unattributed
|
|
3206
|
+
else str(row_metadata.get("project_label") or "").strip()
|
|
3207
|
+
if row_metadata else ""
|
|
3208
|
+
)
|
|
3027
3209
|
started_at = row_metadata.get("started_at") if row_metadata else None
|
|
3028
3210
|
duration_min = None
|
|
3029
3211
|
if isinstance(started_at, str):
|
|
@@ -3041,7 +3223,10 @@ def _session_wire(
|
|
|
3041
3223
|
"key": key,
|
|
3042
3224
|
"source": "codex",
|
|
3043
3225
|
"project": project or None,
|
|
3044
|
-
"project_key":
|
|
3226
|
+
"project_key": (
|
|
3227
|
+
None if unattributed
|
|
3228
|
+
else row_metadata.get("project_key") if row_metadata else None
|
|
3229
|
+
),
|
|
3045
3230
|
"started_at": started_at,
|
|
3046
3231
|
"duration_min": duration_min,
|
|
3047
3232
|
"last_activity": row.last_activity.astimezone(UTC).isoformat(),
|
|
@@ -4645,21 +4830,22 @@ def _cached_projects_wire(
|
|
|
4645
4830
|
|
|
4646
4831
|
def _partial_projects_wire(
|
|
4647
4832
|
entries: Iterable[object],
|
|
4648
|
-
|
|
4833
|
+
by_accounting_identity: "Mapping[tuple[str, str, str], Mapping[str, object]]",
|
|
4649
4834
|
) -> dict[str, object]:
|
|
4650
4835
|
"""Aggregate the qualified subset when older accounting metadata is mixed.
|
|
4651
4836
|
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4837
|
+
#845 §4.4: the lookup is by ACCOUNTING IDENTITY, not by path. One rollout
|
|
4838
|
+
path can host a healthy conversation and a malformed one, and a path-keyed
|
|
4839
|
+
map would publish one decision for both. An entry the identity map leaves
|
|
4840
|
+
unattributed — refused or malformed — carries a ``None`` item and is
|
|
4841
|
+
omitted here, where the Projects-domain warning covers it. Every other
|
|
4842
|
+
entry keeps the project key the healthy fold publishes for it.
|
|
4655
4843
|
"""
|
|
4656
4844
|
groups: dict[tuple[str, str], dict[str, object]] = {}
|
|
4657
4845
|
for entry in entries:
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
)
|
|
4662
|
-
row_metadata = metadata.get(identity)
|
|
4846
|
+
accounting_identity = _codex_accounting_identity(entry)
|
|
4847
|
+
identity = (accounting_identity[0], accounting_identity[2])
|
|
4848
|
+
row_metadata = by_accounting_identity.get(accounting_identity)
|
|
4663
4849
|
project_key = str(row_metadata.get("project_key") or "").strip() if row_metadata else ""
|
|
4664
4850
|
project_label = str(row_metadata.get("project_label") or "").strip() if row_metadata else ""
|
|
4665
4851
|
if not project_key or not project_label:
|
|
@@ -5723,6 +5909,17 @@ def _codex_account_scopes_wire(
|
|
|
5723
5909
|
accounting_end: dt.datetime,
|
|
5724
5910
|
metadata_incomplete: bool,
|
|
5725
5911
|
conversation_metadata: Mapping[tuple[str, str], Mapping[str, object]],
|
|
5912
|
+
#: #845 §4.4/§4.5. The per-conversation decisions of the partial
|
|
5913
|
+
#: generation, and whether the conversation-metadata read itself failed.
|
|
5914
|
+
#: Both default to the healthy shape so a direct caller that has neither
|
|
5915
|
+
#: keeps today's behaviour.
|
|
5916
|
+
conversation_identity_metadata: "Mapping[tuple[str, str, str], Mapping[str, object]]" = (
|
|
5917
|
+
MappingProxyType({})),
|
|
5918
|
+
conversation_metadata_failed: bool = False,
|
|
5919
|
+
#: §4.6 rule 1: a transient generation publishes NO Codex project rows on
|
|
5920
|
+
#: any child either, so a focused account cannot show rows the parent is
|
|
5921
|
+
#: withholding.
|
|
5922
|
+
metadata_transient: bool = False,
|
|
5726
5923
|
alerts: Iterable[Mapping[str, object]],
|
|
5727
5924
|
budget_milestones: Iterable[Mapping[str, object]],
|
|
5728
5925
|
projected_budget_milestones: Iterable[Mapping[str, object]],
|
|
@@ -5883,9 +6080,19 @@ def _codex_account_scopes_wire(
|
|
|
5883
6080
|
"sessions": _session_wire(
|
|
5884
6081
|
sessions_view, metadata=conversation_metadata,
|
|
5885
6082
|
private_labels=private_session_labels,
|
|
6083
|
+
# Each child computes the set over its OWN partition, so a
|
|
6084
|
+
# child whose entries carry no unattributed identity on a
|
|
6085
|
+
# shared path keeps that path's session project.
|
|
6086
|
+
unattributed_paths=(
|
|
6087
|
+
codex_unattributed_paths(
|
|
6088
|
+
rows, conversation_identity_metadata)
|
|
6089
|
+
if metadata_incomplete else frozenset()
|
|
6090
|
+
),
|
|
5886
6091
|
),
|
|
5887
6092
|
"projects": (
|
|
5888
|
-
_partial_projects_wire(
|
|
6093
|
+
_partial_projects_wire((), {})
|
|
6094
|
+
if metadata_transient
|
|
6095
|
+
else _partial_projects_wire(rows, conversation_identity_metadata)
|
|
5889
6096
|
if metadata_incomplete else _cached_projects_wire(
|
|
5890
6097
|
context, account_observations, rows,
|
|
5891
6098
|
changed_old=changed_old_rows_by_account.get(key, ()),
|
|
@@ -5897,7 +6104,12 @@ def _codex_account_scopes_wire(
|
|
|
5897
6104
|
)
|
|
5898
6105
|
),
|
|
5899
6106
|
"cache_report": _codex_cache_report_wire(
|
|
5900
|
-
rows, metadata=conversation_metadata,
|
|
6107
|
+
rows, metadata=conversation_metadata,
|
|
6108
|
+
by_accounting_identity=(
|
|
6109
|
+
conversation_identity_metadata if metadata_incomplete
|
|
6110
|
+
else None),
|
|
6111
|
+
withhold_projects=conversation_metadata_failed,
|
|
6112
|
+
now_utc=context.now_utc,
|
|
5901
6113
|
display_tz_name=context.display_tz_name, speed=context.speed,
|
|
5902
6114
|
anomaly_threshold_pp=context.cache_report_anomaly_threshold_pp,
|
|
5903
6115
|
cache_key=("account", key),
|
|
@@ -7213,6 +7425,12 @@ class _CodexAccountingCapture:
|
|
|
7213
7425
|
#: row that is deterministically unqualifiable and for a read that failed,
|
|
7214
7426
|
#: and those want different explanations and different remedies.
|
|
7215
7427
|
metadata_read_failed: bool = False
|
|
7428
|
+
#: #845/#846 §4.6. The fixed internal vocabulary of metadata legs that
|
|
7429
|
+
#: failed with a genuine `sqlite3.Error` or a `transient=True` refusal:
|
|
7430
|
+
#: `accounting_health`, `detail_probe`, `qualified_accounting`,
|
|
7431
|
+
#: `fallback_accounting`, `conversation_metadata`. Nothing is added to the wire; the combined
|
|
7432
|
+
#: verdict is derived from this tuple.
|
|
7433
|
+
failed_legs: tuple[str, ...] = ()
|
|
7216
7434
|
#: The name of the population THIS capture read, taken from the accounting
|
|
7217
7435
|
#: result rather than re-read from `_lib_snapshot_cache`'s module global at
|
|
7218
7436
|
#: fold time. The global is process-wide, so a concurrent share render's
|
|
@@ -7225,44 +7443,63 @@ class _CodexAccountingCapture:
|
|
|
7225
7443
|
population_signature: tuple | None = None
|
|
7226
7444
|
|
|
7227
7445
|
|
|
7228
|
-
#: The detail routes' horizon: one year ending at the generation instant.
|
|
7229
|
-
#:
|
|
7230
|
-
#:
|
|
7231
|
-
#: approximation of it.
|
|
7446
|
+
#: The detail routes' horizon: one year ending at the generation instant. The
|
|
7447
|
+
#: carrier probe covers its union with the accounting window, which may begin
|
|
7448
|
+
#: earlier for a configured budget.
|
|
7232
7449
|
CODEX_DETAIL_HORIZON = dt.timedelta(days=365)
|
|
7233
7450
|
|
|
7234
7451
|
|
|
7235
|
-
def
|
|
7236
|
-
|
|
7237
|
-
) -> "
|
|
7238
|
-
"""
|
|
7452
|
+
def _codex_build_metadata_inventory(
|
|
7453
|
+
cache_conn: sqlite3.Connection,
|
|
7454
|
+
) -> "CodexThreadMetadataInventory | None":
|
|
7455
|
+
"""The ONE thread-and-alias inventory this build's consumers share (§4.3).
|
|
7456
|
+
|
|
7457
|
+
The health counter, the detail probe's counter, the qualified reader and
|
|
7458
|
+
the identity map each used to load it for themselves, so a build paid the
|
|
7459
|
+
whole-table thread pass three or four times over. They now receive this
|
|
7460
|
+
one.
|
|
7461
|
+
|
|
7462
|
+
A read failure answers ``None`` rather than raising. Each consumer then
|
|
7463
|
+
loads the inventory itself and fails inside its OWN classification `try`,
|
|
7464
|
+
exactly as it did before this call existed, so the set of failed legs
|
|
7465
|
+
§4.6 rule 1 composes is unchanged by the sharing.
|
|
7466
|
+
"""
|
|
7467
|
+
try:
|
|
7468
|
+
return load_codex_thread_metadata_inventory(cache_conn)
|
|
7469
|
+
except (sqlite3.Error, RuntimeError):
|
|
7470
|
+
return None
|
|
7239
7471
|
|
|
7240
|
-
#834 S2 (#828). Neither detail route queries independently and no
|
|
7241
|
-
ingest-maintained marker is introduced: the generation is built once, this
|
|
7242
|
-
runs inside it, and both routes read the frozen result.
|
|
7243
7472
|
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7247
|
-
|
|
7473
|
+
def _probe_codex_detail_metadata_health_leg(
|
|
7474
|
+
context: DashboardReadContext,
|
|
7475
|
+
inventory: "CodexThreadMetadataInventory | None" = None,
|
|
7476
|
+
accounting_start: dt.datetime | None = None,
|
|
7477
|
+
accounting_end: dt.datetime | None = None,
|
|
7478
|
+
) -> "tuple[Mapping[str, object], BaseException | None]":
|
|
7479
|
+
"""The probe leg, with its failure REPORTED rather than absorbed.
|
|
7480
|
+
|
|
7481
|
+
#846 §4.6: a failure here is one of four legs the combined verdict weighs,
|
|
7482
|
+
so the exception is handed back instead of being turned into a carrier on
|
|
7483
|
+
the spot. The published carrier for the failed case is the same
|
|
7484
|
+
`transient_read_failure` this leg published on its own before.
|
|
7248
7485
|
"""
|
|
7249
7486
|
try:
|
|
7250
7487
|
probe = probe_codex_detail_metadata_health(
|
|
7251
7488
|
cache_conn=context.cache_conn,
|
|
7252
|
-
start=context.now_utc - CODEX_DETAIL_HORIZON,
|
|
7253
|
-
|
|
7489
|
+
start=min(context.now_utc - CODEX_DETAIL_HORIZON, accounting_start)
|
|
7490
|
+
if accounting_start is not None
|
|
7491
|
+
else context.now_utc - CODEX_DETAIL_HORIZON,
|
|
7492
|
+
end=max(context.now_utc, accounting_end)
|
|
7493
|
+
if accounting_end is not None else context.now_utc,
|
|
7494
|
+
inventory=inventory,
|
|
7254
7495
|
)
|
|
7255
|
-
except (sqlite3.Error, RuntimeError):
|
|
7256
|
-
|
|
7257
|
-
"Codex detail metadata probe failed; publishing a retryable "
|
|
7258
|
-
"transient health result for this generation"
|
|
7259
|
-
)
|
|
7260
|
-
return build_metadata_health("transient_read_failure")
|
|
7496
|
+
except (sqlite3.Error, RuntimeError) as exc:
|
|
7497
|
+
return build_metadata_health("transient_read_failure"), exc
|
|
7261
7498
|
if probe.incomplete_rows > 0:
|
|
7262
7499
|
return build_metadata_health(
|
|
7263
7500
|
"malformed_row_partial", incomplete_rows=probe.incomplete_rows,
|
|
7264
|
-
)
|
|
7265
|
-
return build_metadata_health("healthy")
|
|
7501
|
+
), None
|
|
7502
|
+
return build_metadata_health("healthy"), None
|
|
7266
7503
|
|
|
7267
7504
|
|
|
7268
7505
|
def _capture_codex_accounting(
|
|
@@ -7273,10 +7510,13 @@ def _capture_codex_accounting(
|
|
|
7273
7510
|
active_roots: tuple[str, ...],
|
|
7274
7511
|
path_scope: object,
|
|
7275
7512
|
incomplete_rows: int,
|
|
7513
|
+
failed_legs: tuple[str, ...] = (),
|
|
7514
|
+
leg_errors: "list[tuple[str, BaseException]] | None" = None,
|
|
7515
|
+
inventory: "CodexThreadMetadataInventory | None" = None,
|
|
7276
7516
|
) -> _CodexAccountingCapture:
|
|
7277
7517
|
"""Read the incremental accounting carrier without folding public views.
|
|
7278
7518
|
|
|
7279
|
-
#834 S2 (#828, #829): the caller passes the
|
|
7519
|
+
#834 S2 (#828, #829): the caller passes the health read's ROW COUNT rather than
|
|
7280
7520
|
a boolean, because the typed health result this capture returns has to
|
|
7281
7521
|
report the count a malformed-row partial measured, and a boolean has
|
|
7282
7522
|
already thrown it away.
|
|
@@ -7292,7 +7532,29 @@ def _capture_codex_accounting(
|
|
|
7292
7532
|
changed_old: tuple[object, ...] = ()
|
|
7293
7533
|
changed_new: tuple[object, ...] = ()
|
|
7294
7534
|
population_signature: tuple | None = None
|
|
7295
|
-
|
|
7535
|
+
|
|
7536
|
+
def fallback_entries() -> tuple[object, ...]:
|
|
7537
|
+
nonlocal metadata_read_failed, failed_legs
|
|
7538
|
+
try:
|
|
7539
|
+
return load_cached_rooted_codex_accounting_entries(
|
|
7540
|
+
accounting_start, accounting_end, speed=context.speed,
|
|
7541
|
+
cache_conn=context.cache_conn,
|
|
7542
|
+
)
|
|
7543
|
+
except QualifiedMetadataUnavailable as exc:
|
|
7544
|
+
# A failed SQLite read has no trustworthy rows. Keep the typed
|
|
7545
|
+
# transient carrier; deterministic identity refusals retain their
|
|
7546
|
+
# existing fail-loud path.
|
|
7547
|
+
if not isinstance(exc.__cause__, sqlite3.Error):
|
|
7548
|
+
raise
|
|
7549
|
+
metadata_read_failed = True
|
|
7550
|
+
failed_legs = (*failed_legs, "fallback_accounting")
|
|
7551
|
+
if leg_errors is not None:
|
|
7552
|
+
leg_errors.append(("fallback_accounting", exc))
|
|
7553
|
+
return ()
|
|
7554
|
+
# §4.6 rule 1: the qualified read is skipped when a leg before it has
|
|
7555
|
+
# already failed. Its result could not be published anyway, and issuing it
|
|
7556
|
+
# over a store whose metadata legs are failing only costs the tick.
|
|
7557
|
+
if not metadata_incomplete and not failed_legs:
|
|
7296
7558
|
try:
|
|
7297
7559
|
cached_accounting = _lib_snapshot_cache.build_cached_codex_accounting(
|
|
7298
7560
|
cache_conn=context.cache_conn,
|
|
@@ -7310,6 +7572,7 @@ def _capture_codex_accounting(
|
|
|
7310
7572
|
speed=context.speed,
|
|
7311
7573
|
sync=False,
|
|
7312
7574
|
cache_conn=context.cache_conn,
|
|
7575
|
+
inventory=inventory,
|
|
7313
7576
|
),
|
|
7314
7577
|
load_paths=lambda identities: load_qualified_codex_entries(
|
|
7315
7578
|
accounting_start,
|
|
@@ -7318,6 +7581,7 @@ def _capture_codex_accounting(
|
|
|
7318
7581
|
sync=False,
|
|
7319
7582
|
cache_conn=context.cache_conn,
|
|
7320
7583
|
source_identities=identities,
|
|
7584
|
+
inventory=inventory,
|
|
7321
7585
|
),
|
|
7322
7586
|
path_of=lambda entry: (
|
|
7323
7587
|
str(entry.source_root_key), str(entry.source_path),
|
|
@@ -7350,13 +7614,12 @@ def _capture_codex_accounting(
|
|
|
7350
7614
|
# class over a superset of this window, so its count is the
|
|
7351
7615
|
# measured answer and this exception carries none.
|
|
7352
7616
|
metadata_read_failed = getattr(exc, "transient", True)
|
|
7617
|
+
if metadata_read_failed:
|
|
7618
|
+
failed_legs = (*failed_legs, "qualified_accounting")
|
|
7619
|
+
if leg_errors is not None:
|
|
7620
|
+
leg_errors.append(("qualified_accounting", exc))
|
|
7353
7621
|
_lib_snapshot_cache.reset_codex_accounting_cache_state()
|
|
7354
|
-
entries =
|
|
7355
|
-
accounting_start,
|
|
7356
|
-
accounting_end,
|
|
7357
|
-
speed=context.speed,
|
|
7358
|
-
cache_conn=context.cache_conn,
|
|
7359
|
-
)
|
|
7622
|
+
entries = fallback_entries()
|
|
7360
7623
|
dirty_accounts = tuple(sorted({
|
|
7361
7624
|
str(getattr(entry, "account_key", "") or
|
|
7362
7625
|
_lib_accounts.UNATTRIBUTED)
|
|
@@ -7364,12 +7627,7 @@ def _capture_codex_accounting(
|
|
|
7364
7627
|
}))
|
|
7365
7628
|
else:
|
|
7366
7629
|
_lib_snapshot_cache.reset_codex_accounting_cache_state()
|
|
7367
|
-
entries =
|
|
7368
|
-
accounting_start,
|
|
7369
|
-
accounting_end,
|
|
7370
|
-
speed=context.speed,
|
|
7371
|
-
cache_conn=context.cache_conn,
|
|
7372
|
-
)
|
|
7630
|
+
entries = fallback_entries()
|
|
7373
7631
|
dirty_accounts = tuple(sorted({
|
|
7374
7632
|
str(getattr(entry, "account_key", "") or
|
|
7375
7633
|
_lib_accounts.UNATTRIBUTED)
|
|
@@ -7383,6 +7641,7 @@ def _capture_codex_accounting(
|
|
|
7383
7641
|
entries=tuple(entries),
|
|
7384
7642
|
metadata_incomplete=metadata_incomplete,
|
|
7385
7643
|
metadata_read_failed=metadata_read_failed,
|
|
7644
|
+
failed_legs=failed_legs,
|
|
7386
7645
|
population_signature=(
|
|
7387
7646
|
None if metadata_incomplete else population_signature),
|
|
7388
7647
|
)
|
|
@@ -7483,7 +7742,10 @@ class CodexSourceCapture:
|
|
|
7483
7742
|
accounting: _CodexAccountingCapture
|
|
7484
7743
|
accounting_exists: bool
|
|
7485
7744
|
accounting_exists_error: Exception | None
|
|
7486
|
-
|
|
7745
|
+
#: #845 §4.5: the typed result, not a bare map. An empty store and a
|
|
7746
|
+
#: failed read produce identical maps, and the composition of §4.6 needs to
|
|
7747
|
+
#: tell them apart.
|
|
7748
|
+
conversation_metadata: "_CodexConversationMetadataRead"
|
|
7487
7749
|
#: #819: the count OR the exception, never a boolean that cannot tell them
|
|
7488
7750
|
#: apart. `AccountRegistryReading` states why, and the build threads this
|
|
7489
7751
|
#: one object into both the decoration gate and the published
|
|
@@ -7632,12 +7894,35 @@ def capture_codex_source_state(
|
|
|
7632
7894
|
else:
|
|
7633
7895
|
accounting_start = min(accounting_start, budget_start)
|
|
7634
7896
|
with _lib_perf.phase("source.accounting"):
|
|
7635
|
-
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7897
|
+
leg_errors: "list[tuple[str, BaseException]]" = []
|
|
7898
|
+
failed_legs: tuple[str, ...] = ()
|
|
7899
|
+
inventory = _codex_build_metadata_inventory(context.cache_conn)
|
|
7900
|
+
# §4.6, the first taxonomy hole. `load_codex_project_metadata_health`
|
|
7901
|
+
# had no local classification `try`, so a SQLite failure here
|
|
7902
|
+
# propagated to the broad handler below and failed the provider
|
|
7903
|
+
# outright. It is a leg now.
|
|
7904
|
+
try:
|
|
7905
|
+
health = load_codex_project_metadata_health(
|
|
7906
|
+
cache_conn=context.cache_conn,
|
|
7907
|
+
start=accounting_start,
|
|
7908
|
+
end=accounting_end,
|
|
7909
|
+
inventory=inventory,
|
|
7910
|
+
)
|
|
7911
|
+
except sqlite3.Error as exc:
|
|
7912
|
+
failed_legs = (*failed_legs, "accounting_health")
|
|
7913
|
+
leg_errors.append(("accounting_health", exc))
|
|
7914
|
+
health = CodexProjectMetadataHealth(
|
|
7915
|
+
total_rows=0, qualified_rows=0,
|
|
7916
|
+
missing_conversation_key_rows=0,
|
|
7917
|
+
missing_thread_join_rows=0,
|
|
7918
|
+
undecodable_metadata_rows=0,
|
|
7919
|
+
)
|
|
7920
|
+
detail_metadata_health, probe_error = (
|
|
7921
|
+
_probe_codex_detail_metadata_health_leg(
|
|
7922
|
+
context, inventory, accounting_start, accounting_end))
|
|
7923
|
+
if probe_error is not None:
|
|
7924
|
+
failed_legs = (*failed_legs, "detail_probe")
|
|
7925
|
+
leg_errors.append(("detail_probe", probe_error))
|
|
7641
7926
|
accounting = _capture_codex_accounting(
|
|
7642
7927
|
context,
|
|
7643
7928
|
accounting_start=accounting_start,
|
|
@@ -7645,6 +7930,9 @@ def capture_codex_source_state(
|
|
|
7645
7930
|
active_roots=active_roots,
|
|
7646
7931
|
path_scope=path_scope,
|
|
7647
7932
|
incomplete_rows=health.incomplete_rows,
|
|
7933
|
+
failed_legs=failed_legs,
|
|
7934
|
+
leg_errors=leg_errors,
|
|
7935
|
+
inventory=inventory,
|
|
7648
7936
|
)
|
|
7649
7937
|
try:
|
|
7650
7938
|
accounting_exists = has_cached_codex_accounting_entries(
|
|
@@ -7656,8 +7944,35 @@ def capture_codex_source_state(
|
|
|
7656
7944
|
# value, then defer it until the build knows that branch is live.
|
|
7657
7945
|
accounting_exists = False
|
|
7658
7946
|
accounting_exists_error = exc
|
|
7659
|
-
conversation_metadata =
|
|
7660
|
-
|
|
7947
|
+
conversation_metadata = _codex_conversation_metadata(
|
|
7948
|
+
context.cache_conn,
|
|
7949
|
+
inventory=inventory,
|
|
7950
|
+
accounting_entries=accounting.entries,
|
|
7951
|
+
# §4.4: the identity map's trigger is the accounting-window gate,
|
|
7952
|
+
# not the carrier. It is consulted exactly when the qualified read
|
|
7953
|
+
# was skipped and the partial fold runs, and a healthy generation
|
|
7954
|
+
# never consults it.
|
|
7955
|
+
build_identity_map=accounting.metadata_incomplete,
|
|
7956
|
+
)
|
|
7957
|
+
if conversation_metadata.error is not None:
|
|
7958
|
+
accounting = replace(
|
|
7959
|
+
accounting,
|
|
7960
|
+
failed_legs=(*accounting.failed_legs, "conversation_metadata"),
|
|
7961
|
+
)
|
|
7962
|
+
leg_errors.append(
|
|
7963
|
+
("conversation_metadata", conversation_metadata.error))
|
|
7964
|
+
if accounting.failed_legs:
|
|
7965
|
+
# ONE server-side line naming the sorted failed legs and their
|
|
7966
|
+
# exception classes, so a transient generation can be diagnosed
|
|
7967
|
+
# without guessing which read raised.
|
|
7968
|
+
_lib_log.get_logger("dashboard").warning(
|
|
7969
|
+
"Codex metadata legs failed (%s); publishing a retryable "
|
|
7970
|
+
"transient health result for this generation",
|
|
7971
|
+
", ".join(
|
|
7972
|
+
f"{leg}:{type(error).__name__}"
|
|
7973
|
+
for leg, error in sorted(leg_errors, key=lambda pair: pair[0])
|
|
7974
|
+
),
|
|
7975
|
+
)
|
|
7661
7976
|
with _lib_perf.phase("source.accounts"):
|
|
7662
7977
|
account_registry = read_account_registry(
|
|
7663
7978
|
context.stats_conn, "codex")
|
|
@@ -7931,38 +8246,100 @@ def _build_codex_source_state(
|
|
|
7931
8246
|
_warn_codex_budget_window_once("accounting_range")
|
|
7932
8247
|
else:
|
|
7933
8248
|
accounting_start = min(accounting_start, budget_start)
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
8249
|
+
builder_leg_errors: "list[tuple[str, BaseException]]" = []
|
|
8250
|
+
builder_failed_legs: tuple[str, ...] = ()
|
|
8251
|
+
builder_inventory: "CodexThreadMetadataInventory | None" = None
|
|
8252
|
+
if capture is not None:
|
|
8253
|
+
health = capture.health
|
|
8254
|
+
detail_metadata_health = capture.detail_metadata_health
|
|
8255
|
+
accounting_capture = capture.accounting
|
|
8256
|
+
else:
|
|
8257
|
+
builder_inventory = _codex_build_metadata_inventory(context.cache_conn)
|
|
8258
|
+
try:
|
|
8259
|
+
health = load_codex_project_metadata_health(
|
|
8260
|
+
cache_conn=context.cache_conn,
|
|
8261
|
+
start=accounting_start,
|
|
8262
|
+
end=accounting_end,
|
|
8263
|
+
inventory=builder_inventory,
|
|
8264
|
+
)
|
|
8265
|
+
except sqlite3.Error as exc:
|
|
8266
|
+
builder_failed_legs = (*builder_failed_legs, "accounting_health")
|
|
8267
|
+
builder_leg_errors.append(("accounting_health", exc))
|
|
8268
|
+
health = CodexProjectMetadataHealth(
|
|
8269
|
+
total_rows=0, qualified_rows=0,
|
|
8270
|
+
missing_conversation_key_rows=0, missing_thread_join_rows=0,
|
|
8271
|
+
undecodable_metadata_rows=0,
|
|
8272
|
+
)
|
|
8273
|
+
detail_metadata_health, builder_probe_error = (
|
|
8274
|
+
_probe_codex_detail_metadata_health_leg(
|
|
8275
|
+
context, builder_inventory, accounting_start, accounting_end))
|
|
8276
|
+
if builder_probe_error is not None:
|
|
8277
|
+
builder_failed_legs = (*builder_failed_legs, "detail_probe")
|
|
8278
|
+
builder_leg_errors.append(("detail_probe", builder_probe_error))
|
|
8279
|
+
accounting_capture = _capture_codex_accounting(
|
|
7952
8280
|
context,
|
|
7953
8281
|
accounting_start=accounting_start,
|
|
7954
8282
|
accounting_end=accounting_end,
|
|
7955
8283
|
active_roots=active_roots,
|
|
7956
8284
|
path_scope=path_scope,
|
|
7957
8285
|
incomplete_rows=health.incomplete_rows,
|
|
8286
|
+
failed_legs=builder_failed_legs,
|
|
8287
|
+
leg_errors=builder_leg_errors,
|
|
8288
|
+
inventory=builder_inventory,
|
|
7958
8289
|
)
|
|
7959
|
-
)
|
|
7960
8290
|
qualified_entries = accounting_capture.qualified_entries
|
|
7961
8291
|
accounting_dirty_accounts = accounting_capture.dirty_accounts
|
|
7962
8292
|
accounting_changed_old = accounting_capture.changed_old
|
|
7963
8293
|
accounting_changed_new = accounting_capture.changed_new
|
|
7964
8294
|
accounting_entries = accounting_capture.entries
|
|
7965
8295
|
metadata_incomplete = accounting_capture.metadata_incomplete
|
|
8296
|
+
|
|
8297
|
+
conversation_metadata_read = (
|
|
8298
|
+
capture.conversation_metadata
|
|
8299
|
+
if capture is not None
|
|
8300
|
+
else _codex_conversation_metadata(
|
|
8301
|
+
context.cache_conn,
|
|
8302
|
+
inventory=builder_inventory,
|
|
8303
|
+
accounting_entries=accounting_entries,
|
|
8304
|
+
build_identity_map=metadata_incomplete,
|
|
8305
|
+
)
|
|
8306
|
+
)
|
|
8307
|
+
# Restored with the typed result of §4.5: this map used to be handed
|
|
8308
|
+
# to the folds as a `MappingProxyType`, and the read now returns a
|
|
8309
|
+
# dataclass whose `metadata` member is the same dict, so the
|
|
8310
|
+
# read-only view moves here, where both the captured and the
|
|
8311
|
+
# capture-less path consume it.
|
|
8312
|
+
conversation_metadata = MappingProxyType(
|
|
8313
|
+
conversation_metadata_read.metadata)
|
|
8314
|
+
conversation_identity_metadata = (
|
|
8315
|
+
conversation_metadata_read.by_accounting_identity)
|
|
8316
|
+
conversation_metadata_failed = conversation_metadata_read.error is not None
|
|
8317
|
+
# §4.6 rule 1. A transient generation is one where ANY metadata leg failed
|
|
8318
|
+
# with a genuine `sqlite3.Error` or a `transient=True` refusal. It
|
|
8319
|
+
# publishes no Codex project rows anywhere, says so on the chip, and is
|
|
8320
|
+
# never retained by a reuse or retention gate, so the next tick rebuilds.
|
|
8321
|
+
codex_failed_legs = tuple(accounting_capture.failed_legs)
|
|
8322
|
+
if conversation_metadata_failed and (
|
|
8323
|
+
"conversation_metadata" not in codex_failed_legs
|
|
8324
|
+
):
|
|
8325
|
+
codex_failed_legs = (*codex_failed_legs, "conversation_metadata")
|
|
8326
|
+
builder_leg_errors.append(
|
|
8327
|
+
("conversation_metadata", conversation_metadata_read.error))
|
|
8328
|
+
if capture is None and codex_failed_legs:
|
|
8329
|
+
# The capture path emits this line itself; on the direct builder path
|
|
8330
|
+
# it is emitted here, once, naming the sorted failed legs and their
|
|
8331
|
+
# exception classes.
|
|
8332
|
+
_lib_log.get_logger("dashboard").warning(
|
|
8333
|
+
"Codex metadata legs failed (%s); publishing a retryable "
|
|
8334
|
+
"transient health result for this generation",
|
|
8335
|
+
", ".join(
|
|
8336
|
+
f"{leg}:{type(error).__name__}"
|
|
8337
|
+
for leg, error in sorted(
|
|
8338
|
+
builder_leg_errors, key=lambda pair: pair[0])
|
|
8339
|
+
) or ", ".join(sorted(codex_failed_legs)),
|
|
8340
|
+
)
|
|
8341
|
+
metadata_transient = bool(codex_failed_legs)
|
|
8342
|
+
|
|
7966
8343
|
# Derived HERE rather than beside `health`, because `metadata_incomplete`
|
|
7967
8344
|
# is reassigned on the line above and the message has to answer for the
|
|
7968
8345
|
# state that is actually published. Computed early, a transient read
|
|
@@ -7992,7 +8369,10 @@ def _build_codex_source_state(
|
|
|
7992
8369
|
metadata_warning_message = (
|
|
7993
8370
|
"Codex project metadata could not be read for this build; it will "
|
|
7994
8371
|
"retry on the next refresh."
|
|
7995
|
-
if
|
|
8372
|
+
if metadata_transient
|
|
8373
|
+
else f"{health.incomplete_rows} Codex accounting row(s) could not be "
|
|
8374
|
+
"used; run `cctally cache-sync --source codex --rebuild`."
|
|
8375
|
+
if health.malformed_accounting_rows > 0
|
|
7996
8376
|
else f"{health.incomplete_rows} Codex accounting row(s) lack project "
|
|
7997
8377
|
"metadata; run `cctally cache-sync --source codex --rebuild`."
|
|
7998
8378
|
if health.incomplete_rows > 0
|
|
@@ -8267,14 +8647,12 @@ def _build_codex_source_state(
|
|
|
8267
8647
|
context, budget_entries, cost_events=budget_cost_events,
|
|
8268
8648
|
)
|
|
8269
8649
|
configured_budget = configured_budget_domain["status"]
|
|
8270
|
-
conversation_metadata = (
|
|
8271
|
-
capture.conversation_metadata
|
|
8272
|
-
if capture is not None
|
|
8273
|
-
else _codex_conversation_metadata(context.cache_conn)
|
|
8274
|
-
)
|
|
8275
8650
|
cache_report = _codex_cache_report_wire(
|
|
8276
8651
|
visible_accounting_entries,
|
|
8277
8652
|
metadata=conversation_metadata,
|
|
8653
|
+
by_accounting_identity=(
|
|
8654
|
+
conversation_identity_metadata if metadata_incomplete else None),
|
|
8655
|
+
withhold_projects=conversation_metadata_failed,
|
|
8278
8656
|
now_utc=context.now_utc,
|
|
8279
8657
|
display_tz_name=context.display_tz_name,
|
|
8280
8658
|
speed=context.speed,
|
|
@@ -8285,7 +8663,13 @@ def _build_codex_source_state(
|
|
|
8285
8663
|
semantic_signature=period_signature,
|
|
8286
8664
|
)
|
|
8287
8665
|
projects = (
|
|
8288
|
-
|
|
8666
|
+
# §4.6 rule 1: empty on the parent and on every account child. A
|
|
8667
|
+
# generation whose metadata legs failed has established nothing about
|
|
8668
|
+
# the rows, so publishing any of them would be a guess.
|
|
8669
|
+
_partial_projects_wire((), {})
|
|
8670
|
+
if metadata_transient
|
|
8671
|
+
else _partial_projects_wire(
|
|
8672
|
+
visible_accounting_entries, conversation_identity_metadata)
|
|
8289
8673
|
if metadata_incomplete else _cached_projects_wire(
|
|
8290
8674
|
context,
|
|
8291
8675
|
quota_observations,
|
|
@@ -8307,6 +8691,12 @@ def _build_codex_source_state(
|
|
|
8307
8691
|
sessions,
|
|
8308
8692
|
metadata=conversation_metadata,
|
|
8309
8693
|
private_labels=private_session_labels,
|
|
8694
|
+
# §4.4: computed from the entries that built THIS view.
|
|
8695
|
+
unattributed_paths=(
|
|
8696
|
+
codex_unattributed_paths(
|
|
8697
|
+
visible_accounting_entries, conversation_identity_metadata)
|
|
8698
|
+
if metadata_incomplete else frozenset()
|
|
8699
|
+
),
|
|
8310
8700
|
)
|
|
8311
8701
|
# #819: an unreadable account registry makes this build PARTIAL. The
|
|
8312
8702
|
# source itself is intact — every non-account domain below is built from
|
|
@@ -8330,7 +8720,8 @@ def _build_codex_source_state(
|
|
|
8330
8720
|
# would publish a scope naming two accounts beside a `data` carrying none.
|
|
8331
8721
|
availability = (
|
|
8332
8722
|
"partial"
|
|
8333
|
-
if metadata_incomplete or
|
|
8723
|
+
if metadata_incomplete or metadata_transient or hero_failure
|
|
8724
|
+
or account_registry.error is not None
|
|
8334
8725
|
else ("ok" if (entries or quota_blocks or budget_rows) else "empty")
|
|
8335
8726
|
)
|
|
8336
8727
|
hero_input = None if hero_failure else sum(entry.input_tokens for entry in cycle_entries)
|
|
@@ -8339,7 +8730,7 @@ def _build_codex_source_state(
|
|
|
8339
8730
|
hero_reasoning = None if hero_failure else sum(entry.reasoning_output_tokens for entry in cycle_entries)
|
|
8340
8731
|
hero_total = None if hero_failure else sum(entry.total_tokens for entry in cycle_entries)
|
|
8341
8732
|
warnings: list[SourceDashboardWarning] = []
|
|
8342
|
-
if metadata_incomplete:
|
|
8733
|
+
if metadata_incomplete or metadata_transient:
|
|
8343
8734
|
warnings.append(SourceDashboardWarning(
|
|
8344
8735
|
"codex_metadata_incomplete",
|
|
8345
8736
|
metadata_warning_message,
|
|
@@ -8499,6 +8890,9 @@ def _build_codex_source_state(
|
|
|
8499
8890
|
accounting_end=accounting_end,
|
|
8500
8891
|
metadata_incomplete=metadata_incomplete,
|
|
8501
8892
|
conversation_metadata=conversation_metadata,
|
|
8893
|
+
conversation_identity_metadata=conversation_identity_metadata,
|
|
8894
|
+
conversation_metadata_failed=conversation_metadata_failed,
|
|
8895
|
+
metadata_transient=metadata_transient,
|
|
8502
8896
|
alerts=alerts,
|
|
8503
8897
|
budget_milestones=budget_rows,
|
|
8504
8898
|
projected_budget_milestones=projected_budget_rows,
|
|
@@ -8878,7 +9272,7 @@ def _build_codex_source_state(
|
|
|
8878
9272
|
# health as unknown on a store whose probe read fine.
|
|
8879
9273
|
metadata_health=(
|
|
8880
9274
|
build_metadata_health("transient_read_failure")
|
|
8881
|
-
if
|
|
9275
|
+
if metadata_transient
|
|
8882
9276
|
else detail_metadata_health
|
|
8883
9277
|
),
|
|
8884
9278
|
)
|