feat: deferred dispatch, release-check provider, settings polish

- Defer quiet-hours dispatches into new deferred_dispatch table; drain
  job + periodic catch-up scan re-fire at window end with coalescing on
  (link, event_type, collection_id).
- Add ON DELETE SET NULL migration on event_log_id and partial unique
  index on (link_id, collection_id, event_type) WHERE status='pending'.
- Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe
  URL validation, settings UI cassette, and scheduled polling.
- Replace importlib-only version lookup with version.py helper that
  prefers the higher of installed metadata vs source pyproject so stale
  editable dev installs stop misreporting.
- Aurora frontend polish: MetaStrip component, ReleaseCassette,
  EventDetailModal expansion, and i18n additions.
This commit is contained in:
2026-05-12 02:58:07 +03:00
parent bb5afcc222
commit ba199f24bd
47 changed files with 5627 additions and 290 deletions
@@ -1369,6 +1369,12 @@ _INDEXES: list[tuple[str, str, str]] = [
("ix_command_template_slot_config_id", "command_template_slot", "config_id"),
("ix_action_rule_action_id", "action_rule", "action_id"),
("ix_action_execution_action_started", "action_execution", "action_id, started_at DESC"),
# Deferred-dispatch drain: WHERE status = 'pending' AND fire_at <= ?
# ORDER BY fire_at. The composite (status, fire_at) is the only access
# pattern; an individual fire_at index isn't needed.
("ix_deferred_dispatch_status_fire_at", "deferred_dispatch", "status, fire_at"),
("ix_deferred_dispatch_link_id", "deferred_dispatch", "link_id"),
("ix_deferred_dispatch_event_log_id", "deferred_dispatch", "event_log_id"),
]
@@ -1397,6 +1403,95 @@ async def migrate_performance_indexes(engine: AsyncEngine) -> None:
)
async def migrate_deferred_dispatch_event_log_fk(engine: AsyncEngine) -> None:
"""Rebuild ``deferred_dispatch`` if its event_log FK lacks ON DELETE SET NULL.
Early builds of this feature created the table with a default ``NO ACTION``
FK on ``event_log_id``. The daily event_log cleanup deletes rows past the
retention horizon — with SQLite's enforced foreign_keys PRAGMA, a pending
DeferredDispatch row pointing at an aging-out event_log row would block
the cleanup with an FK violation.
SQLite can't ALTER a constraint without rebuilding the table. The table
has zero rows in any prod install old enough to need this fix (the
feature shipped in the same release as this migration), so a drop +
recreate via ``create_all`` is safe.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "deferred_dispatch"):
return
# Read the original CREATE TABLE SQL to see whether SET NULL is wired.
row = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text(
"SELECT sql FROM sqlite_master "
"WHERE type='table' AND name='deferred_dispatch'"
)
).fetchone()
)
ddl = (row[0] or "") if row else ""
if "ON DELETE SET NULL" in ddl.upper():
return
# Confirm there's nothing to migrate — refuse to drop a populated
# table even though the schema was wrong. Better to leave a warning
# than to lose state.
count_row = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text("SELECT COUNT(*) FROM deferred_dispatch")
).fetchone()
)
if count_row and count_row[0]:
logger.warning(
"deferred_dispatch FK is missing ON DELETE SET NULL but the "
"table holds %d rows; not auto-dropping. Inspect manually.",
count_row[0],
)
return
await conn.execute(text("DROP TABLE deferred_dispatch"))
logger.info(
"Dropped deferred_dispatch (empty) so create_all rebuilds it "
"with ON DELETE SET NULL on event_log_id",
)
# Recreate the table from the SQLModel metadata in this same txn.
from sqlmodel import SQLModel
# Ensure the model is registered on metadata before we ask create_all
# to build it. Lazy import to avoid a circular at module load time.
from .models import DeferredDispatch # noqa: F401
await conn.run_sync(
SQLModel.metadata.create_all,
tables=[SQLModel.metadata.tables["deferred_dispatch"]],
)
async def migrate_deferred_dispatch_unique_pending(engine: AsyncEngine) -> None:
"""Add a partial unique index preventing duplicate pending defers.
Without this, two webhook handlers (or a webhook racing the watcher)
can both call ``_find_pending_asset_rows`` and find nothing, then both
INSERT — defeating coalescing. The partial index makes the second
INSERT raise ``IntegrityError`` and the caller's transaction abort,
after which a retry will see the now-visible row.
SQLite has supported ``CREATE UNIQUE INDEX ... WHERE ...`` since 3.8.
Once the table exists this is safe to run on every boot.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "deferred_dispatch"):
return
try:
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS "
"ux_deferred_dispatch_pending "
"ON deferred_dispatch(link_id, collection_id, event_type) "
"WHERE status = 'pending'"
))
except Exception: # pragma: no cover — log and continue
logger.warning(
"Failed to create partial unique index on deferred_dispatch",
exc_info=True,
)
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.