cctally 1.91.0 → 1.92.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 +51 -0
- package/README.md +4 -2
- package/bin/_cctally_cache.py +903 -74
- package/bin/_cctally_config.py +57 -0
- package/bin/_cctally_core.py +94 -14
- package/bin/_cctally_dashboard.py +217 -19
- package/bin/_cctally_dashboard_conversation.py +170 -20
- package/bin/_cctally_dashboard_envelope.py +2 -0
- package/bin/_cctally_db.py +481 -19
- package/bin/_cctally_doctor.py +18 -1
- package/bin/_cctally_journal.py +1156 -21
- package/bin/_cctally_journal_repair.py +6 -0
- package/bin/_cctally_parser.py +26 -0
- package/bin/_cctally_quota.py +171 -55
- package/bin/_cctally_record.py +13 -1
- package/bin/_cctally_rederive.py +4 -0
- package/bin/_cctally_statusline.py +6 -6
- package/bin/_cctally_store.py +1061 -40
- package/bin/_cctally_transcript.py +32 -2
- package/bin/_cctally_tui.py +54 -6
- package/bin/_lib_cache_report.py +8 -3
- package/bin/_lib_codex_conversation.py +851 -81
- package/bin/_lib_codex_conversation_query.py +2031 -96
- package/bin/_lib_codex_find_projection.py +517 -0
- package/bin/_lib_codex_harness_preamble.py +176 -0
- package/bin/_lib_codex_hooks.py +5 -3
- package/bin/_lib_codex_js_scan.py +254 -0
- package/bin/_lib_codex_landmarks.py +309 -0
- package/bin/_lib_codex_title_clean.py +116 -0
- package/bin/_lib_conversation_dispatch.py +168 -22
- package/bin/_lib_conversation_query.py +62 -2
- package/bin/_lib_conversation_watch.py +4 -2
- package/bin/_lib_doctor.py +64 -0
- package/bin/_lib_quota_alert_axes.py +31 -34
- package/bin/_lib_stats_damage.py +523 -0
- package/bin/_lib_stats_publish.py +243 -0
- package/bin/cctally +17 -3
- package/dashboard/static/assets/index-Dat-mza6.js +97 -0
- package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +8 -1
- package/dashboard/static/assets/index-CILAoEja.js +0 -90
package/bin/_cctally_config.py
CHANGED
|
@@ -307,6 +307,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
307
307
|
"dashboard.expose_transcripts",
|
|
308
308
|
"dashboard.cache_failure_markers",
|
|
309
309
|
"dashboard.live_tail",
|
|
310
|
+
"dashboard.lan_auth",
|
|
310
311
|
"update.check.enabled",
|
|
311
312
|
"update.check.ttl_hours",
|
|
312
313
|
"update.channel",
|
|
@@ -1028,6 +1029,22 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
1028
1029
|
except ValueError:
|
|
1029
1030
|
return True
|
|
1030
1031
|
return True
|
|
1032
|
+
if key == "dashboard.lan_auth":
|
|
1033
|
+
# Boolean opt-OUT (issue #282). Default TRUE — non-loopback dashboard
|
|
1034
|
+
# runs require their per-run access token unless the user explicitly
|
|
1035
|
+
# disables the gate. Invalid hand edits fail safe to authentication ON.
|
|
1036
|
+
block = config.get("dashboard") if isinstance(config, dict) else None
|
|
1037
|
+
if not isinstance(block, dict):
|
|
1038
|
+
block = {}
|
|
1039
|
+
stored = block.get("lan_auth")
|
|
1040
|
+
if stored is None:
|
|
1041
|
+
return True
|
|
1042
|
+
if isinstance(stored, bool):
|
|
1043
|
+
return stored
|
|
1044
|
+
# The config command normalizes accepted text spellings before write.
|
|
1045
|
+
# A persisted string is therefore a hand edit, not an explicit opt-out;
|
|
1046
|
+
# accept only the JSON boolean false at this security boundary.
|
|
1047
|
+
return True
|
|
1031
1048
|
if key == "telemetry.enabled":
|
|
1032
1049
|
# Boolean opt-OUT (anonymous install-count telemetry, spec 2026-07-07).
|
|
1033
1050
|
# Default TRUE — absence is ON. A hand-edited junk value surfaces the
|
|
@@ -1623,6 +1640,34 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1623
1640
|
else:
|
|
1624
1641
|
print(f"dashboard.live_tail={'true' if canonical else 'false'}")
|
|
1625
1642
|
return 0
|
|
1643
|
+
if key == "dashboard.lan_auth":
|
|
1644
|
+
try:
|
|
1645
|
+
canonical = c._normalize_alerts_enabled_value(raw)
|
|
1646
|
+
except ValueError:
|
|
1647
|
+
print(
|
|
1648
|
+
f"cctally: invalid boolean value for dashboard.lan_auth: "
|
|
1649
|
+
f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
|
|
1650
|
+
file=sys.stderr,
|
|
1651
|
+
)
|
|
1652
|
+
return 2
|
|
1653
|
+
with config_writer_lock():
|
|
1654
|
+
config = _load_config_unlocked()
|
|
1655
|
+
existing = config.get("dashboard")
|
|
1656
|
+
if existing is not None and not isinstance(existing, dict):
|
|
1657
|
+
print(
|
|
1658
|
+
"cctally: dashboard config error: dashboard must be an object",
|
|
1659
|
+
file=sys.stderr,
|
|
1660
|
+
)
|
|
1661
|
+
return 2
|
|
1662
|
+
block = dict(existing or {})
|
|
1663
|
+
block["lan_auth"] = canonical
|
|
1664
|
+
config["dashboard"] = block
|
|
1665
|
+
save_config(config)
|
|
1666
|
+
if getattr(args, "emit_json", False):
|
|
1667
|
+
print(json.dumps({"dashboard": {"lan_auth": canonical}}, indent=2))
|
|
1668
|
+
else:
|
|
1669
|
+
print(f"dashboard.lan_auth={'true' if canonical else 'false'}")
|
|
1670
|
+
return 0
|
|
1626
1671
|
if key == "telemetry.enabled":
|
|
1627
1672
|
# Anonymous install-count telemetry opt-out (spec 2026-07-07). Mirror
|
|
1628
1673
|
# dashboard.live_tail exactly: validate the bool first, then
|
|
@@ -2237,6 +2282,18 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
2237
2282
|
save_config(config)
|
|
2238
2283
|
# idempotent: silent on missing key
|
|
2239
2284
|
return 0
|
|
2285
|
+
if key == "dashboard.lan_auth":
|
|
2286
|
+
# Restart-only opt-out. Removing the leaf restores the safe True
|
|
2287
|
+
# default while preserving all sibling dashboard preferences.
|
|
2288
|
+
with config_writer_lock():
|
|
2289
|
+
config = _load_config_unlocked()
|
|
2290
|
+
block = config.get("dashboard")
|
|
2291
|
+
if isinstance(block, dict) and "lan_auth" in block:
|
|
2292
|
+
del block["lan_auth"]
|
|
2293
|
+
if not block:
|
|
2294
|
+
config.pop("dashboard", None)
|
|
2295
|
+
save_config(config)
|
|
2296
|
+
return 0
|
|
2240
2297
|
if key == "telemetry.enabled":
|
|
2241
2298
|
# Mirror the dashboard.live_tail unset branch: drop only the enabled
|
|
2242
2299
|
# leaf; if the telemetry block ends up empty, drop the parent too.
|
package/bin/_cctally_core.py
CHANGED
|
@@ -364,9 +364,49 @@ STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
|
|
|
364
364
|
# full pass is measured against. Same mechanical reason as 1005 — an
|
|
365
365
|
# epoch-current open returns before any schema work — so it is a second bump
|
|
366
366
|
# rather than an amendment to the first.
|
|
367
|
-
|
|
367
|
+
# 1006 -> 1007 (#460): scheduled quota-alert ownership. Adds the per-root
|
|
368
|
+
# future-capture schedule that lets a matured boundary widen to its owning root
|
|
369
|
+
# instead of deferring forever on a quiet hook-only install.
|
|
370
|
+
# 1007 -> 1008 (#496 S3): in-place transactional publication. Adds
|
|
371
|
+
# `stats_publication_stamp`, the publication identity written inside the
|
|
372
|
+
# publication transaction. It replaces the marker's `scratchPath` crash
|
|
373
|
+
# discriminator, which an in-place publish inverts because it attaches the
|
|
374
|
+
# scratch read-only and leaves it on disk whether the transaction committed or
|
|
375
|
+
# rolled back. A stats schema change is an epoch bump and never a migration;
|
|
376
|
+
# the 13-migration registry stays frozen. Each install pays one rebuild on
|
|
377
|
+
# upgrade, deferred to the background worker by #453.
|
|
378
|
+
STATS_INDEX_EPOCH = 1008
|
|
368
379
|
LEGACY_STATS_HEAD = 13
|
|
369
380
|
|
|
381
|
+
#: #496 S1 F1. A NEW branch, for a state that cannot occur before the
|
|
382
|
+
#: publication transaction exists: a replacement index was published and then
|
|
383
|
+
#: failed validation on a fresh connection. The existing corrupt-stats text
|
|
384
|
+
#: says the database was "Not auto-recreated", which would be false here, so
|
|
385
|
+
#: this path gets its own wording. It does not alter the heal message or any
|
|
386
|
+
#: other corruption path.
|
|
387
|
+
STATS_PUBLICATION_FAILED_MSG = (
|
|
388
|
+
"stats.db published a rebuilt index that then FAILED validation, so the "
|
|
389
|
+
"live index is known bad and cctally refuses to use it. path: {path}. "
|
|
390
|
+
"The rebuild record naming the failing check is at {record}. The damaged "
|
|
391
|
+
"predecessor was preserved under quarantine/ with a forensics bundle in "
|
|
392
|
+
"logs/. Recovery: run `cctally db rebuild --db stats`."
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
#: #496 S3. The text above describes PHYSICAL replacement, which is now the
|
|
396
|
+
#: fallback. In-place publication drops the live generation and installs the
|
|
397
|
+
#: scratch's inside one transaction, so it preserves nothing and allocates no
|
|
398
|
+
#: quarantine directory — and the sentence about a preserved predecessor would
|
|
399
|
+
#: send a user whose index is already known bad to a directory that does not
|
|
400
|
+
#: exist. Selected by the mechanism the publication marker records.
|
|
401
|
+
STATS_PUBLICATION_FAILED_IN_PLACE_MSG = (
|
|
402
|
+
"stats.db published a rebuilt index that then FAILED validation, so the "
|
|
403
|
+
"live index is known bad and cctally refuses to use it. path: {path}. "
|
|
404
|
+
"The rebuild record naming the failing check is at {record}. This index "
|
|
405
|
+
"was published in place, so no copy of the previous index was kept; every "
|
|
406
|
+
"row it holds is derived from the append-only journal, which the "
|
|
407
|
+
"publication did not touch. Recovery: run `cctally db rebuild --db stats`."
|
|
408
|
+
)
|
|
409
|
+
|
|
370
410
|
|
|
371
411
|
# === Telemetry constants (non-path; see spec 2026-07-07) =============
|
|
372
412
|
#
|
|
@@ -705,18 +745,27 @@ def ensure_dirs() -> None:
|
|
|
705
745
|
# before returning does not, and must not — see `stats_open_guarded`):
|
|
706
746
|
# bin/_cctally_journal.py _acquire_maintenance_{shared,exclusive} / _release
|
|
707
747
|
# bin/_cctally_store.py _heal_flock_blocking, reached through
|
|
708
|
-
# _acquire_stats_maintenance_reentrant by the
|
|
709
|
-
#
|
|
748
|
+
# _acquire_stats_maintenance_reentrant by the epoch
|
|
749
|
+
# resolver
|
|
750
|
+
# bin/_cctally_store.py _acquire_stats_maintenance_for_heal, the corruption
|
|
751
|
+
# heal's ownership-first BOUNDED acquire (#496 S3)
|
|
710
752
|
# bin/_cctally_db.py cmd_db_rebuild, _acquire_db_admin_writer_flocks
|
|
711
753
|
# (db skip / db unskip), _cmd_db_repair_exclusive,
|
|
712
754
|
# _vacuum_one_db
|
|
713
755
|
# bin/_cctally_rederive.py _rederive_locks
|
|
756
|
+
# bin/_cctally_store.py stats_open_guarded's interrupted-rebuild-recovery
|
|
757
|
+
# branch, which upgrades to EXCLUSIVE and then calls
|
|
758
|
+
# rebuild_stats_index (#496 S3)
|
|
714
759
|
# Adding another acquisition site without noting it here reintroduces the hang.
|
|
715
760
|
#
|
|
716
|
-
# The opener (`_cctally_store.stats_open_guarded`) takes the lock SHARED
|
|
717
|
-
# releases it before handing the connection back, so
|
|
718
|
-
# note a hold — but it DOES consult
|
|
719
|
-
# acquire entirely when this context
|
|
761
|
+
# The opener (`_cctally_store.stats_open_guarded`) takes the lock SHARED around
|
|
762
|
+
# an ordinary open and releases it before handing the connection back, so that
|
|
763
|
+
# acquire deliberately does NOT note a hold — but it DOES consult
|
|
764
|
+
# `holds_stats_maintenance()` to skip the acquire entirely when this context
|
|
765
|
+
# already owns the exclusive side. Its interrupted-rebuild-recovery branch is
|
|
766
|
+
# the exception: that one upgrades to EXCLUSIVE and holds it across a rebuild,
|
|
767
|
+
# whose in-place publisher opens the destination through `stats_open_guarded`
|
|
768
|
+
# again, so it notes the hold like every other exclusive site.
|
|
720
769
|
|
|
721
770
|
_STATS_MAINTENANCE_HELD = contextvars.ContextVar(
|
|
722
771
|
"cctally_stats_maintenance_held", default=0
|
|
@@ -1369,6 +1418,9 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1369
1418
|
-- that are not functions of window dirtiness — a delivery-gate
|
|
1370
1419
|
-- transition, and a future-clocked observation that becomes eligible
|
|
1371
1420
|
-- when wall time passes it with no row mutation at all.
|
|
1421
|
+
-- `next_evaluation_by_root_json` owns that scalar minimum: one earliest
|
|
1422
|
+
-- future capture per root, so a due hook tick can reconcile only the
|
|
1423
|
+
-- roots whose instants matured.
|
|
1372
1424
|
--
|
|
1373
1425
|
-- `last_full_pass_at` is the periodic verification's deadline. Two
|
|
1374
1426
|
-- cases a scoped sweep structurally cannot see — a block whose physical
|
|
@@ -1386,6 +1438,7 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1386
1438
|
alerts_enabled INTEGER,
|
|
1387
1439
|
next_evaluation_at_utc TEXT,
|
|
1388
1440
|
last_full_pass_at TEXT,
|
|
1441
|
+
next_evaluation_by_root_json TEXT NOT NULL DEFAULT '{}',
|
|
1389
1442
|
PRIMARY KEY(source)
|
|
1390
1443
|
);
|
|
1391
1444
|
|
|
@@ -1417,14 +1470,19 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1417
1470
|
}
|
|
1418
1471
|
if "account_key" not in _proj_cols:
|
|
1419
1472
|
conn.execute("DROP TABLE quota_projection_state")
|
|
1473
|
+
# SQLite stores `sqlite_schema.sql` verbatim apart from stripping
|
|
1474
|
+
# `IF NOT EXISTS`, and `_stats_schema_fingerprint` hashes that text, so
|
|
1475
|
+
# this body must stay character-for-character equal to the fresh-path
|
|
1476
|
+
# definition above (#496 S1 F18).
|
|
1420
1477
|
conn.execute(
|
|
1421
|
-
"CREATE TABLE quota_projection_state ("
|
|
1422
|
-
"
|
|
1423
|
-
"
|
|
1424
|
-
"
|
|
1425
|
-
"
|
|
1426
|
-
"
|
|
1427
|
-
"
|
|
1478
|
+
"CREATE TABLE quota_projection_state (\n"
|
|
1479
|
+
" source_root_key TEXT NOT NULL,\n"
|
|
1480
|
+
" account_key TEXT NOT NULL DEFAULT 'unattributed',\n"
|
|
1481
|
+
" generation TEXT NOT NULL,\n"
|
|
1482
|
+
" physical_signature TEXT NOT NULL,\n"
|
|
1483
|
+
" completed_at_utc TEXT NOT NULL,\n"
|
|
1484
|
+
" PRIMARY KEY(source_root_key, account_key)\n"
|
|
1485
|
+
" )"
|
|
1428
1486
|
)
|
|
1429
1487
|
if add_column_if_missing is not None:
|
|
1430
1488
|
for _tbl in ("quota_window_blocks", "quota_percent_milestones",
|
|
@@ -1445,6 +1503,11 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1445
1503
|
# no-op over the table it already has.
|
|
1446
1504
|
add_column_if_missing(
|
|
1447
1505
|
conn, "quota_projection_ledger_state", "last_full_pass_at", "TEXT")
|
|
1506
|
+
# Epoch 1007 / #460: same legacy-cutover seam. Current-epoch indexes
|
|
1507
|
+
# rebuild; a legacy index cuts over in place and needs the column added.
|
|
1508
|
+
add_column_if_missing(
|
|
1509
|
+
conn, "quota_projection_ledger_state",
|
|
1510
|
+
"next_evaluation_by_root_json", "TEXT NOT NULL DEFAULT '{}'")
|
|
1448
1511
|
|
|
1449
1512
|
|
|
1450
1513
|
def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
@@ -2444,6 +2507,23 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
2444
2507
|
"kind TEXT NOT NULL, "
|
|
2445
2508
|
"violation_json TEXT NOT NULL)"
|
|
2446
2509
|
)
|
|
2510
|
+
# In-place publication identity (#496 S3 §5). An in-place publish
|
|
2511
|
+
# attaches the scratch read-only and detaches it, so the scratch
|
|
2512
|
+
# survives commit and rollback identically and the publication marker's
|
|
2513
|
+
# `scratchPath` proxy inverts. This row is written INSIDE the
|
|
2514
|
+
# publication transaction, so it commits atomically with the content
|
|
2515
|
+
# and the `user_version` it describes and a crash before the commit
|
|
2516
|
+
# rolls it back. The opener compares it against the marker's
|
|
2517
|
+
# `recordPath` and knows without inference whether that publication
|
|
2518
|
+
# committed. Holds at most one row; single-row-ness is deliberately not
|
|
2519
|
+
# enforced structurally, because a duplicated row is one of the states
|
|
2520
|
+
# that must resolve INDETERMINATE rather than be made impossible.
|
|
2521
|
+
conn.execute(
|
|
2522
|
+
"CREATE TABLE IF NOT EXISTS stats_publication_stamp ("
|
|
2523
|
+
"record_path TEXT NOT NULL, "
|
|
2524
|
+
"started_at_utc TEXT NOT NULL, "
|
|
2525
|
+
"stamped_at_utc TEXT NOT NULL)"
|
|
2526
|
+
)
|
|
2447
2527
|
|
|
2448
2528
|
# §6.2 backfill gate (Task 8): stamp the one-shot marker AFTER the three
|
|
2449
2529
|
# open-time backfills ran, so the next open skips them (and their probes)
|