cctally 1.28.0 → 1.30.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 +30 -0
- package/bin/_cctally_cache.py +147 -59
- package/bin/_cctally_core.py +22 -49
- package/bin/_cctally_dashboard.py +239 -152
- package/bin/_cctally_db.py +211 -31
- package/bin/_cctally_milestones.py +126 -166
- package/bin/_cctally_record.py +161 -192
- package/bin/_lib_alert_axes.py +7 -4
- package/bin/_lib_conversation.py +59 -8
- package/bin/_lib_conversation_query.py +306 -52
- package/bin/_lib_jsonl.py +69 -50
- package/bin/cctally +5 -5
- package/dashboard/static/assets/index-4OxMhN7N.js +53 -0
- package/dashboard/static/assets/index-DEDO-eqP.css +1 -0
- package/dashboard/static/assets/newsreader-latin-400-italic-CEihAR-f.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-400-italic-CNZoH1hn.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-400-normal-BFBkh4jY.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-400-normal-gRTjlS2D.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-500-normal-B66TYsaK.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-500-normal-DFwuUcdu.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-600-normal-30OJ_TG_.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-600-normal-DUnT2r2g.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-400-italic-BMTE_bNQ.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-400-italic-qdgKLcPG.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-400-normal-DYA1XoQK.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-400-normal-svq1FPys.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-500-normal-BNHmvKvI.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-500-normal-CZruMFou.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-600-normal-BXv5iMHi.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-600-normal-BrbfzHZ5.woff +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-400-italic-QbB8kb5s.woff +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-400-italic-bZegYFuM.woff2 +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-400-normal-BekUZro8.woff +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-400-normal-DdKr49mV.woff2 +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-500-normal-BEAbKU8A.woff +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-500-normal-CL6a8tp2.woff2 +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-600-normal-CVAR0otO.woff +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-600-normal-CaH84vfx.woff2 +0 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-Bj5ckRUE.css +0 -1
- package/dashboard/static/assets/index-Dw4G5FD9.js +0 -18
package/bin/_cctally_db.py
CHANGED
|
@@ -569,7 +569,14 @@ def _recover_version_ahead(
|
|
|
569
569
|
("schema_migrations_skipped", skipped)):
|
|
570
570
|
try:
|
|
571
571
|
for row in conn.execute(f"SELECT name FROM {table}").fetchall():
|
|
572
|
-
|
|
572
|
+
# Normalize legacy unprefixed markers to their canonical NNN_
|
|
573
|
+
# name (issue #148). The alias union above keeps such a row from
|
|
574
|
+
# being trimmed; without this normalization the membership test
|
|
575
|
+
# below compares canonical m.name against the legacy alias and
|
|
576
|
+
# falsely concludes the migration is missing, resetting
|
|
577
|
+
# user_version to 0 and forcing a needless full re-walk. Mirrors
|
|
578
|
+
# the alias-aware read in cmd_db_status.
|
|
579
|
+
dest.add(aliases.get(row[0], row[0]))
|
|
573
580
|
except sqlite3.OperationalError:
|
|
574
581
|
pass
|
|
575
582
|
|
|
@@ -2401,18 +2408,11 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2401
2408
|
conn.execute(
|
|
2402
2409
|
"CREATE VIRTUAL TABLE IF NOT EXISTS conversation_fts "
|
|
2403
2410
|
"USING fts5(text, content='conversation_messages', content_rowid='id')")
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
"BEGIN INSERT INTO conversation_fts(conversation_fts, rowid, text) "
|
|
2410
|
-
"VALUES('delete', old.id, old.text); END")
|
|
2411
|
-
conn.execute(
|
|
2412
|
-
"CREATE TRIGGER IF NOT EXISTS conv_fts_au AFTER UPDATE OF text ON conversation_messages "
|
|
2413
|
-
"BEGIN INSERT INTO conversation_fts(conversation_fts, rowid, text) "
|
|
2414
|
-
"VALUES('delete', old.id, old.text); "
|
|
2415
|
-
"INSERT INTO conversation_fts(rowid, text) VALUES (new.id, new.text); END")
|
|
2411
|
+
# Trigger DDL lives in ONE place (_CONV_FTS_TRIGGER_DDL) so this
|
|
2412
|
+
# initial create and the #138 storm-free full-clear
|
|
2413
|
+
# (clear_conversation_messages, which drops + recreates the
|
|
2414
|
+
# triggers) can never drift.
|
|
2415
|
+
_create_conversation_fts_triggers(conn)
|
|
2416
2416
|
if recovering:
|
|
2417
2417
|
# Repopulate the freshly-(re)created index from the base table
|
|
2418
2418
|
# so pre-recovery history is searchable. Cheap no-op when
|
|
@@ -2422,14 +2422,11 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2422
2422
|
conn.execute("DELETE FROM cache_meta WHERE key='fts5_unavailable'")
|
|
2423
2423
|
except sqlite3.OperationalError:
|
|
2424
2424
|
# partial create cleanup, then mark unavailable
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
conn.execute(stmt)
|
|
2431
|
-
except sqlite3.OperationalError:
|
|
2432
|
-
pass
|
|
2425
|
+
_drop_conversation_fts_triggers(conn)
|
|
2426
|
+
try:
|
|
2427
|
+
conn.execute("DROP TABLE IF EXISTS conversation_fts")
|
|
2428
|
+
except sqlite3.OperationalError:
|
|
2429
|
+
pass
|
|
2433
2430
|
_set_cache_meta(conn, "fts5_unavailable", "1")
|
|
2434
2431
|
else:
|
|
2435
2432
|
# FTS5 is unavailable on THIS sqlite build. If a prior (FTS-capable)
|
|
@@ -2441,13 +2438,7 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2441
2438
|
# writes succeed under the LIKE fallback. (The conversation_fts vtable
|
|
2442
2439
|
# itself can't be DROPped without the fts5 module, but with no triggers
|
|
2443
2440
|
# nothing writes to it.)
|
|
2444
|
-
|
|
2445
|
-
"DROP TRIGGER IF EXISTS conv_fts_ad",
|
|
2446
|
-
"DROP TRIGGER IF EXISTS conv_fts_ai"):
|
|
2447
|
-
try:
|
|
2448
|
-
conn.execute(stmt)
|
|
2449
|
-
except sqlite3.OperationalError:
|
|
2450
|
-
pass
|
|
2441
|
+
_drop_conversation_fts_triggers(conn)
|
|
2451
2442
|
_set_cache_meta(conn, "fts5_unavailable", "1")
|
|
2452
2443
|
# The FTS branch above issues DML (DELETE/INSERT on cache_meta) which opens
|
|
2453
2444
|
# an implicit transaction under sqlite3's legacy autocommit mode. Close it
|
|
@@ -2475,6 +2466,93 @@ def _set_cache_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
|
|
|
2475
2466
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
|
|
2476
2467
|
|
|
2477
2468
|
|
|
2469
|
+
# Conversation FTS sync triggers (external-content FTS5). Defined ONCE here so
|
|
2470
|
+
# the initial create in _apply_cache_schema and the #138 storm-free full-clear
|
|
2471
|
+
# in clear_conversation_messages (which drops + recreates them) can never drift.
|
|
2472
|
+
# conv_fts_ad / conv_fts_au use the external-content `'delete'` idiom.
|
|
2473
|
+
_CONV_FTS_TRIGGER_DDL = (
|
|
2474
|
+
"CREATE TRIGGER IF NOT EXISTS conv_fts_ai AFTER INSERT ON conversation_messages "
|
|
2475
|
+
"BEGIN INSERT INTO conversation_fts(rowid, text) VALUES (new.id, new.text); END",
|
|
2476
|
+
"CREATE TRIGGER IF NOT EXISTS conv_fts_ad AFTER DELETE ON conversation_messages "
|
|
2477
|
+
"BEGIN INSERT INTO conversation_fts(conversation_fts, rowid, text) "
|
|
2478
|
+
"VALUES('delete', old.id, old.text); END",
|
|
2479
|
+
"CREATE TRIGGER IF NOT EXISTS conv_fts_au AFTER UPDATE OF text ON conversation_messages "
|
|
2480
|
+
"BEGIN INSERT INTO conversation_fts(conversation_fts, rowid, text) "
|
|
2481
|
+
"VALUES('delete', old.id, old.text); "
|
|
2482
|
+
"INSERT INTO conversation_fts(rowid, text) VALUES (new.id, new.text); END",
|
|
2483
|
+
)
|
|
2484
|
+
# Drop by name (the body is irrelevant to DROP TRIGGER); reverse order is
|
|
2485
|
+
# cosmetic — order doesn't matter for independent triggers.
|
|
2486
|
+
_CONV_FTS_TRIGGER_NAMES = ("conv_fts_au", "conv_fts_ad", "conv_fts_ai")
|
|
2487
|
+
|
|
2488
|
+
|
|
2489
|
+
def _create_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
|
|
2490
|
+
"""Create the three external-content FTS5 sync triggers (idempotent —
|
|
2491
|
+
each is ``IF NOT EXISTS``). Single source of truth for the trigger DDL,
|
|
2492
|
+
shared by ``_apply_cache_schema`` and ``clear_conversation_messages``
|
|
2493
|
+
(#138). The caller must have already created ``conversation_fts``."""
|
|
2494
|
+
for stmt in _CONV_FTS_TRIGGER_DDL:
|
|
2495
|
+
conn.execute(stmt)
|
|
2496
|
+
|
|
2497
|
+
|
|
2498
|
+
def _drop_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
|
|
2499
|
+
"""Drop the three FTS5 sync triggers (idempotent — ``IF EXISTS``). Swallows
|
|
2500
|
+
``OperationalError`` per statement so a partial/absent trigger set (e.g. an
|
|
2501
|
+
FTS-unavailable build) is tolerated."""
|
|
2502
|
+
for name in _CONV_FTS_TRIGGER_NAMES:
|
|
2503
|
+
try:
|
|
2504
|
+
conn.execute(f"DROP TRIGGER IF EXISTS {name}")
|
|
2505
|
+
except sqlite3.OperationalError:
|
|
2506
|
+
pass
|
|
2507
|
+
|
|
2508
|
+
|
|
2509
|
+
def clear_conversation_messages(conn: sqlite3.Connection) -> None:
|
|
2510
|
+
"""Full-clear ``conversation_messages`` + its FTS index WITHOUT firing the
|
|
2511
|
+
per-row delete trigger O(rows) (#138).
|
|
2512
|
+
|
|
2513
|
+
A bulk ``DELETE FROM conversation_messages`` fires ``conv_fts_ad`` once per
|
|
2514
|
+
row — each an FTS5 ``'delete'`` shadow-write — AND forfeits SQLite's
|
|
2515
|
+
no-trigger truncate fast-path, stalling the held ``cache.db.lock`` far
|
|
2516
|
+
longer than the ``session_entries`` clear alone. We suppress the triggers:
|
|
2517
|
+
|
|
2518
|
+
drop all 3 conv_fts triggers
|
|
2519
|
+
→ DELETE FROM conversation_messages (true truncate fast-path now)
|
|
2520
|
+
→ INSERT INTO conversation_fts(conversation_fts) VALUES('delete-all')
|
|
2521
|
+
(resets the external-content index)
|
|
2522
|
+
→ recreate all 3 triggers
|
|
2523
|
+
|
|
2524
|
+
Ordering is load-bearing: clearing the FTS index while the per-row delete
|
|
2525
|
+
trigger is still live makes the base ``DELETE`` write ``'delete'`` postings
|
|
2526
|
+
against already-gone rows and CORRUPTS the index (``database disk image is
|
|
2527
|
+
malformed``; verified on SQLite 3.53.1). Dropping the triggers first makes
|
|
2528
|
+
the base ``DELETE`` not touch the index at all; the explicit ``'delete-all'``
|
|
2529
|
+
then resets it cleanly and ``integrity-check`` still passes.
|
|
2530
|
+
|
|
2531
|
+
Runs inside the caller's open transaction (the held ``cache.db.lock``); the
|
|
2532
|
+
caller owns the commit. When FTS5 is unavailable
|
|
2533
|
+
(``cache_meta.fts5_unavailable`` set → no triggers, no usable vtable),
|
|
2534
|
+
falls back to a plain base ``DELETE`` — there are no triggers to storm and a
|
|
2535
|
+
``'delete-all'`` would error on the absent vtable."""
|
|
2536
|
+
try:
|
|
2537
|
+
fts_unavailable = conn.execute(
|
|
2538
|
+
"SELECT 1 FROM cache_meta WHERE key='fts5_unavailable'"
|
|
2539
|
+
).fetchone() is not None
|
|
2540
|
+
except sqlite3.OperationalError:
|
|
2541
|
+
# No cache_meta yet — only possible before the schema is applied, in
|
|
2542
|
+
# which case there is no FTS vtable/triggers either. Bias to the plain
|
|
2543
|
+
# DELETE: it can't storm what doesn't exist and won't touch a vtable.
|
|
2544
|
+
fts_unavailable = True
|
|
2545
|
+
|
|
2546
|
+
if fts_unavailable:
|
|
2547
|
+
conn.execute("DELETE FROM conversation_messages")
|
|
2548
|
+
return
|
|
2549
|
+
|
|
2550
|
+
_drop_conversation_fts_triggers(conn)
|
|
2551
|
+
conn.execute("DELETE FROM conversation_messages")
|
|
2552
|
+
conn.execute("INSERT INTO conversation_fts(conversation_fts) VALUES('delete-all')")
|
|
2553
|
+
_create_conversation_fts_triggers(conn)
|
|
2554
|
+
|
|
2555
|
+
|
|
2478
2556
|
def _eagerly_apply_cache_migrations() -> None:
|
|
2479
2557
|
"""Open cache.db so its pending migrations (notably
|
|
2480
2558
|
``001_dedup_highest_wins``) apply BEFORE stats migration 008's gate
|
|
@@ -2913,6 +2991,24 @@ def _002_conversation_messages_backfill(conn: sqlite3.Connection) -> None:
|
|
|
2913
2991
|
conn.commit()
|
|
2914
2992
|
|
|
2915
2993
|
|
|
2994
|
+
@cache_migration("003_conversation_reingest_tool_ids")
|
|
2995
|
+
def _003_conversation_reingest_tool_ids(conn: sqlite3.Connection) -> None:
|
|
2996
|
+
"""Flag-only re-ingest of conversation_messages so tool_use.id /
|
|
2997
|
+
tool_result.tool_use_id / preview land on existing history (#164).
|
|
2998
|
+
|
|
2999
|
+
The destructive clear + offset-0 backfill run in sync_cache UNDER the
|
|
3000
|
+
cache.db.lock flock — NOT here. Clearing in the handler would violate the
|
|
3001
|
+
lock discipline cache-001 follows and would empty the reader on
|
|
3002
|
+
stats-only / eager-migration opens or ``dashboard --no-sync``. A distinct
|
|
3003
|
+
flag from 002's conversation_backfill_pending: 002 = backfill-without-clear;
|
|
3004
|
+
003 = clear-then-backfill. The dispatcher stamps this migration centrally
|
|
3005
|
+
on the existing-install path (issue #140); a fresh install stamps it
|
|
3006
|
+
without running (empty table), and the flag — if ever set — is a harmless
|
|
3007
|
+
no-op there."""
|
|
3008
|
+
_set_cache_meta(conn, "conversation_reingest_pending", "1")
|
|
3009
|
+
conn.commit()
|
|
3010
|
+
|
|
3011
|
+
|
|
2916
3012
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
2917
3013
|
|
|
2918
3014
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
|
@@ -3775,9 +3871,16 @@ def _migration_budget_milestone_period_keys(conn: sqlite3.Connection) -> None:
|
|
|
3775
3871
|
}
|
|
3776
3872
|
return "period" in cols
|
|
3777
3873
|
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3874
|
+
def _table_exists(table: str) -> bool:
|
|
3875
|
+
return conn.execute(
|
|
3876
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
|
3877
|
+
).fetchone() is not None
|
|
3878
|
+
|
|
3879
|
+
# Compute needs-rebuild BEFORE any transaction (no deferred-BEGIN-then-read on
|
|
3880
|
+
# stats.db — SQLITE_BUSY_SNAPSHOT, migrations-gotchas.md). A spec table that
|
|
3881
|
+
# does not exist (e.g. codex_budget_milestones on a DB predating that feature,
|
|
3882
|
+
# now that v012 no longer live-creates it — #143) needs no period column.
|
|
3883
|
+
pending = [s for s in specs if _table_exists(s[0]) and not _has_period(s[0])]
|
|
3781
3884
|
|
|
3782
3885
|
if not pending:
|
|
3783
3886
|
# Fresh install (live CREATE already made the new shape) or prior run.
|
|
@@ -3800,6 +3903,83 @@ def _migration_budget_milestone_period_keys(conn: sqlite3.Connection) -> None:
|
|
|
3800
3903
|
raise
|
|
3801
3904
|
|
|
3802
3905
|
|
|
3906
|
+
@stats_migration("012_unify_budget_milestones_vendor")
|
|
3907
|
+
def _migration_unify_budget_milestones_vendor(conn: sqlite3.Connection) -> None:
|
|
3908
|
+
"""Merge ``codex_budget_milestones`` into a vendor-tagged ``budget_milestones``
|
|
3909
|
+
(issue #143).
|
|
3910
|
+
|
|
3911
|
+
``budget_milestones`` (Claude, keyed ``week_start_at``) and
|
|
3912
|
+
``codex_budget_milestones`` (Codex, keyed ``period_start_at``) are
|
|
3913
|
+
structurally identical modulo vendor + key-column name. This migration
|
|
3914
|
+
rebuilds ``budget_milestones`` with a ``vendor`` column and the renamed
|
|
3915
|
+
``period_start_at`` key, copies Claude rows (``week_start_at``->``period_start_at``,
|
|
3916
|
+
``vendor='claude'``) and Codex rows (``vendor='codex'``), and drops the Codex
|
|
3917
|
+
table. History + ``alerted_at`` + ``period`` are preserved verbatim; the
|
|
3918
|
+
write-once ``period`` NULL sentinel is carried as-is. ``id`` is NOT copied
|
|
3919
|
+
(AUTOINCREMENT reassigns — the envelope/dispatch ids are composite strings,
|
|
3920
|
+
never the row PK).
|
|
3921
|
+
|
|
3922
|
+
State machine (idempotent / partial-state safe): the Claude rebuild and the
|
|
3923
|
+
Codex absorb are independently guarded, so a retry after a crash-before-stamp
|
|
3924
|
+
(table already unified, Codex maybe gone) is a clean no-op or a Codex-only
|
|
3925
|
+
absorb. Reads happen BEFORE BEGIN IMMEDIATE (SQLITE_BUSY_SNAPSHOT).
|
|
3926
|
+
"""
|
|
3927
|
+
def _cols(table: str) -> set:
|
|
3928
|
+
return {str(r[1]) for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
|
3929
|
+
|
|
3930
|
+
def _table_exists(table: str) -> bool:
|
|
3931
|
+
return conn.execute(
|
|
3932
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
|
3933
|
+
).fetchone() is not None
|
|
3934
|
+
|
|
3935
|
+
claude_needs_rebuild = "vendor" not in _cols("budget_milestones")
|
|
3936
|
+
codex_present = _table_exists("codex_budget_milestones")
|
|
3937
|
+
if not claude_needs_rebuild and not codex_present:
|
|
3938
|
+
return # already unified, no Codex leftover -> dispatcher fast-stamps
|
|
3939
|
+
|
|
3940
|
+
new_table = """
|
|
3941
|
+
CREATE TABLE budget_milestones (
|
|
3942
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3943
|
+
vendor TEXT NOT NULL,
|
|
3944
|
+
period_start_at TEXT NOT NULL,
|
|
3945
|
+
period TEXT,
|
|
3946
|
+
threshold INTEGER NOT NULL,
|
|
3947
|
+
budget_usd REAL NOT NULL,
|
|
3948
|
+
spent_usd REAL NOT NULL,
|
|
3949
|
+
consumption_pct REAL NOT NULL,
|
|
3950
|
+
crossed_at_utc TEXT NOT NULL,
|
|
3951
|
+
alerted_at TEXT,
|
|
3952
|
+
UNIQUE(vendor, period_start_at, period, threshold)
|
|
3953
|
+
)
|
|
3954
|
+
"""
|
|
3955
|
+
cols = ("vendor, period_start_at, period, threshold, budget_usd, spent_usd, "
|
|
3956
|
+
"consumption_pct, crossed_at_utc, alerted_at")
|
|
3957
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
3958
|
+
try:
|
|
3959
|
+
if claude_needs_rebuild:
|
|
3960
|
+
conn.execute("ALTER TABLE budget_milestones RENAME TO budget_milestones_old_012")
|
|
3961
|
+
conn.execute(new_table)
|
|
3962
|
+
conn.execute(
|
|
3963
|
+
f"INSERT INTO budget_milestones ({cols}) "
|
|
3964
|
+
"SELECT 'claude', week_start_at, period, threshold, budget_usd, "
|
|
3965
|
+
"spent_usd, consumption_pct, crossed_at_utc, alerted_at "
|
|
3966
|
+
"FROM budget_milestones_old_012"
|
|
3967
|
+
)
|
|
3968
|
+
conn.execute("DROP TABLE budget_milestones_old_012")
|
|
3969
|
+
if codex_present:
|
|
3970
|
+
conn.execute(
|
|
3971
|
+
f"INSERT INTO budget_milestones ({cols}) "
|
|
3972
|
+
"SELECT 'codex', period_start_at, period, threshold, budget_usd, "
|
|
3973
|
+
"spent_usd, consumption_pct, crossed_at_utc, alerted_at "
|
|
3974
|
+
"FROM codex_budget_milestones"
|
|
3975
|
+
)
|
|
3976
|
+
conn.execute("DROP TABLE codex_budget_milestones")
|
|
3977
|
+
conn.commit()
|
|
3978
|
+
except Exception:
|
|
3979
|
+
conn.rollback()
|
|
3980
|
+
raise
|
|
3981
|
+
|
|
3982
|
+
|
|
3803
3983
|
# === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
|
|
3804
3984
|
|
|
3805
3985
|
# ──────────────────────────────────────────────────────────────────────
|