cctally 1.78.0 → 1.79.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 +13 -0
- package/README.md +2 -0
- package/bin/_cctally_cache.py +140 -2
- package/bin/_cctally_config.py +50 -0
- package/bin/_cctally_dashboard.py +175 -20
- package/bin/_cctally_dashboard_conversation.py +1 -1
- package/bin/_cctally_dashboard_envelope.py +12 -0
- package/bin/_cctally_dashboard_sources.py +15 -1
- package/bin/_cctally_doctor.py +16 -1
- package/bin/_cctally_milestone_history.py +845 -0
- package/bin/_cctally_tui.py +23 -0
- package/bin/_cctally_update.py +338 -15
- package/bin/_cctally_weekrefs.py +6 -2
- package/bin/_lib_codex_conversation.py +1342 -18
- package/bin/_lib_codex_conversation_export.py +21 -1
- package/bin/_lib_codex_conversation_query.py +530 -67
- package/bin/_lib_conversation_dispatch.py +2 -2
- package/bin/_lib_doctor.py +38 -0
- package/bin/_lib_milestone_history.py +158 -0
- package/bin/cctally +31 -0
- package/dashboard/static/assets/index-BwvAcS_k.js +92 -0
- package/dashboard/static/assets/index-CrIlpAlQ.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-CkTe6VJt.js +0 -87
- package/dashboard/static/assets/index-DsEXuMpq.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.79.0] - 2026-07-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- The dashboard hero modal (Current Week / Current Cycle) now navigates history: the week/cycle chip's `‹`/`›` (or `ArrowUp`/`ArrowDown`) steps between previous 7-day billing cycles and a block navigator (or `ArrowLeft`/`ArrowRight`) scrolls a selected cycle's known 5-hour blocks, for both Claude subscription weeks and Codex native quota cycles; the default view stays on the current cycle with its active block. Weeks with a re-anchoring credit render the full chronological ledger with cumulative cost restarting per segment and a credit divider between segments, and empty weeks show an explicit empty state. A new `GET /api/milestones/<source>/week/<key>` route serves each selected week's complete milestone payload on demand, while a compact navigation index rides the live envelope (built only on non-idle snapshot rebuilds). Historic weeks are read-only; the Share action is hidden while a historic week is shown.
|
|
12
|
+
- Qualified Codex reasoning now uses a provider-native title/summary/body treatment instead of Claude Thinking chrome, while empty reasoning stays absent and Claude Thinking is unchanged. Evidence-preserved lifecycle fallbacks render as concise Codex task states, and recognized Git/memory harness markers become privacy-safe system actions with on-demand raw payload access; authored lookalikes remain literal prose. (#334)
|
|
13
|
+
- Qualified Codex conversation detail now preserves and renders plans, web searches, MCP completions, and agent-control operations as bounded provider-native cards with honest request/result/error state, safe web links, and on-demand raw payloads. Proven spawns gain a privacy-safe link to the exact retained child conversation only when same-root parent and canonical task-path evidence is unique; ambiguous, cross-root, malformed, and future shapes stay unlinked with the generic fallback. Local or placeholder Markdown image targets degrade to an explicit unavailable badge without a browser request or rendered path disclosure. (#332)
|
|
14
|
+
- Qualified Codex conversation detail now exposes bounded native terminal, output, and patch cards, and the dashboard renders supported `exec` calls as clean terminals plus `apply_patch`/patch-completion records as faithful diff cards. Provider names remain visible, diff-less events stay honest, raw payloads remain available on demand, and unsupported future wrappers keep the generic fallback. (#331)
|
|
15
|
+
- Beta release channel. Every release now ships to a beta channel first (npm `beta` dist-tag, GitHub prerelease); the maintainer later promotes chosen releases to stable (npm `latest`, GitHub "Latest", the Homebrew tap). Opt in with `cctally config set update.channel beta`: `cctally update` then tracks SemVer-max(beta, latest) and installs the exact resolved version (never a bare `@beta`), a `(beta)` marker appears on the CLI banner and the dashboard update badge/modal, the dashboard settings gain an Update-channel toggle, and `cctally doctor` reports the configured channel (warning on beta + Homebrew, which tracks stable only). Stable stays the default and flipping back to it never silently downgrades.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- The compact qualified conversation reader now keeps provider notification labels intact and the complete Codex input/output/cached-input/reasoning-output subtitle visible without clipping or horizontal overflow. Retained Codex world-state, inter-agent metadata, turn-context control fields, and unknown future record families stay replay-safe but no longer risk becoming fabricated transcript noise or exposing private harness state. (#335)
|
|
19
|
+
- The dashboard's qualified conversation reader now keeps Codex reasoning, tool calls/results, injected metadata labels, and lifecycle events in their native semantic roles instead of collapsing them into generic background rows. Skill-invocation titles no longer expose local `SKILL.md` paths, the outline prioritizes real prompts, logical assistant responses, and compactions while retaining full detail payload, token, model, and cost data, and an overflow menu opened immediately after reader mount is no longer closed by delayed initialization. (#330)
|
|
20
|
+
|
|
8
21
|
## [1.78.0] - 2026-07-21
|
|
9
22
|
|
|
10
23
|
### Changed
|
package/README.md
CHANGED
|
@@ -49,6 +49,8 @@ cctally dashboard # opens http://127.0.0.1:8789
|
|
|
49
49
|
|
|
50
50
|
For status-line integration and tuning, see [docs/installation.md](docs/installation.md) and [docs/configuration.md](docs/configuration.md).
|
|
51
51
|
|
|
52
|
+
**Beta channel (opt-in).** Every release ships to a beta channel first; the maintainer promotes the ones that prove out to stable, which is the default. To ride along with the newest builds as they land, opt in with `cctally config set update.channel beta` (npm or source installs — Homebrew tracks stable). `cctally update` then installs the exact latest beta version, and `cctally config set update.channel stable` flips you back cleanly with no silent downgrade. See [docs/commands/update.md](docs/commands/update.md#beta-channel).
|
|
53
|
+
|
|
52
54
|
## The live dashboard
|
|
53
55
|
|
|
54
56
|
`cctally dashboard` serves a web app at `localhost:8789` that updates live as you work - no refresh, no polling. Eleven panels cover the whole picture: **current week**, **forecast**, **$/1% trend**, **recent sessions**, **weekly**, **monthly**, **5-hour blocks**, **daily heatmap**, **projects**, **cache report**, and **recent alerts**. Any panel expands into a focused view, sessions are filterable and searchable, and a settings drawer tunes alerts and display options on the fly. It runs only on your own machine by default; one flag opens it to the other devices on your network when you want that.
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -1044,6 +1044,52 @@ def _replay_codex_normalization(conn: sqlite3.Connection) -> None:
|
|
|
1044
1044
|
_recompute_codex_rollups(conn, affected)
|
|
1045
1045
|
|
|
1046
1046
|
|
|
1047
|
+
def _repair_codex_turn_ids_for_source(
|
|
1048
|
+
conn: sqlite3.Connection, source_path: str,
|
|
1049
|
+
) -> set[str]:
|
|
1050
|
+
"""Reconcile stored normalized rows after a late native turn anchor arrives.
|
|
1051
|
+
|
|
1052
|
+
Active Codex streams can emit the response before a later completion, patch,
|
|
1053
|
+
or abort record exposes its turn id. Run this bounded per-file repair only
|
|
1054
|
+
when such a native proof arrives; ordinary append ticks remain delta-only.
|
|
1055
|
+
The physical log stays authoritative.
|
|
1056
|
+
"""
|
|
1057
|
+
events = [
|
|
1058
|
+
_lib_jsonl.CodexPhysicalEvent(*row)
|
|
1059
|
+
for row in conn.execute(
|
|
1060
|
+
"SELECT source_path, line_offset, source_root_key, conversation_key, "
|
|
1061
|
+
"native_thread_id, root_thread_id, parent_thread_id, timestamp_utc, "
|
|
1062
|
+
"record_type, event_type, turn_id, call_id, payload_json "
|
|
1063
|
+
"FROM codex_conversation_events WHERE source_path=? ORDER BY line_offset",
|
|
1064
|
+
(source_path,),
|
|
1065
|
+
)
|
|
1066
|
+
]
|
|
1067
|
+
inferred, _terminal = _lib_codex_conversation.infer_codex_event_turns(events)
|
|
1068
|
+
expected = {event.line_offset: turn for event, turn in zip(events, inferred)}
|
|
1069
|
+
affected = {
|
|
1070
|
+
row[0]
|
|
1071
|
+
for row in conn.execute(
|
|
1072
|
+
"SELECT DISTINCT conversation_key FROM codex_conversation_messages "
|
|
1073
|
+
"WHERE source_path=?",
|
|
1074
|
+
(source_path,),
|
|
1075
|
+
)
|
|
1076
|
+
if row[0]
|
|
1077
|
+
}
|
|
1078
|
+
for line_offset, stored_turn in conn.execute(
|
|
1079
|
+
"SELECT line_offset, turn_id FROM codex_conversation_messages "
|
|
1080
|
+
"WHERE source_path=?",
|
|
1081
|
+
(source_path,),
|
|
1082
|
+
):
|
|
1083
|
+
inferred_turn = expected.get(line_offset)
|
|
1084
|
+
if stored_turn != inferred_turn:
|
|
1085
|
+
conn.execute(
|
|
1086
|
+
"UPDATE codex_conversation_messages SET turn_id=? "
|
|
1087
|
+
"WHERE source_path=? AND line_offset=?",
|
|
1088
|
+
(inferred_turn, source_path, line_offset),
|
|
1089
|
+
)
|
|
1090
|
+
return affected
|
|
1091
|
+
|
|
1092
|
+
|
|
1047
1093
|
def _collect_inactive_codex_paths_and_roots(
|
|
1048
1094
|
conn: sqlite3.Connection,
|
|
1049
1095
|
current_file_identities: set[tuple[str, str]],
|
|
@@ -4849,6 +4895,7 @@ def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
|
|
|
4849
4895
|
cache_uri = _cctally_core.CACHE_DB_PATH.resolve().as_uri() + "?mode=ro"
|
|
4850
4896
|
conn.execute("ATTACH DATABASE ? AS cache_db", (cache_uri,))
|
|
4851
4897
|
_import_legacy_conversation_rows(conn)
|
|
4898
|
+
_ensure_codex_conversation_contract(conn)
|
|
4852
4899
|
_harden_conversation_sidecars()
|
|
4853
4900
|
return conn
|
|
4854
4901
|
|
|
@@ -4900,6 +4947,68 @@ def _import_legacy_conversation_rows(conn: sqlite3.Connection) -> None:
|
|
|
4900
4947
|
conn.commit()
|
|
4901
4948
|
|
|
4902
4949
|
|
|
4950
|
+
def _ensure_codex_conversation_contract(conn: sqlite3.Connection) -> bool:
|
|
4951
|
+
"""Converge retained Codex events on first read after a contract bump.
|
|
4952
|
+
|
|
4953
|
+
Qualified CLI reads and ``dashboard --no-sync`` intentionally do not ingest
|
|
4954
|
+
JSONL. They still must remain usable after an upgrade, so replay only the
|
|
4955
|
+
already-retained physical events under the provider-local conversation lock.
|
|
4956
|
+
Empty stores keep their existing rebuild marker for the next real sync.
|
|
4957
|
+
"""
|
|
4958
|
+
current = _lib_codex_conversation.CODEX_CONVERSATION_CONTRACT_VERSION
|
|
4959
|
+
|
|
4960
|
+
def needs_replay() -> bool:
|
|
4961
|
+
version = conn.execute(
|
|
4962
|
+
"SELECT value FROM cache_meta "
|
|
4963
|
+
"WHERE key='codex_conversation_contract_version'"
|
|
4964
|
+
).fetchone()
|
|
4965
|
+
pending = conn.execute(
|
|
4966
|
+
"SELECT 1 FROM cache_meta "
|
|
4967
|
+
"WHERE key='conversation_rebuild_codex_pending'"
|
|
4968
|
+
).fetchone() is not None
|
|
4969
|
+
has_events = conn.execute(
|
|
4970
|
+
"SELECT 1 FROM codex_conversation_events LIMIT 1"
|
|
4971
|
+
).fetchone() is not None
|
|
4972
|
+
return has_events and (pending or version is None or version[0] != current)
|
|
4973
|
+
|
|
4974
|
+
if not needs_replay():
|
|
4975
|
+
return False
|
|
4976
|
+
|
|
4977
|
+
_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH.touch()
|
|
4978
|
+
lock_fh = open(_cctally_core.CONVERSATIONS_LOCK_CODEX_PATH, "w")
|
|
4979
|
+
try:
|
|
4980
|
+
if not _acquire_cache_flock(lock_fh, timeout=15.0):
|
|
4981
|
+
return False
|
|
4982
|
+
if not needs_replay():
|
|
4983
|
+
return False
|
|
4984
|
+
try:
|
|
4985
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
4986
|
+
_codex_conversation_fts_full_clear(conn)
|
|
4987
|
+
_replay_codex_normalization(conn)
|
|
4988
|
+
conn.execute(
|
|
4989
|
+
"INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
|
|
4990
|
+
("codex_conversation_contract_version", current),
|
|
4991
|
+
)
|
|
4992
|
+
conn.execute(
|
|
4993
|
+
"DELETE FROM cache_meta "
|
|
4994
|
+
"WHERE key='conversation_rebuild_codex_pending'"
|
|
4995
|
+
)
|
|
4996
|
+
conn.commit()
|
|
4997
|
+
return True
|
|
4998
|
+
except (sqlite3.DatabaseError, OSError, ValueError) as exc:
|
|
4999
|
+
conn.rollback()
|
|
5000
|
+
eprint(
|
|
5001
|
+
f"[codex-conversations] retained-event contract replay failed: {exc}"
|
|
5002
|
+
)
|
|
5003
|
+
return False
|
|
5004
|
+
finally:
|
|
5005
|
+
try:
|
|
5006
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
5007
|
+
except OSError:
|
|
5008
|
+
pass
|
|
5009
|
+
lock_fh.close()
|
|
5010
|
+
|
|
5011
|
+
|
|
4903
5012
|
def _prepare_claude_conversation_maintenance(
|
|
4904
5013
|
conn: sqlite3.Connection,
|
|
4905
5014
|
*,
|
|
@@ -5240,10 +5349,25 @@ def sync_codex_conversations(
|
|
|
5240
5349
|
"SELECT 1 FROM cache_meta "
|
|
5241
5350
|
"WHERE key='conversation_rebuild_codex_pending'"
|
|
5242
5351
|
).fetchone() is not None
|
|
5243
|
-
|
|
5352
|
+
contract_row = conn.execute(
|
|
5353
|
+
"SELECT value FROM cache_meta "
|
|
5354
|
+
"WHERE key='codex_conversation_contract_version'"
|
|
5355
|
+
).fetchone()
|
|
5356
|
+
has_contract_state = conn.execute(
|
|
5357
|
+
"SELECT 1 FROM codex_conversation_events LIMIT 1"
|
|
5358
|
+
).fetchone() is not None
|
|
5359
|
+
contract_rebuild = (
|
|
5360
|
+
has_contract_state
|
|
5361
|
+
and (
|
|
5362
|
+
contract_row is None
|
|
5363
|
+
or contract_row[0]
|
|
5364
|
+
!= _lib_codex_conversation.CODEX_CONVERSATION_CONTRACT_VERSION
|
|
5365
|
+
)
|
|
5366
|
+
)
|
|
5367
|
+
if (pending_rebuild or contract_rebuild) and targeted:
|
|
5244
5368
|
stats.deferred_reason = "rebuild_pending"
|
|
5245
5369
|
return stats
|
|
5246
|
-
rebuild = rebuild or pending_rebuild
|
|
5370
|
+
rebuild = rebuild or pending_rebuild or contract_rebuild
|
|
5247
5371
|
if rebuild:
|
|
5248
5372
|
_clear_codex_conversation_store(conn)
|
|
5249
5373
|
conn.commit()
|
|
@@ -5464,6 +5588,13 @@ def sync_codex_conversations(
|
|
|
5464
5588
|
affected_keys.update(
|
|
5465
5589
|
row.conversation_key for row in normalized.rows
|
|
5466
5590
|
)
|
|
5591
|
+
if any(
|
|
5592
|
+
_lib_codex_conversation.codex_event_is_late_turn_anchor(event)
|
|
5593
|
+
for event in events
|
|
5594
|
+
):
|
|
5595
|
+
affected_keys.update(
|
|
5596
|
+
_repair_codex_turn_ids_for_source(conn, path_str)
|
|
5597
|
+
)
|
|
5467
5598
|
_recompute_codex_rollups(conn, affected_keys)
|
|
5468
5599
|
terminal = state.thread
|
|
5469
5600
|
conn.execute(
|
|
@@ -5511,6 +5642,13 @@ def sync_codex_conversations(
|
|
|
5511
5642
|
stats.files_failed += 1
|
|
5512
5643
|
|
|
5513
5644
|
if only_paths is None and stats.files_failed == 0:
|
|
5645
|
+
conn.execute(
|
|
5646
|
+
"INSERT OR REPLACE INTO cache_meta(key,value) VALUES(?,?)",
|
|
5647
|
+
(
|
|
5648
|
+
"codex_conversation_contract_version",
|
|
5649
|
+
_lib_codex_conversation.CODEX_CONVERSATION_CONTRACT_VERSION,
|
|
5650
|
+
),
|
|
5651
|
+
)
|
|
5514
5652
|
conn.execute(
|
|
5515
5653
|
"DELETE FROM cache_meta "
|
|
5516
5654
|
"WHERE key='conversation_rebuild_codex_pending'"
|
package/bin/_cctally_config.py
CHANGED
|
@@ -309,6 +309,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
309
309
|
"dashboard.live_tail",
|
|
310
310
|
"update.check.enabled",
|
|
311
311
|
"update.check.ttl_hours",
|
|
312
|
+
"update.channel",
|
|
312
313
|
"statusline.visual_burn_rate",
|
|
313
314
|
"statusline.cost_source",
|
|
314
315
|
"statusline.cctally_extensions",
|
|
@@ -883,6 +884,12 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
883
884
|
return c._validate_update_check_ttl_hours_value(stored)
|
|
884
885
|
except ValueError:
|
|
885
886
|
return c.UPDATE_DEFAULT_TTL_HOURS
|
|
887
|
+
if key == "update.channel":
|
|
888
|
+
# Release channel opt-in (beta-channel, spec 2026-07-21 §3). Default
|
|
889
|
+
# "stable"; hand-edited junk surfaces the default — mirrors
|
|
890
|
+
# dashboard.bind / update.check.*. The single resolver keeps the CLI,
|
|
891
|
+
# check pipeline, doctor, and dashboard envelope in lockstep.
|
|
892
|
+
return c.resolve_update_channel(config)
|
|
886
893
|
if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
887
894
|
return _config_codex_leaf_value(config, key)
|
|
888
895
|
if key in (
|
|
@@ -1564,6 +1571,35 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1564
1571
|
rendered = str(normalized)
|
|
1565
1572
|
print(f"{key}={rendered}")
|
|
1566
1573
|
return 0
|
|
1574
|
+
if key == "update.channel":
|
|
1575
|
+
# Release channel opt-in (beta-channel, spec 2026-07-21 §3). Enum
|
|
1576
|
+
# {stable,beta}; validate first so rejection short-circuits before
|
|
1577
|
+
# lock acquisition. Read-modify-write preserving the sibling
|
|
1578
|
+
# `update.check` block (mirrors update.check.* preserving nothing it
|
|
1579
|
+
# doesn't own).
|
|
1580
|
+
try:
|
|
1581
|
+
canonical = c._validate_update_channel_value(raw)
|
|
1582
|
+
except ValueError as exc:
|
|
1583
|
+
print(f"cctally config: {exc}", file=sys.stderr)
|
|
1584
|
+
return 2
|
|
1585
|
+
with config_writer_lock():
|
|
1586
|
+
config = _load_config_unlocked()
|
|
1587
|
+
existing_update = config.get("update")
|
|
1588
|
+
if existing_update is not None and not isinstance(existing_update, dict):
|
|
1589
|
+
print(
|
|
1590
|
+
"cctally: update config error: update must be an object",
|
|
1591
|
+
file=sys.stderr,
|
|
1592
|
+
)
|
|
1593
|
+
return 2
|
|
1594
|
+
update_block = dict(existing_update or {})
|
|
1595
|
+
update_block["channel"] = canonical
|
|
1596
|
+
config["update"] = update_block
|
|
1597
|
+
save_config(config)
|
|
1598
|
+
if getattr(args, "emit_json", False):
|
|
1599
|
+
print(json.dumps({"update": {"channel": canonical}}, indent=2))
|
|
1600
|
+
else:
|
|
1601
|
+
print(f"update.channel={canonical}")
|
|
1602
|
+
return 0
|
|
1567
1603
|
if key in (
|
|
1568
1604
|
"budget.weekly_usd",
|
|
1569
1605
|
"budget.alerts_enabled",
|
|
@@ -1990,6 +2026,20 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1990
2026
|
save_config(config)
|
|
1991
2027
|
# idempotent: silent on missing key
|
|
1992
2028
|
return 0
|
|
2029
|
+
if key == "update.channel":
|
|
2030
|
+
# Drop the channel leaf; preserve a sibling update.check block, and
|
|
2031
|
+
# prune an empty `update` so config.json stays tidy. Mirrors the
|
|
2032
|
+
# update.check.* unset branch above.
|
|
2033
|
+
with config_writer_lock():
|
|
2034
|
+
config = _load_config_unlocked()
|
|
2035
|
+
update_block = config.get("update")
|
|
2036
|
+
if isinstance(update_block, dict) and "channel" in update_block:
|
|
2037
|
+
del update_block["channel"]
|
|
2038
|
+
if not update_block:
|
|
2039
|
+
config.pop("update", None)
|
|
2040
|
+
save_config(config)
|
|
2041
|
+
# idempotent: silent on missing key
|
|
2042
|
+
return 0
|
|
1993
2043
|
if key in (
|
|
1994
2044
|
"budget.weekly_usd",
|
|
1995
2045
|
"budget.alerts_enabled",
|
|
@@ -4435,6 +4435,7 @@ _GET_ROUTES = (
|
|
|
4435
4435
|
("prefix", "/api/session/", "_handle_get_session_detail", None, True),
|
|
4436
4436
|
("prefix", "/api/project/", "_handle_get_project_detail", None, False),
|
|
4437
4437
|
("prefix", "/api/block/", "_handle_get_block_detail", None, True),
|
|
4438
|
+
("prefix", "/api/milestones/", "_handle_get_milestones_week", None, True),
|
|
4438
4439
|
("exact", "/api/update/status", "_handle_get_update_status", None, False),
|
|
4439
4440
|
("prefix", "/api/update/stream/", "_handle_get_update_stream", None, True),
|
|
4440
4441
|
("exact", "/api/share/templates", "_handle_share_templates_get", None, False),
|
|
@@ -5228,6 +5229,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5228
5229
|
# compatible. `enabled` must be a JSON bool; `ttl_hours` an int
|
|
5229
5230
|
# (bools rejected — see _validate_update_check_ttl_hours_value).
|
|
5230
5231
|
update_check_validated: "dict | None" = None
|
|
5232
|
+
update_channel_validated: "str | None" = None
|
|
5231
5233
|
if "update" in payload:
|
|
5232
5234
|
update_in = payload["update"]
|
|
5233
5235
|
if not isinstance(update_in, dict):
|
|
@@ -5236,13 +5238,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5236
5238
|
)
|
|
5237
5239
|
return
|
|
5238
5240
|
for inner in update_in.keys():
|
|
5239
|
-
if inner
|
|
5241
|
+
if inner not in ("check", "channel"):
|
|
5240
5242
|
self._respond_json(
|
|
5241
5243
|
400,
|
|
5242
5244
|
{"error": f"unknown update settings key: {inner}",
|
|
5243
5245
|
"field": f"update.{inner}"},
|
|
5244
5246
|
)
|
|
5245
5247
|
return
|
|
5248
|
+
# Release channel opt-in (beta-channel, spec 2026-07-21 §3). Enum
|
|
5249
|
+
# {stable,beta}; 400 on invalid. Mirrors the config-key validator.
|
|
5250
|
+
if "channel" in update_in:
|
|
5251
|
+
try:
|
|
5252
|
+
update_channel_validated = (
|
|
5253
|
+
_cctally()._validate_update_channel_value(
|
|
5254
|
+
update_in["channel"]
|
|
5255
|
+
)
|
|
5256
|
+
)
|
|
5257
|
+
except ValueError as exc:
|
|
5258
|
+
self._respond_json(
|
|
5259
|
+
400,
|
|
5260
|
+
{"error": str(exc), "field": "update.channel"},
|
|
5261
|
+
)
|
|
5262
|
+
return
|
|
5246
5263
|
check_in = update_in.get("check", {})
|
|
5247
5264
|
if not isinstance(check_in, dict):
|
|
5248
5265
|
self._respond_json(
|
|
@@ -5398,7 +5415,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5398
5415
|
self._respond_json(400, {"error": str(exc)})
|
|
5399
5416
|
return
|
|
5400
5417
|
|
|
5401
|
-
if
|
|
5418
|
+
if (
|
|
5419
|
+
update_check_validated is not None
|
|
5420
|
+
or update_channel_validated is not None
|
|
5421
|
+
):
|
|
5402
5422
|
# Same hand-edited-junk guard as alerts: a non-dict
|
|
5403
5423
|
# `update` or `update.check` block in config.json should
|
|
5404
5424
|
# surface as a recoverable 400, not a 500.
|
|
@@ -5412,18 +5432,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5412
5432
|
)
|
|
5413
5433
|
return
|
|
5414
5434
|
merged_update = dict(existing_update or {})
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
existing_check
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5435
|
+
if update_check_validated is not None:
|
|
5436
|
+
existing_check = merged_update.get("check")
|
|
5437
|
+
if existing_check is not None and not isinstance(
|
|
5438
|
+
existing_check, dict
|
|
5439
|
+
):
|
|
5440
|
+
self._respond_json(
|
|
5441
|
+
400, {"error": "update.check must be an object",
|
|
5442
|
+
"field": "update.check"}
|
|
5443
|
+
)
|
|
5444
|
+
return
|
|
5445
|
+
merged_check = dict(existing_check or {})
|
|
5446
|
+
merged_check.update(update_check_validated)
|
|
5447
|
+
merged_update["check"] = merged_check
|
|
5448
|
+
if update_channel_validated is not None:
|
|
5449
|
+
# Partial-PUT: preserve the sibling `update.check` block.
|
|
5450
|
+
merged_update["channel"] = update_channel_validated
|
|
5427
5451
|
merged["update"] = merged_update
|
|
5428
5452
|
|
|
5429
5453
|
if cache_report_validated is not None:
|
|
@@ -5531,11 +5555,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5531
5555
|
_cctally()._reconcile_codex_budget_on_config_write(
|
|
5532
5556
|
validated_budget
|
|
5533
5557
|
)
|
|
5534
|
-
if
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5558
|
+
if (
|
|
5559
|
+
update_check_validated is not None
|
|
5560
|
+
or update_channel_validated is not None
|
|
5561
|
+
):
|
|
5562
|
+
# Echo the touched update leaves (cooked defaults included) so the
|
|
5563
|
+
# SettingsOverlay can repaint without a follow-up GET. The channel
|
|
5564
|
+
# echo uses the read-surface name `configured_channel` (spec §3
|
|
5565
|
+
# naming), mirrored from the merged config via the single resolver.
|
|
5566
|
+
out_update: dict = {}
|
|
5567
|
+
if update_check_validated is not None:
|
|
5568
|
+
out_update["check"] = {
|
|
5539
5569
|
"enabled": _config_known_value(
|
|
5540
5570
|
merged, "update.check.enabled"
|
|
5541
5571
|
),
|
|
@@ -5543,7 +5573,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5543
5573
|
merged, "update.check.ttl_hours"
|
|
5544
5574
|
),
|
|
5545
5575
|
}
|
|
5546
|
-
|
|
5576
|
+
if update_channel_validated is not None:
|
|
5577
|
+
out_update["configured_channel"] = (
|
|
5578
|
+
_cctally().resolve_update_channel(merged)
|
|
5579
|
+
)
|
|
5580
|
+
out["update"] = out_update
|
|
5547
5581
|
if cache_report_validated is not None:
|
|
5548
5582
|
# Echo the full cooked block (resolved defaults included) so
|
|
5549
5583
|
# the dashboard composer can repaint without a follow-up GET
|
|
@@ -6272,6 +6306,117 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6272
6306
|
self.end_headers()
|
|
6273
6307
|
self.wfile.write(body)
|
|
6274
6308
|
|
|
6309
|
+
def _send_milestones_json(self, status: int, body: dict) -> None:
|
|
6310
|
+
payload = encode_dashboard_json_bytes(body, ensure_ascii=False)
|
|
6311
|
+
self.send_response(status)
|
|
6312
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
6313
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
6314
|
+
self.send_header("Cache-Control", "no-cache")
|
|
6315
|
+
self.end_headers()
|
|
6316
|
+
self.wfile.write(payload)
|
|
6317
|
+
|
|
6318
|
+
def _handle_get_milestones_week(self, path: str) -> None:
|
|
6319
|
+
"""GET /api/milestones/<source>/week/<key> — one week's/cycle's
|
|
6320
|
+
complete milestone-history payload (spec §2, hero-modal history).
|
|
6321
|
+
|
|
6322
|
+
Dedicated route (deliberately NOT the /api/source/ family, whose
|
|
6323
|
+
not-found semantics are scoped to the current frozen bundle). Wire is
|
|
6324
|
+
snake_case via ``encode_dashboard_json_bytes`` (the dashboard
|
|
6325
|
+
convention — NOT the CLI camelCase envelope). Read-only, per-request
|
|
6326
|
+
connections, no sync/migration writes from the assembly; the multi-read
|
|
6327
|
+
assembly runs inside one transaction per DB. 400 malformed key/source;
|
|
6328
|
+
404 ``{code: "unknown_key", reason}`` for keys that don't resolve.
|
|
6329
|
+
"""
|
|
6330
|
+
import re as _re
|
|
6331
|
+
import types as _types
|
|
6332
|
+
import urllib.parse as _urlparse
|
|
6333
|
+
from _cctally_cache import open_cache_db
|
|
6334
|
+
|
|
6335
|
+
tail = path[len("/api/milestones/"):]
|
|
6336
|
+
parts = tail.split("/week/", 1)
|
|
6337
|
+
if len(parts) != 2 or not parts[1]:
|
|
6338
|
+
self._send_milestones_json(400, {"error": "invalid path"})
|
|
6339
|
+
return
|
|
6340
|
+
source, raw_key = parts[0], parts[1]
|
|
6341
|
+
if source not in ("claude", "codex"):
|
|
6342
|
+
self._send_milestones_json(400, {"error": "invalid source"})
|
|
6343
|
+
return
|
|
6344
|
+
key = _urlparse.unquote(raw_key)
|
|
6345
|
+
c = sys.modules["cctally"]
|
|
6346
|
+
try:
|
|
6347
|
+
if source == "claude":
|
|
6348
|
+
# Claude key is a week_start_date (YYYY-MM-DD).
|
|
6349
|
+
if not _re.fullmatch(r"\d{4}-\d{2}-\d{2}", key):
|
|
6350
|
+
self._send_milestones_json(400, {"error": "invalid week key"})
|
|
6351
|
+
return
|
|
6352
|
+
conn = open_db()
|
|
6353
|
+
try:
|
|
6354
|
+
conn.execute("BEGIN")
|
|
6355
|
+
detail = c.build_claude_week_detail(conn, key)
|
|
6356
|
+
conn.commit()
|
|
6357
|
+
finally:
|
|
6358
|
+
conn.close()
|
|
6359
|
+
if detail is None:
|
|
6360
|
+
self._send_milestones_json(404, {
|
|
6361
|
+
"error": "unknown week", "code": "unknown_key",
|
|
6362
|
+
"reason": "unknown",
|
|
6363
|
+
})
|
|
6364
|
+
return
|
|
6365
|
+
self._send_milestones_json(200, {**detail, "source": "claude", "key": key})
|
|
6366
|
+
return
|
|
6367
|
+
|
|
6368
|
+
# Codex: opaque server-issued cycle key. Resolve across all codex
|
|
6369
|
+
# roots (keys are unique per cycle, so a client-supplied index key
|
|
6370
|
+
# matches its exact cycle without needing the hero-cycle identity —
|
|
6371
|
+
# this also keeps a just-closed former-current cycle fetchable when
|
|
6372
|
+
# the live hero cycle is momentarily unavailable, spec §2).
|
|
6373
|
+
from _cctally_dashboard_sources import resolve_dashboard_source_semantics
|
|
6374
|
+
speed = resolve_dashboard_source_semantics(
|
|
6375
|
+
load_config(), display_tz_name="UTC",
|
|
6376
|
+
).speed
|
|
6377
|
+
now_utc = _command_as_of()
|
|
6378
|
+
stats_conn = open_db()
|
|
6379
|
+
cache_conn = open_cache_db()
|
|
6380
|
+
try:
|
|
6381
|
+
# Stable read across BOTH DBs for a single response (spec §2):
|
|
6382
|
+
# the cache conn supplies the physical cost correlation, so it
|
|
6383
|
+
# gets the same BEGIN/commit envelope as the stats conn.
|
|
6384
|
+
stats_conn.execute("BEGIN")
|
|
6385
|
+
cache_conn.execute("BEGIN")
|
|
6386
|
+
roots = tuple(sorted({
|
|
6387
|
+
str(r[0]) for r in stats_conn.execute(
|
|
6388
|
+
"SELECT DISTINCT source_root_key FROM quota_window_blocks "
|
|
6389
|
+
"WHERE source='codex'"
|
|
6390
|
+
)
|
|
6391
|
+
}))
|
|
6392
|
+
identity = _types.SimpleNamespace(source_root_keys=roots, resets_at=None)
|
|
6393
|
+
result = c.build_codex_cycle_detail(
|
|
6394
|
+
stats_conn, cache_conn, identity=identity, key=key,
|
|
6395
|
+
speed=speed, now_utc=now_utc,
|
|
6396
|
+
)
|
|
6397
|
+
stats_conn.commit()
|
|
6398
|
+
cache_conn.commit()
|
|
6399
|
+
finally:
|
|
6400
|
+
stats_conn.close()
|
|
6401
|
+
cache_conn.close()
|
|
6402
|
+
if isinstance(result, tuple):
|
|
6403
|
+
_none, reason = result
|
|
6404
|
+
self._send_milestones_json(404, {
|
|
6405
|
+
"error": "unknown cycle", "code": "unknown_key",
|
|
6406
|
+
"reason": reason,
|
|
6407
|
+
})
|
|
6408
|
+
return
|
|
6409
|
+
if result is None:
|
|
6410
|
+
self._send_milestones_json(404, {
|
|
6411
|
+
"error": "unknown cycle", "code": "unknown_key",
|
|
6412
|
+
"reason": "unknown",
|
|
6413
|
+
})
|
|
6414
|
+
return
|
|
6415
|
+
self._send_milestones_json(200, {**result, "source": "codex", "key": key})
|
|
6416
|
+
except Exception as exc: # noqa: BLE001
|
|
6417
|
+
self.log_error("/api/milestones failed: %r", exc)
|
|
6418
|
+
self.send_error(500, "milestones detail failed")
|
|
6419
|
+
|
|
6275
6420
|
def _serve_api_events(self) -> None:
|
|
6276
6421
|
import queue as _queue
|
|
6277
6422
|
self.send_response(200)
|
|
@@ -6496,9 +6641,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6496
6641
|
_w.status() if _w is not None
|
|
6497
6642
|
else {"current_run_id": None}
|
|
6498
6643
|
)
|
|
6644
|
+
# Beta-channel (spec 2026-07-21 §3): carry the configured release
|
|
6645
|
+
# channel (from config, not cached state) so the badge/modal keep the
|
|
6646
|
+
# `(beta)` marker + exact-version command after an execvp refresh, when
|
|
6647
|
+
# the client polls /api/update/status instead of the SSE envelope.
|
|
6648
|
+
try:
|
|
6649
|
+
_c = _cctally()
|
|
6650
|
+
configured_channel = _c.resolve_update_channel(_c.load_config())
|
|
6651
|
+
except Exception:
|
|
6652
|
+
configured_channel = "stable"
|
|
6499
6653
|
body = {
|
|
6500
6654
|
"state": state,
|
|
6501
6655
|
"suppress": suppress,
|
|
6656
|
+
"configured_channel": configured_channel,
|
|
6502
6657
|
**worker_status,
|
|
6503
6658
|
}
|
|
6504
6659
|
self._respond_json(200, body)
|
|
@@ -1057,7 +1057,7 @@ def _handle_get_conversation_payload_impl(handler, path: str) -> None:
|
|
|
1057
1057
|
q = _u.parse_qs(handler.path.partition("?")[2])
|
|
1058
1058
|
if session_id.startswith("v1."):
|
|
1059
1059
|
# Qualified payload readback (§3.4): Codex uses block_key + which={call,
|
|
1060
|
-
# output}; a v1.claude key keeps the tool_use_id + which={input,result}
|
|
1060
|
+
# output,event}; a v1.claude key keeps the tool_use_id + which={input,result}
|
|
1061
1061
|
# selector. neutral_payload picks per the resolved source; gone → 410.
|
|
1062
1062
|
which_q = _qs_str(q, "which", "")
|
|
1063
1063
|
tool_use_id_q = _qs_str(q, "tool_use_id", "") or None
|
|
@@ -1287,6 +1287,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1287
1287
|
update_envelope = {
|
|
1288
1288
|
"state": _update_state_envelope,
|
|
1289
1289
|
"suppress": _update_suppress_envelope,
|
|
1290
|
+
# Beta-channel (spec 2026-07-21 §3): the configured release channel,
|
|
1291
|
+
# derived DIRECTLY from config (never from cached state — a cached
|
|
1292
|
+
# producer field can't seed the settings toggle after a failed fetch
|
|
1293
|
+
# or on brew). The resolved-target fields (latest_version /
|
|
1294
|
+
# resolved_dist_tag / latest_version_channel) ride inside `state`.
|
|
1295
|
+
"configured_channel": sys.modules["cctally"].resolve_update_channel(config),
|
|
1290
1296
|
}
|
|
1291
1297
|
|
|
1292
1298
|
# Doctor aggregate block (spec §5.5). Only the small severity tree
|
|
@@ -1427,6 +1433,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1427
1433
|
# bound or the data source crashed during sync
|
|
1428
1434
|
# (recorded on ``last_sync_error``).
|
|
1429
1435
|
"five_hour_milestones": getattr(snap, "five_hour_milestones", []) or [],
|
|
1436
|
+
# Hero-modal historical-milestone navigation index (spec §3).
|
|
1437
|
+
# Additive/optional: newest-first per-week entries built on the
|
|
1438
|
+
# non-idle rebuild and stored on the snapshot; ``getattr``
|
|
1439
|
+
# default keeps legacy/positional fixture snapshots serializing
|
|
1440
|
+
# (they emit ``[]``). Historical rows never ride the envelope.
|
|
1441
|
+
"week_index": getattr(snap, "week_index", []) or [],
|
|
1430
1442
|
},
|
|
1431
1443
|
|
|
1432
1444
|
"forecast":
|
|
@@ -2080,7 +2080,21 @@ def build_codex_source_state(
|
|
|
2080
2080
|
now_utc=context.now_utc,
|
|
2081
2081
|
display_tz_name=context.display_tz_name,
|
|
2082
2082
|
)
|
|
2083
|
-
|
|
2083
|
+
# Hero-modal historical-milestone navigation index (spec §1c, §3). Built
|
|
2084
|
+
# here on the non-idle codex source rebuild (idle ticks reuse the stored
|
|
2085
|
+
# bundle) over the durable projection — a pure serializer never touches it.
|
|
2086
|
+
# Guarded: an index failure must never fail the codex source build.
|
|
2087
|
+
cycle_index: tuple = ()
|
|
2088
|
+
if cycle is not None and not hero_failure:
|
|
2089
|
+
try:
|
|
2090
|
+
cycle_index = tuple(
|
|
2091
|
+
sys.modules["cctally"].build_codex_cycle_index(
|
|
2092
|
+
context.stats_conn, identity=cycle, now_utc=context.now_utc,
|
|
2093
|
+
)
|
|
2094
|
+
)
|
|
2095
|
+
except sqlite3.Error:
|
|
2096
|
+
cycle_index = ()
|
|
2097
|
+
quota = {**quota, "blocks": quota_blocks, "cycle_index": cycle_index}
|
|
2084
2098
|
budget_rows = _budget_wire(context.stats_conn)
|
|
2085
2099
|
projected_budget_rows = _projected_budget_wire(context.stats_conn)
|
|
2086
2100
|
budget_cost_events = _codex_budget_cost_events(context, budget_entries)
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -800,14 +800,28 @@ def doctor_gather_state(
|
|
|
800
800
|
# on corruption — both behaviors would hide diagnostic state
|
|
801
801
|
# (codex H1).
|
|
802
802
|
config_json_error = None
|
|
803
|
+
config_parsed: dict = {}
|
|
803
804
|
try:
|
|
804
805
|
if _cctally_core.CONFIG_PATH.exists():
|
|
805
|
-
json.loads(
|
|
806
|
+
config_parsed = json.loads(
|
|
807
|
+
_cctally_core.CONFIG_PATH.read_text(encoding="utf-8")
|
|
808
|
+
)
|
|
806
809
|
except json.JSONDecodeError as exc:
|
|
807
810
|
config_json_error = f"{type(exc).__name__}: {exc}"
|
|
808
811
|
except OSError as exc:
|
|
809
812
|
config_json_error = f"OSError: {exc}"
|
|
810
813
|
|
|
814
|
+
# Configured update (release) channel (beta-channel, spec 2026-07-21 §3):
|
|
815
|
+
# derived from the SAME raw read (never load_config, which auto-creates on
|
|
816
|
+
# first run). Fail-soft to "stable" — resolve_update_channel already
|
|
817
|
+
# tolerates a non-dict block / junk value.
|
|
818
|
+
try:
|
|
819
|
+
update_channel = c.resolve_update_channel(
|
|
820
|
+
config_parsed if isinstance(config_parsed, dict) else {}
|
|
821
|
+
)
|
|
822
|
+
except Exception:
|
|
823
|
+
update_channel = "stable"
|
|
824
|
+
|
|
811
825
|
update_state = None
|
|
812
826
|
update_state_error = None
|
|
813
827
|
try:
|
|
@@ -880,6 +894,7 @@ def doctor_gather_state(
|
|
|
880
894
|
codex_jsonl_present=codex_jsonl_present,
|
|
881
895
|
codex_project_metadata_health=codex_project_metadata_health,
|
|
882
896
|
codex_project_metadata_error=codex_project_metadata_error,
|
|
897
|
+
update_channel=update_channel,
|
|
883
898
|
dashboard_bind_stored=dashboard_bind_stored,
|
|
884
899
|
runtime_bind=runtime_bind,
|
|
885
900
|
# Conversation viewer (Plan 2, spec §5): only consulted on a LAN bind.
|