Harden production workflows and agent admission
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -10,36 +12,73 @@ from ..timeutil import to_utc
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# PH-002/PH-011/PH-018: explicit sort + filter contracts.
|
||||
OutboxState = Literal["pending", "sending", "sent", "retry", "failed", "discarded"]
|
||||
OutboxEventType = Literal["firing", "resolved"]
|
||||
OutboxNotifier = Literal["debug", "telegram", "webhook", "alertmanager"]
|
||||
_SORT_COLUMNS = {
|
||||
"created_at": NotificationOutbox.created_at,
|
||||
"updated_at": NotificationOutbox.updated_at,
|
||||
}
|
||||
SortField = Literal["created_at", "updated_at"]
|
||||
|
||||
|
||||
def _encode_outbox_cursor(row: NotificationOutbox, sort: SortField) -> str:
|
||||
return encode(to_utc(getattr(row, sort)).isoformat(), row.id)
|
||||
|
||||
|
||||
@router.get("/notifiers/outbox", response_model=OutboxPage)
|
||||
async def list_outbox(
|
||||
state: str | None = Query(default=None),
|
||||
state: OutboxState | None = Query(default=None),
|
||||
notifier: OutboxNotifier | None = Query(default=None),
|
||||
event_type: OutboxEventType | None = Query(default=None),
|
||||
sort: SortField = Query(default="updated_at"),
|
||||
direction: Literal["asc", "desc"] = Query(default="desc"),
|
||||
cursor: str | None = Query(default=None, max_length=512),
|
||||
back: bool = Query(default=False),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OutboxPage:
|
||||
stmt = select(NotificationOutbox).order_by(
|
||||
NotificationOutbox.created_at.desc(), NotificationOutbox.id.desc()
|
||||
)
|
||||
col = _SORT_COLUMNS[sort]
|
||||
# PH-review: bidirectional cursor — for `back=True` we run the query in the
|
||||
# opposite direction relative to the requested sort and reverse the result
|
||||
# client-side. This avoids the unbounded cursor_stack in the URL.
|
||||
effective_desc = (direction == "desc") ^ back
|
||||
order_col = col.desc() if effective_desc else col.asc()
|
||||
tiebreak = NotificationOutbox.id.desc() if effective_desc else NotificationOutbox.id.asc()
|
||||
stmt = select(NotificationOutbox).order_by(order_col, tiebreak)
|
||||
if state is not None:
|
||||
stmt = stmt.where(NotificationOutbox.state == state)
|
||||
if notifier is not None:
|
||||
stmt = stmt.where(NotificationOutbox.notifier == notifier)
|
||||
if event_type is not None:
|
||||
stmt = stmt.where(NotificationOutbox.event_type == event_type)
|
||||
if cursor is not None:
|
||||
ts_str, last_id = decode(cursor, 2)
|
||||
ts = decode_datetime(ts_str)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
NotificationOutbox.created_at < ts,
|
||||
and_(
|
||||
NotificationOutbox.created_at == ts,
|
||||
NotificationOutbox.id < last_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
cmp = col < ts if effective_desc else col > ts
|
||||
tie = NotificationOutbox.id < last_id if effective_desc else NotificationOutbox.id > last_id
|
||||
stmt = stmt.where(or_(cmp, and_(col == ts, tie)))
|
||||
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
rows = rows[:limit]
|
||||
next_cursor = encode(to_utc(rows[-1].created_at).isoformat(), rows[-1].id)
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
if back:
|
||||
rows = list(reversed(rows))
|
||||
next_cursor: str | None = None
|
||||
prev_cursor: str | None = None
|
||||
if rows:
|
||||
if back:
|
||||
# Came from a forward page → there is always a next page (the one
|
||||
# we just came from). `has_more` here means there are still earlier
|
||||
# pages remaining.
|
||||
next_cursor = _encode_outbox_cursor(rows[-1], sort)
|
||||
if has_more:
|
||||
prev_cursor = _encode_outbox_cursor(rows[0], sort)
|
||||
else:
|
||||
if has_more:
|
||||
next_cursor = _encode_outbox_cursor(rows[-1], sort)
|
||||
if cursor is not None:
|
||||
prev_cursor = _encode_outbox_cursor(rows[0], sort)
|
||||
return OutboxPage(
|
||||
items=[
|
||||
NotificationOutboxItem(
|
||||
@@ -56,4 +95,5 @@ async def list_outbox(
|
||||
for r in rows
|
||||
],
|
||||
next_cursor=next_cursor,
|
||||
prev_cursor=prev_cursor,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user