mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
Merge 8a6b08dfa2 into 20e7fc0164
This commit is contained in:
commit
78f4418159
11 changed files with 1405 additions and 2 deletions
3
app.py
3
app.py
|
|
@ -763,6 +763,9 @@ set_task_scheduler(task_scheduler)
|
|||
from routes.task_routes import setup_task_routes
|
||||
app.include_router(setup_task_routes(task_scheduler))
|
||||
|
||||
from routes.notification_routes import setup_notification_routes
|
||||
app.include_router(setup_notification_routes())
|
||||
|
||||
from routes.assistant_routes import setup_assistant_routes
|
||||
app.include_router(setup_assistant_routes(task_scheduler))
|
||||
|
||||
|
|
|
|||
|
|
@ -743,6 +743,65 @@ class TaskRun(Base):
|
|||
)
|
||||
|
||||
|
||||
class NotificationEvent(Base):
|
||||
"""Durable notification/audit event.
|
||||
|
||||
System events are logged here even when they do not deserve a visible UI
|
||||
entry. Events that should appear in the notification inbox are paired with
|
||||
one or more NotificationInboxItem rows.
|
||||
"""
|
||||
__tablename__ = "notification_events"
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
owner = Column(String, nullable=True, index=True)
|
||||
event_class = Column(String, nullable=False, default="system_event", index=True)
|
||||
title = Column(String, nullable=False, default="")
|
||||
body = Column(Text, nullable=True)
|
||||
source_type = Column(String, nullable=True, index=True)
|
||||
source_id = Column(String, nullable=True, index=True)
|
||||
source_url = Column(Text, nullable=True)
|
||||
severity = Column(String, nullable=False, default="info", index=True)
|
||||
category = Column(String, nullable=True, index=True)
|
||||
dedupe_key = Column(String, nullable=True, index=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
retention_expires_at = Column(DateTime, nullable=True, index=True)
|
||||
created_at = Column(DateTime, nullable=False, default=utcnow_naive, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_notification_events_owner_created', 'owner', 'created_at'),
|
||||
Index('ix_notification_events_owner_class_created', 'owner', 'event_class', 'created_at'),
|
||||
Index('ix_notification_events_owner_dedupe', 'owner', 'dedupe_key'),
|
||||
)
|
||||
|
||||
|
||||
class NotificationInboxItem(Base):
|
||||
"""Visible/dismissible notification inbox entry derived from an event."""
|
||||
__tablename__ = "notification_inbox_items"
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
owner = Column(String, nullable=True, index=True)
|
||||
event_id = Column(String, ForeignKey("notification_events.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
notification_kind = Column(String, nullable=False, default="inbox_record", index=True)
|
||||
primary_action = Column(String, nullable=True)
|
||||
action_url = Column(Text, nullable=True)
|
||||
is_read = Column(Boolean, default=False, nullable=False, index=True)
|
||||
read_at = Column(DateTime, nullable=True)
|
||||
dismissed_at = Column(DateTime, nullable=True, index=True)
|
||||
archived_at = Column(DateTime, nullable=True, index=True)
|
||||
created_at = Column(DateTime, nullable=False, default=utcnow_naive, index=True)
|
||||
|
||||
event = relationship(
|
||||
"NotificationEvent",
|
||||
backref=backref("inbox_items", cascade="all, delete-orphan"),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_notification_inbox_owner_created', 'owner', 'created_at'),
|
||||
Index('ix_notification_inbox_owner_unread', 'owner', 'is_read', 'created_at'),
|
||||
Index('ix_notification_inbox_owner_visible', 'owner', 'archived_at', 'dismissed_at', 'created_at'),
|
||||
)
|
||||
|
||||
|
||||
class Memory(Base):
|
||||
"""
|
||||
SQLAlchemy model for Memory table.
|
||||
|
|
|
|||
87
routes/notification_routes.py
Normal file
87
routes/notification_routes.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Routes for durable notification events and inbox items."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.auth_helpers import require_user
|
||||
from src.notifications import (
|
||||
archive_notification,
|
||||
count_unread_notifications,
|
||||
dismiss_notification,
|
||||
list_inbox_notifications,
|
||||
list_notification_events,
|
||||
mark_notification_read,
|
||||
)
|
||||
|
||||
|
||||
class NotificationReadRequest(BaseModel):
|
||||
read: bool = True
|
||||
|
||||
|
||||
def setup_notification_routes() -> APIRouter:
|
||||
router = APIRouter(prefix="/api/notifications", tags=["notifications"])
|
||||
|
||||
@router.get("")
|
||||
async def list_notifications(
|
||||
request: Request,
|
||||
limit: int = 50,
|
||||
include_archived: bool = False,
|
||||
include_dismissed: bool = False,
|
||||
):
|
||||
owner = require_user(request)
|
||||
return {
|
||||
"notifications": list_inbox_notifications(
|
||||
owner=owner,
|
||||
limit=limit,
|
||||
include_archived=include_archived,
|
||||
include_dismissed=include_dismissed,
|
||||
)
|
||||
}
|
||||
|
||||
@router.get("/count")
|
||||
async def notification_count(request: Request):
|
||||
owner = require_user(request)
|
||||
return {"unread": count_unread_notifications(owner=owner)}
|
||||
|
||||
@router.get("/events")
|
||||
async def notification_events(
|
||||
request: Request,
|
||||
limit: int = 100,
|
||||
event_class: Optional[str] = None,
|
||||
):
|
||||
owner = require_user(request)
|
||||
return {
|
||||
"events": list_notification_events(
|
||||
owner=owner,
|
||||
limit=limit,
|
||||
event_class=event_class,
|
||||
)
|
||||
}
|
||||
|
||||
@router.post("/{item_id}/read")
|
||||
async def read_notification(request: Request, item_id: str, body: NotificationReadRequest):
|
||||
owner = require_user(request)
|
||||
item = mark_notification_read(item_id=item_id, owner=owner, read=body.read)
|
||||
if not item:
|
||||
raise HTTPException(404, "Notification not found")
|
||||
return item
|
||||
|
||||
@router.post("/{item_id}/dismiss")
|
||||
async def dismiss_inbox_notification(request: Request, item_id: str):
|
||||
owner = require_user(request)
|
||||
item = dismiss_notification(item_id=item_id, owner=owner)
|
||||
if not item:
|
||||
raise HTTPException(404, "Notification not found")
|
||||
return item
|
||||
|
||||
@router.post("/{item_id}/archive")
|
||||
async def archive_inbox_notification(request: Request, item_id: str):
|
||||
owner = require_user(request)
|
||||
item = archive_notification(item_id=item_id, owner=owner)
|
||||
if not item:
|
||||
raise HTTPException(404, "Notification not found")
|
||||
return item
|
||||
|
||||
return router
|
||||
|
|
@ -23,6 +23,8 @@ from core.database import ( # explicit re-exports for IDE/type-checker visibili
|
|||
CrewMember,
|
||||
ScheduledTask,
|
||||
TaskRun,
|
||||
NotificationEvent,
|
||||
NotificationInboxItem,
|
||||
Memory,
|
||||
init_db,
|
||||
get_db,
|
||||
|
|
|
|||
438
src/notifications.py
Normal file
438
src/notifications.py
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
"""Durable notification event and inbox helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session as OrmSession
|
||||
|
||||
from core.database import (
|
||||
NotificationEvent,
|
||||
NotificationInboxItem,
|
||||
SessionLocal,
|
||||
utcnow_naive,
|
||||
)
|
||||
|
||||
SYSTEM_EVENT = "system_event"
|
||||
INBOX_RECORD = "inbox_record"
|
||||
ACTIONABLE = "actionable"
|
||||
|
||||
EVENT_CLASSES = {SYSTEM_EVENT, INBOX_RECORD, ACTIONABLE}
|
||||
INBOX_CLASSES = {INBOX_RECORD, ACTIONABLE}
|
||||
SEVERITIES = {"info", "attention", "urgent", "error"}
|
||||
|
||||
|
||||
def _normalize_owner(owner: str | None) -> str | None:
|
||||
owner = (owner or "").strip()
|
||||
return owner or None
|
||||
|
||||
|
||||
def _clamp_text(value: str | None, limit: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
return text if len(text) <= limit else text[: limit - 1] + "..."
|
||||
|
||||
|
||||
def _safe_limit(limit: int | None, default: int = 50, maximum: int = 200) -> int:
|
||||
try:
|
||||
n = int(limit or default)
|
||||
except (TypeError, ValueError):
|
||||
n = default
|
||||
return max(1, min(maximum, n))
|
||||
|
||||
|
||||
def _iso(dt: datetime | None) -> str | None:
|
||||
if not dt:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _owner_query(query, model, owner: str | None):
|
||||
owner = _normalize_owner(owner)
|
||||
if owner is None:
|
||||
return query.filter(model.owner == None) # noqa: E711
|
||||
return query.filter(model.owner == owner)
|
||||
|
||||
|
||||
def _validate_event_class(event_class: str) -> str:
|
||||
value = (event_class or SYSTEM_EVENT).strip()
|
||||
if value not in EVENT_CLASSES:
|
||||
raise ValueError(f"Unknown notification event_class: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_severity(severity: str) -> str:
|
||||
value = (severity or "info").strip()
|
||||
if value not in SEVERITIES:
|
||||
raise ValueError(f"Unknown notification severity: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_event(event: NotificationEvent) -> dict[str, Any]:
|
||||
return {
|
||||
"id": event.id,
|
||||
"owner": event.owner,
|
||||
"event_class": event.event_class,
|
||||
"title": event.title,
|
||||
"body": event.body,
|
||||
"source_type": event.source_type,
|
||||
"source_id": event.source_id,
|
||||
"source_url": event.source_url,
|
||||
"severity": event.severity,
|
||||
"category": event.category,
|
||||
"dedupe_key": event.dedupe_key,
|
||||
"metadata": event.metadata_json or {},
|
||||
"retention_expires_at": _iso(event.retention_expires_at),
|
||||
"created_at": _iso(event.created_at),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_item(item: NotificationInboxItem) -> dict[str, Any]:
|
||||
event = item.event
|
||||
return {
|
||||
"id": item.id,
|
||||
"owner": item.owner,
|
||||
"event_id": item.event_id,
|
||||
"notification_kind": item.notification_kind,
|
||||
"primary_action": item.primary_action,
|
||||
"action_url": item.action_url,
|
||||
"is_read": bool(item.is_read),
|
||||
"read_at": _iso(item.read_at),
|
||||
"dismissed_at": _iso(item.dismissed_at),
|
||||
"archived_at": _iso(item.archived_at),
|
||||
"created_at": _iso(item.created_at),
|
||||
"event": _serialize_event(event) if event else None,
|
||||
"event_class": event.event_class if event else None,
|
||||
"title": event.title if event else "",
|
||||
"body": event.body if event else None,
|
||||
"source_type": event.source_type if event else None,
|
||||
"source_id": event.source_id if event else None,
|
||||
"source_url": event.source_url if event else None,
|
||||
"severity": event.severity if event else "info",
|
||||
"category": event.category if event else None,
|
||||
"metadata": event.metadata_json if event and event.metadata_json else {},
|
||||
}
|
||||
|
||||
|
||||
def _upsert_event(
|
||||
db: OrmSession,
|
||||
*,
|
||||
owner: str | None,
|
||||
event_class: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
source_type: str | None = None,
|
||||
source_id: str | None = None,
|
||||
source_url: str | None = None,
|
||||
severity: str = "info",
|
||||
category: str | None = None,
|
||||
dedupe_key: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
retention_expires_at: datetime | None = None,
|
||||
) -> NotificationEvent:
|
||||
owner = _normalize_owner(owner)
|
||||
event_class = _validate_event_class(event_class)
|
||||
severity = _validate_severity(severity)
|
||||
event = None
|
||||
if dedupe_key:
|
||||
event = _owner_query(db.query(NotificationEvent), NotificationEvent, owner).filter(
|
||||
NotificationEvent.dedupe_key == dedupe_key
|
||||
).first()
|
||||
if event is None:
|
||||
event = NotificationEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
owner=owner,
|
||||
dedupe_key=dedupe_key,
|
||||
created_at=utcnow_naive(),
|
||||
)
|
||||
db.add(event)
|
||||
|
||||
event.event_class = event_class
|
||||
event.title = _clamp_text(title or "Notification", 240) or "Notification"
|
||||
event.body = _clamp_text(body, 20000)
|
||||
event.source_type = _clamp_text(source_type, 80)
|
||||
event.source_id = _clamp_text(source_id, 240)
|
||||
event.source_url = _clamp_text(source_url, 2000)
|
||||
event.severity = severity
|
||||
event.category = _clamp_text(category, 80)
|
||||
event.metadata_json = metadata or {}
|
||||
event.retention_expires_at = retention_expires_at
|
||||
return event
|
||||
|
||||
|
||||
def record_notification_event(
|
||||
*,
|
||||
owner: str | None,
|
||||
event_class: str = SYSTEM_EVENT,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
source_type: str | None = None,
|
||||
source_id: str | None = None,
|
||||
source_url: str | None = None,
|
||||
severity: str = "info",
|
||||
category: str | None = None,
|
||||
dedupe_key: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
retention_expires_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = _upsert_event(
|
||||
db,
|
||||
owner=owner,
|
||||
event_class=event_class,
|
||||
title=title,
|
||||
body=body,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
source_url=source_url,
|
||||
severity=severity,
|
||||
category=category,
|
||||
dedupe_key=dedupe_key,
|
||||
metadata=metadata,
|
||||
retention_expires_at=retention_expires_at,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return _serialize_event(event)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def create_inbox_notification(
|
||||
*,
|
||||
owner: str | None,
|
||||
notification_kind: str = INBOX_RECORD,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
source_type: str | None = None,
|
||||
source_id: str | None = None,
|
||||
source_url: str | None = None,
|
||||
severity: str = "info",
|
||||
category: str | None = None,
|
||||
dedupe_key: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
primary_action: str | None = None,
|
||||
action_url: str | None = None,
|
||||
retention_expires_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if notification_kind not in INBOX_CLASSES:
|
||||
raise ValueError(f"Unknown inbox notification_kind: {notification_kind}")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = _upsert_event(
|
||||
db,
|
||||
owner=owner,
|
||||
event_class=notification_kind,
|
||||
title=title,
|
||||
body=body,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
source_url=source_url,
|
||||
severity=severity,
|
||||
category=category,
|
||||
dedupe_key=dedupe_key,
|
||||
metadata=metadata,
|
||||
retention_expires_at=retention_expires_at,
|
||||
)
|
||||
db.flush()
|
||||
item = db.query(NotificationInboxItem).filter(
|
||||
NotificationInboxItem.event_id == event.id
|
||||
).first()
|
||||
if item is None:
|
||||
item = NotificationInboxItem(
|
||||
id=str(uuid.uuid4()),
|
||||
owner=_normalize_owner(owner),
|
||||
event_id=event.id,
|
||||
notification_kind=notification_kind,
|
||||
created_at=utcnow_naive(),
|
||||
)
|
||||
db.add(item)
|
||||
item.notification_kind = notification_kind
|
||||
item.primary_action = _clamp_text(primary_action, 80)
|
||||
item.action_url = _clamp_text(action_url, 2000)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return _serialize_item(item)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def record_task_notification(
|
||||
*,
|
||||
task_name: str,
|
||||
status: str,
|
||||
task_id: str | None = None,
|
||||
owner: str | None = None,
|
||||
body: str | None = None,
|
||||
run_id: str | None = None,
|
||||
output_target: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist the durable counterpart to the transient task notification."""
|
||||
clean_status = (status or "").strip().lower() or "unknown"
|
||||
ok = clean_status == "success"
|
||||
clean_name = (task_name or "Task").strip() or "Task"
|
||||
metadata = {
|
||||
"task_id": task_id,
|
||||
"run_id": run_id,
|
||||
"status": clean_status,
|
||||
"output_target": output_target or "session",
|
||||
}
|
||||
source_id = run_id or task_id
|
||||
dedupe_key = f"task-run:{run_id}:{clean_status}" if run_id else None
|
||||
|
||||
if ok and body:
|
||||
return create_inbox_notification(
|
||||
owner=owner,
|
||||
notification_kind=INBOX_RECORD,
|
||||
title=clean_name,
|
||||
body=body,
|
||||
source_type="task_run",
|
||||
source_id=source_id,
|
||||
source_url="#tasks",
|
||||
severity="info",
|
||||
category="task",
|
||||
dedupe_key=dedupe_key,
|
||||
metadata=metadata,
|
||||
primary_action="open_task",
|
||||
action_url=f"odysseus://tasks/{task_id or ''}",
|
||||
)
|
||||
|
||||
if not ok:
|
||||
return create_inbox_notification(
|
||||
owner=owner,
|
||||
notification_kind=ACTIONABLE,
|
||||
title=f"Task failed: {clean_name}",
|
||||
body=body,
|
||||
source_type="task_run",
|
||||
source_id=source_id,
|
||||
source_url="#tasks",
|
||||
severity="error",
|
||||
category="task",
|
||||
dedupe_key=dedupe_key,
|
||||
metadata=metadata,
|
||||
primary_action="open_task",
|
||||
action_url=f"odysseus://tasks/{task_id or ''}",
|
||||
)
|
||||
|
||||
return record_notification_event(
|
||||
owner=owner,
|
||||
event_class=SYSTEM_EVENT,
|
||||
title=f"Task finished: {clean_name}",
|
||||
body=None,
|
||||
source_type="task_run",
|
||||
source_id=source_id,
|
||||
source_url="#tasks",
|
||||
severity="info",
|
||||
category="task",
|
||||
dedupe_key=dedupe_key,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def list_inbox_notifications(
|
||||
*,
|
||||
owner: str | None,
|
||||
limit: int = 50,
|
||||
include_archived: bool = False,
|
||||
include_dismissed: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = _owner_query(db.query(NotificationInboxItem), NotificationInboxItem, owner)
|
||||
if not include_archived:
|
||||
q = q.filter(NotificationInboxItem.archived_at == None) # noqa: E711
|
||||
if not include_dismissed:
|
||||
q = q.filter(NotificationInboxItem.dismissed_at == None) # noqa: E711
|
||||
items = q.order_by(NotificationInboxItem.created_at.desc()).limit(_safe_limit(limit)).all()
|
||||
return [_serialize_item(item) for item in items]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def list_notification_events(
|
||||
*,
|
||||
owner: str | None,
|
||||
limit: int = 100,
|
||||
event_class: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = _owner_query(db.query(NotificationEvent), NotificationEvent, owner)
|
||||
if event_class:
|
||||
q = q.filter(NotificationEvent.event_class == event_class)
|
||||
events = q.order_by(NotificationEvent.created_at.desc()).limit(_safe_limit(limit, default=100)).all()
|
||||
return [_serialize_event(event) for event in events]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def count_unread_notifications(*, owner: str | None) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = _owner_query(db.query(NotificationInboxItem), NotificationInboxItem, owner).filter(
|
||||
NotificationInboxItem.is_read == False, # noqa: E712
|
||||
NotificationInboxItem.dismissed_at == None, # noqa: E711
|
||||
NotificationInboxItem.archived_at == None, # noqa: E711
|
||||
)
|
||||
return int(q.count())
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def mark_notification_read(*, item_id: str, owner: str | None, read: bool = True) -> dict[str, Any] | None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
item = _owner_query(db.query(NotificationInboxItem), NotificationInboxItem, owner).filter(
|
||||
NotificationInboxItem.id == item_id
|
||||
).first()
|
||||
if not item:
|
||||
return None
|
||||
item.is_read = bool(read)
|
||||
item.read_at = utcnow_naive() if read else None
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return _serialize_item(item)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def dismiss_notification(*, item_id: str, owner: str | None) -> dict[str, Any] | None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
item = _owner_query(db.query(NotificationInboxItem), NotificationInboxItem, owner).filter(
|
||||
NotificationInboxItem.id == item_id
|
||||
).first()
|
||||
if not item:
|
||||
return None
|
||||
item.is_read = True
|
||||
item.read_at = item.read_at or utcnow_naive()
|
||||
item.dismissed_at = utcnow_naive()
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return _serialize_item(item)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def archive_notification(*, item_id: str, owner: str | None) -> dict[str, Any] | None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
item = _owner_query(db.query(NotificationInboxItem), NotificationInboxItem, owner).filter(
|
||||
NotificationInboxItem.id == item_id
|
||||
).first()
|
||||
if not item:
|
||||
return None
|
||||
item.is_read = True
|
||||
item.read_at = item.read_at or utcnow_naive()
|
||||
item.archived_at = utcnow_naive()
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return _serialize_item(item)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -342,6 +342,7 @@ class TaskScheduler:
|
|||
# tasks could be double-dispatched.
|
||||
self._executing_lock = asyncio.Lock()
|
||||
self._pending_notifications = [] # completed task notifications
|
||||
self._durable_notifications_enabled = True
|
||||
self._task_defer_counts = {}
|
||||
# Strict serial execution — exactly one task runs at a time. Anything
|
||||
# else (manual trigger, scheduled dispatch, task chain) waits behind
|
||||
|
|
@ -397,12 +398,35 @@ class TaskScheduler:
|
|||
logger.debug("Task abort marker failed for %s", task_id, exc_info=True)
|
||||
return False
|
||||
|
||||
def add_notification(self, task_name: str, status: str, task_id: str = None, owner: str = None, body: str = None):
|
||||
def add_notification(
|
||||
self,
|
||||
task_name: str,
|
||||
status: str,
|
||||
task_id: str = None,
|
||||
owner: str = None,
|
||||
body: str = None,
|
||||
run_id: str = None,
|
||||
output_target: str = None,
|
||||
):
|
||||
"""Store a notification about a completed task run. Tagged with the
|
||||
task's owner so `pop_notifications` can return only that user's
|
||||
notifications and prevent cross-tenant drain. `body` is the result
|
||||
text — populated when output_target='notification' so the client can
|
||||
show a rich browser Notification, not just a toast."""
|
||||
if getattr(self, "_durable_notifications_enabled", False):
|
||||
try:
|
||||
from src.notifications import record_task_notification
|
||||
record_task_notification(
|
||||
task_name=task_name,
|
||||
status=status,
|
||||
task_id=task_id,
|
||||
owner=owner,
|
||||
body=body,
|
||||
run_id=run_id,
|
||||
output_target=output_target,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Durable task notification write failed", exc_info=True)
|
||||
self._pending_notifications.append({
|
||||
"task_name": task_name,
|
||||
"status": status,
|
||||
|
|
@ -1042,6 +1066,8 @@ class TaskScheduler:
|
|||
task_id,
|
||||
owner=task.owner,
|
||||
body=run.result if output == "notification" else None,
|
||||
run_id=run_id,
|
||||
output_target=output,
|
||||
)
|
||||
elif run.status == "error":
|
||||
self.add_notification(
|
||||
|
|
@ -1050,6 +1076,8 @@ class TaskScheduler:
|
|||
task_id,
|
||||
owner=task.owner,
|
||||
body=run.error or run.result,
|
||||
run_id=run_id,
|
||||
output_target=output,
|
||||
)
|
||||
|
||||
# Log result to the assistant chat so all task activity is visible.
|
||||
|
|
@ -1095,7 +1123,14 @@ class TaskScheduler:
|
|||
except Exception:
|
||||
_should_notify_error = False
|
||||
if _should_notify_error:
|
||||
self.add_notification(f"Task {task_id}", "error", task_id, owner=_owner)
|
||||
self.add_notification(
|
||||
f"Task {task_id}",
|
||||
"error",
|
||||
task_id,
|
||||
owner=_owner,
|
||||
body=f"{type(exec_exc).__name__}: {exec_exc}",
|
||||
run_id=run_id,
|
||||
)
|
||||
try:
|
||||
# Persist the actual exception message so the UI can show it
|
||||
err_text = f"{type(exec_exc).__name__}: {exec_exc}"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import voiceRecorderModule from './js/voiceRecorder.js';
|
|||
import censorModule from './js/censor.js';
|
||||
import galleryModule from './js/gallery.js';
|
||||
import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
|
||||
import notificationsModule from './js/notifications.js';
|
||||
import calendarModule from './js/calendar.js';
|
||||
import notesModule from './js/notes.js';
|
||||
import adminModule from './js/admin.js?v=20260716openrouter3';
|
||||
|
|
@ -3737,6 +3738,9 @@ function startOdysseusApp() {
|
|||
if (compareModule) {
|
||||
compareModule.init(API_BASE);
|
||||
}
|
||||
if (notificationsModule) {
|
||||
notificationsModule.init(API_BASE);
|
||||
}
|
||||
researchPanelModule.init(API_BASE, markdownModule, sessionModule);
|
||||
// Initialize document editor module
|
||||
if (documentModule) {
|
||||
|
|
|
|||
428
static/js/notifications.js
Normal file
428
static/js/notifications.js
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
import uiModule from './ui.js';
|
||||
|
||||
let API_BASE = window.location.origin;
|
||||
let _panelOpen = false;
|
||||
let _mode = 'inbox';
|
||||
let _countTimer = null;
|
||||
let _bound = false;
|
||||
let _anchorButton = null;
|
||||
|
||||
const ICONS = {
|
||||
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||||
list: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
inbox: '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
open: '<path d="M7 7h10v10"/><path d="M7 17 17 7"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
archive: '<rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>',
|
||||
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
};
|
||||
|
||||
function _svg(path, size = 16) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${path}</svg>`;
|
||||
}
|
||||
|
||||
function _esc(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function _relativeTime(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const diff = Date.now() - d.getTime();
|
||||
const abs = Math.abs(diff);
|
||||
if (abs < 60000) return 'just now';
|
||||
if (abs < 3600000) return `${Math.round(abs / 60000)}m ago`;
|
||||
if (abs < 86400000) return `${Math.round(abs / 3600000)}h ago`;
|
||||
return `${Math.round(abs / 86400000)}d ago`;
|
||||
}
|
||||
|
||||
function _ensureShell() {
|
||||
const rail = document.getElementById('icon-rail');
|
||||
if (!document.getElementById('notification-inbox-btn')) {
|
||||
const railBtn = document.createElement('button');
|
||||
railBtn.id = 'notification-inbox-btn';
|
||||
railBtn.className = 'icon-rail-btn notification-inbox-btn';
|
||||
railBtn.type = 'button';
|
||||
railBtn.title = 'Notifications';
|
||||
railBtn.setAttribute('aria-label', 'Notifications');
|
||||
railBtn.innerHTML = `${_svg(ICONS.bell, 16)}<span class="notification-inbox-badge hidden">0</span>`;
|
||||
const separator = rail?.querySelector('.rail-separator');
|
||||
if (separator) separator.insertAdjacentElement('afterend', railBtn);
|
||||
else if (rail) rail.appendChild(railBtn);
|
||||
else document.body.appendChild(railBtn);
|
||||
}
|
||||
|
||||
if (!document.getElementById('notification-inbox-sidebar-btn')) {
|
||||
const sidebarBtn = document.createElement('div');
|
||||
sidebarBtn.id = 'notification-inbox-sidebar-btn';
|
||||
sidebarBtn.className = 'list-item notification-sidebar-item';
|
||||
sidebarBtn.title = 'Notifications';
|
||||
sidebarBtn.setAttribute('role', 'button');
|
||||
sidebarBtn.setAttribute('tabindex', '0');
|
||||
sidebarBtn.innerHTML = `
|
||||
${_svg(ICONS.bell, 14)}
|
||||
<span class="grow">Notifications</span>
|
||||
<span class="notification-inbox-badge notification-sidebar-badge hidden">0</span>
|
||||
`;
|
||||
const searchBtn = document.getElementById('sidebar-search-btn');
|
||||
const sessionsSection = document.getElementById('sessions-section');
|
||||
if (searchBtn) searchBtn.insertAdjacentElement('afterend', sidebarBtn);
|
||||
else if (sessionsSection) sessionsSection.insertAdjacentElement('beforebegin', sidebarBtn);
|
||||
else document.getElementById('sidebar')?.appendChild(sidebarBtn);
|
||||
}
|
||||
|
||||
if (!document.getElementById('notification-inbox-panel')) {
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'notification-inbox-panel';
|
||||
panel.className = 'notification-inbox-panel hidden';
|
||||
panel.innerHTML = `
|
||||
<div class="notification-inbox-head">
|
||||
<div class="notification-inbox-title">${_svg(ICONS.inbox, 15)}<span id="notification-inbox-title">Notifications</span></div>
|
||||
<div class="notification-inbox-tools">
|
||||
<button id="notification-inbox-mode" class="notification-icon-btn" type="button" title="System log">${_svg(ICONS.list, 15)}</button>
|
||||
<button id="notification-inbox-close" class="notification-icon-btn" type="button" title="Close">${_svg(ICONS.x, 15)}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="notification-inbox-list" class="notification-inbox-list"></div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
}
|
||||
}
|
||||
|
||||
function _setBadge(n) {
|
||||
const count = Number(n || 0);
|
||||
document.querySelectorAll('.notification-inbox-badge').forEach((badge) => {
|
||||
badge.textContent = count > 99 ? '99+' : String(count);
|
||||
badge.classList.toggle('hidden', count <= 0);
|
||||
});
|
||||
document.querySelectorAll('.notification-inbox-btn, .notification-sidebar-item').forEach((btn) => {
|
||||
btn.classList.toggle('has-unread', count > 0);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshCount() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/notifications/count`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
_setBadge(data.unread || 0);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _setLoading() {
|
||||
const list = document.getElementById('notification-inbox-list');
|
||||
if (list) list.innerHTML = '<div class="notification-empty">Loading...</div>';
|
||||
}
|
||||
|
||||
function _setEmpty(text) {
|
||||
const list = document.getElementById('notification-inbox-list');
|
||||
if (list) list.innerHTML = `<div class="notification-empty">${_esc(text)}</div>`;
|
||||
}
|
||||
|
||||
async function _loadPanel() {
|
||||
const title = document.getElementById('notification-inbox-title');
|
||||
const modeBtn = document.getElementById('notification-inbox-mode');
|
||||
if (title) title.textContent = _mode === 'events' ? 'System log' : 'Notifications';
|
||||
if (modeBtn) modeBtn.title = _mode === 'events' ? 'Notifications' : 'System log';
|
||||
_setLoading();
|
||||
try {
|
||||
const path = _mode === 'events'
|
||||
? `${API_BASE}/api/notifications/events?limit=80`
|
||||
: `${API_BASE}/api/notifications?limit=80`;
|
||||
const res = await fetch(path, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (_mode === 'events') _renderEvents(data.events || []);
|
||||
else _renderInbox(data.notifications || []);
|
||||
_positionPanel();
|
||||
} catch (e) {
|
||||
_setEmpty('Could not load notifications');
|
||||
_positionPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function _renderInbox(items) {
|
||||
const list = document.getElementById('notification-inbox-list');
|
||||
if (!list) return;
|
||||
if (!items.length) {
|
||||
_setEmpty('Nothing new');
|
||||
return;
|
||||
}
|
||||
list.innerHTML = items.map((item) => {
|
||||
const summary = item.body ? `<div class="notification-body">${_esc(item.body)}</div>` : '';
|
||||
const cls = [
|
||||
'notification-item',
|
||||
item.is_read ? 'is-read' : 'is-unread',
|
||||
`severity-${_esc(item.severity || 'info')}`,
|
||||
].join(' ');
|
||||
return `
|
||||
<div class="${cls}" data-id="${_esc(item.id)}">
|
||||
<button class="notification-main" type="button" data-action="open">
|
||||
<span class="notification-item-title">${_esc(item.title || 'Notification')}</span>
|
||||
<span class="notification-item-meta">${_esc(_relativeTime(item.created_at))}</span>
|
||||
${summary}
|
||||
</button>
|
||||
<div class="notification-actions">
|
||||
<button class="notification-icon-btn" type="button" title="Open" data-action="open">${_svg(ICONS.open, 14)}</button>
|
||||
<button class="notification-icon-btn" type="button" title="Mark read" data-action="read">${_svg(ICONS.check, 14)}</button>
|
||||
<button class="notification-icon-btn" type="button" title="Archive" data-action="archive">${_svg(ICONS.archive, 14)}</button>
|
||||
<button class="notification-icon-btn" type="button" title="Dismiss" data-action="dismiss">${_svg(ICONS.x, 14)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
list.querySelectorAll('.notification-item').forEach((row) => {
|
||||
row.addEventListener('click', (e) => {
|
||||
const action = e.target.closest('[data-action]')?.dataset?.action;
|
||||
const item = items.find(n => n.id === row.dataset.id);
|
||||
if (!item || !action) return;
|
||||
e.stopPropagation();
|
||||
_handleItemAction(item, action);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _renderEvents(events) {
|
||||
const list = document.getElementById('notification-inbox-list');
|
||||
if (!list) return;
|
||||
if (!events.length) {
|
||||
_setEmpty('No events');
|
||||
return;
|
||||
}
|
||||
list.innerHTML = events.map((event) => `
|
||||
<div class="notification-item is-read severity-${_esc(event.severity || 'info')}">
|
||||
<div class="notification-main static">
|
||||
<span class="notification-item-title">${_esc(event.title || 'Event')}</span>
|
||||
<span class="notification-item-meta">${_esc(_relativeTime(event.created_at))}</span>
|
||||
${event.body ? `<div class="notification-body">${_esc(event.body)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function _postItem(item, action) {
|
||||
const body = action === 'read' ? { read: true } : undefined;
|
||||
const res = await fetch(`${API_BASE}/api/notifications/${encodeURIComponent(item.id)}/${action}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function _handleItemAction(item, action) {
|
||||
try {
|
||||
if (action === 'open') {
|
||||
if (!item.is_read) await _postItem(item, 'read');
|
||||
_openTarget(item);
|
||||
_closePanel();
|
||||
} else {
|
||||
await _postItem(item, action);
|
||||
}
|
||||
await refreshCount();
|
||||
if (_panelOpen) await _loadPanel();
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showError('Notification action failed');
|
||||
}
|
||||
}
|
||||
|
||||
function _openTarget(item) {
|
||||
const meta = item.metadata || {};
|
||||
const taskId = meta.task_id || '';
|
||||
const actionUrl = item.action_url || item.source_url || '';
|
||||
if ((item.source_type === 'task_run' || actionUrl.startsWith('odysseus://tasks')) && window.tasksModule) {
|
||||
window.tasksModule.openTasks(taskId || null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(item.source_type || '').startsWith('email')
|
||||
|| actionUrl.startsWith('odysseus://email')
|
||||
|| actionUrl.startsWith('#email')
|
||||
) {
|
||||
_openEmailTarget(actionUrl);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
item.source_type === 'chat_session'
|
||||
|| item.source_type === 'session'
|
||||
|| actionUrl.startsWith('odysseus://chat')
|
||||
|| actionUrl.startsWith('odysseus://session')
|
||||
|| actionUrl.startsWith('#chat=')
|
||||
|| actionUrl.startsWith('#session=')
|
||||
) {
|
||||
_openChatTarget(item, actionUrl);
|
||||
return;
|
||||
}
|
||||
if (actionUrl && actionUrl.startsWith('#')) {
|
||||
window.location.hash = actionUrl;
|
||||
return;
|
||||
}
|
||||
if (actionUrl && /^https?:\/\//.test(actionUrl)) {
|
||||
window.location.assign(actionUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function _openEmailTarget(actionUrl) {
|
||||
import('./emailLibrary.js')
|
||||
.then((mod) => {
|
||||
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (typeof open !== 'function') throw new Error('Email library unavailable');
|
||||
open(_emailTargetOptions(actionUrl));
|
||||
})
|
||||
.catch(() => {
|
||||
document.querySelector('#email-section .section-header-flex')?.click();
|
||||
});
|
||||
}
|
||||
|
||||
function _emailTargetOptions(actionUrl) {
|
||||
const opts = {};
|
||||
const match = String(actionUrl || '').match(/#email=([^:]+):(.+)$/);
|
||||
if (match) {
|
||||
opts.folder = decodeURIComponent(match[1]);
|
||||
opts.uid = decodeURIComponent(match[2]);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function _openChatTarget(item, actionUrl) {
|
||||
const meta = item.metadata || {};
|
||||
const sessionId = meta.session_id || item.source_id || _chatTargetId(actionUrl);
|
||||
if (!sessionId || !window.sessionModule) return;
|
||||
const open = () => window.sessionModule.selectSession(sessionId);
|
||||
const sessions = window.sessionModule.getSessions?.() || [];
|
||||
if (sessions.some((s) => s.id === sessionId)) {
|
||||
open();
|
||||
return;
|
||||
}
|
||||
const load = window.sessionModule.loadSessions?.();
|
||||
if (load && typeof load.then === 'function') {
|
||||
load.then(open).catch(open);
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
function _chatTargetId(actionUrl) {
|
||||
const raw = String(actionUrl || '');
|
||||
const match = raw.match(/^odysseus:\/\/(?:chat|session)s?\/(.+)$/)
|
||||
|| raw.match(/^#(?:chat|session)=(.+)$/);
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
}
|
||||
|
||||
function _isNotificationTrigger(node) {
|
||||
return !!node?.closest?.('#notification-inbox-btn, #notification-inbox-sidebar-btn');
|
||||
}
|
||||
|
||||
function _closePanel() {
|
||||
_panelOpen = false;
|
||||
const panel = document.getElementById('notification-inbox-panel');
|
||||
if (!panel) return;
|
||||
panel.classList.add('hidden');
|
||||
panel.style.left = '';
|
||||
panel.style.right = '';
|
||||
panel.style.top = '';
|
||||
}
|
||||
|
||||
function _positionPanel(anchor = _anchorButton) {
|
||||
const panel = document.getElementById('notification-inbox-panel');
|
||||
if (!panel || panel.classList.contains('hidden')) return;
|
||||
const rect = anchor?.getBoundingClientRect?.();
|
||||
const margin = 10;
|
||||
const width = Math.min(360, window.innerWidth - margin * 2);
|
||||
panel.style.width = `${width}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = `${margin}px`;
|
||||
panel.style.top = `${margin}px`;
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const railOnRight = rect.left > window.innerWidth / 2;
|
||||
const left = railOnRight
|
||||
? Math.max(margin, rect.left - panelRect.width - 8)
|
||||
: Math.min(window.innerWidth - panelRect.width - margin, rect.right + 8);
|
||||
const top = Math.min(
|
||||
window.innerHeight - panelRect.height - margin,
|
||||
Math.max(margin, rect.top - 8)
|
||||
);
|
||||
panel.style.left = `${Math.max(margin, left)}px`;
|
||||
panel.style.top = `${Math.max(margin, top)}px`;
|
||||
}
|
||||
|
||||
function _openPanel(anchor) {
|
||||
_anchorButton = anchor || document.getElementById('notification-inbox-btn');
|
||||
_panelOpen = true;
|
||||
const panel = document.getElementById('notification-inbox-panel');
|
||||
if (!panel) return;
|
||||
panel.classList.remove('hidden');
|
||||
_positionPanel();
|
||||
_loadPanel();
|
||||
}
|
||||
|
||||
function _togglePanel(anchor) {
|
||||
if (_panelOpen && _anchorButton === anchor) {
|
||||
_closePanel();
|
||||
return;
|
||||
}
|
||||
_openPanel(anchor);
|
||||
}
|
||||
|
||||
function _bindEvents() {
|
||||
if (_bound) return;
|
||||
_bound = true;
|
||||
document.querySelectorAll('#notification-inbox-btn, #notification-inbox-sidebar-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_togglePanel(btn);
|
||||
});
|
||||
btn.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
_togglePanel(btn);
|
||||
});
|
||||
});
|
||||
document.getElementById('notification-inbox-close')?.addEventListener('click', () => {
|
||||
_closePanel();
|
||||
});
|
||||
document.getElementById('notification-inbox-mode')?.addEventListener('click', () => {
|
||||
_mode = _mode === 'events' ? 'inbox' : 'events';
|
||||
_loadPanel();
|
||||
});
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
if (!_panelOpen) return;
|
||||
const panel = document.getElementById('notification-inbox-panel');
|
||||
const path = e.composedPath ? e.composedPath() : [];
|
||||
if (path.includes(panel) || _isNotificationTrigger(e.target)) return;
|
||||
_closePanel();
|
||||
}, true);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && _panelOpen) _closePanel();
|
||||
}, true);
|
||||
window.addEventListener('resize', () => {
|
||||
if (_panelOpen) _positionPanel();
|
||||
});
|
||||
document.addEventListener('odysseus:notifications-changed', () => {
|
||||
refreshCount();
|
||||
if (_panelOpen) _loadPanel();
|
||||
});
|
||||
}
|
||||
|
||||
export function init(apiBase = window.location.origin) {
|
||||
API_BASE = apiBase || window.location.origin;
|
||||
_ensureShell();
|
||||
_bindEvents();
|
||||
refreshCount();
|
||||
if (!_countTimer) _countTimer = setInterval(refreshCount, 45000);
|
||||
}
|
||||
|
||||
const notificationsModule = { init, refreshCount };
|
||||
export default notificationsModule;
|
||||
window.notificationsModule = notificationsModule;
|
||||
|
|
@ -3127,6 +3127,9 @@ async function _pollTaskNotifications() {
|
|||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const notes = data.notifications || [];
|
||||
if (notes.length) {
|
||||
document.dispatchEvent(new CustomEvent('odysseus:notifications-changed'));
|
||||
}
|
||||
for (const n of notes) {
|
||||
const ok = n.status === 'success';
|
||||
if (ok) {
|
||||
|
|
|
|||
199
static/style.css
199
static/style.css
|
|
@ -4519,6 +4519,205 @@ body.bg-pattern-sparkles {
|
|||
border-left-color: var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
.notification-inbox-btn,
|
||||
.notification-sidebar-item {
|
||||
position: relative;
|
||||
}
|
||||
.notification-inbox-btn {
|
||||
color: var(--accent, var(--red));
|
||||
}
|
||||
.notification-inbox-btn:hover,
|
||||
.notification-inbox-btn.has-unread {
|
||||
border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--panel) 86%, var(--accent, var(--red)) 14%);
|
||||
}
|
||||
.notification-sidebar-item.has-unread {
|
||||
background: color-mix(in srgb, var(--panel) 86%, var(--accent, var(--red)) 14%);
|
||||
}
|
||||
.notification-inbox-btn:active,
|
||||
.notification-sidebar-item:active { transform: translateY(1px); }
|
||||
.notification-sidebar-item svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent, var(--red));
|
||||
opacity: 1;
|
||||
}
|
||||
.notification-inbox-badge {
|
||||
position: absolute;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-error);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.notification-sidebar-badge {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
margin-left: auto;
|
||||
margin-right: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notification-inbox-panel {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: calc(var(--icon-rail-w, 48px) + 10px);
|
||||
right: auto;
|
||||
z-index: 9299;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
max-height: min(520px, calc(100vh - 20px));
|
||||
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel) 96%, #000 4%);
|
||||
color: var(--fg);
|
||||
box-shadow: 0 18px 52px rgba(0,0,0,0.42);
|
||||
backdrop-filter: blur(18px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.notification-inbox-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
.notification-inbox-head {
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px 0 12px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||
}
|
||||
.notification-inbox-title {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.notification-inbox-tools,
|
||||
.notification-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notification-icon-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--fg) 76%, transparent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.notification-icon-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--border) 75%, transparent);
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
.notification-inbox-list {
|
||||
min-height: 86px;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.notification-item {
|
||||
border: 1px solid color-mix(in srgb, var(--border) 62%, transparent);
|
||||
border-left: 3px solid color-mix(in srgb, var(--fg) 22%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--panel) 91%, var(--fg) 4%);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notification-item.severity-error { border-left-color: var(--color-error); }
|
||||
.notification-item.severity-urgent { border-left-color: var(--accent-primary, var(--accent)); }
|
||||
.notification-item.severity-attention { border-left-color: var(--color-warning, var(--warn)); }
|
||||
.notification-item.is-read {
|
||||
opacity: 0.72;
|
||||
background: color-mix(in srgb, var(--panel) 95%, transparent);
|
||||
}
|
||||
.notification-main {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
padding: 9px 4px 9px 10px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 3px 8px;
|
||||
align-content: start;
|
||||
font: inherit;
|
||||
}
|
||||
.notification-main.static {
|
||||
cursor: default;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.notification-item-title {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notification-item-meta {
|
||||
font-size: 10px;
|
||||
color: color-mix(in srgb, var(--fg) 54%, transparent);
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.notification-body {
|
||||
grid-column: 1 / -1;
|
||||
color: color-mix(in srgb, var(--fg) 74%, transparent);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
max-height: 4.05em;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notification-actions {
|
||||
align-self: start;
|
||||
padding: 7px 6px 0 0;
|
||||
}
|
||||
.notification-empty {
|
||||
min-height: 74px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--fg) 58%, transparent);
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.notification-inbox-panel {
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
right: auto;
|
||||
width: calc(100vw - 24px);
|
||||
}
|
||||
}
|
||||
/* When the notes panel is docked to the right, the default top-right toast
|
||||
sits directly over the Archive / View-toggle buttons in the panel header.
|
||||
Flip it to the top-left so you can still reach the header after an
|
||||
|
|
|
|||
145
tests/test_notification_inbox.py
Normal file
145
tests/test_notification_inbox.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""Durable notification event and inbox behavior."""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from tests.helpers.import_state import clear_fake_database_modules
|
||||
from tests.helpers.sqlite_db import make_temp_sqlite
|
||||
|
||||
clear_fake_database_modules()
|
||||
|
||||
import core.database as cdb # noqa: E402
|
||||
import routes.notification_routes as notification_routes # noqa: E402
|
||||
import src.notifications as notifications # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def notification_db(monkeypatch):
|
||||
SessionLocal, engine, tmpfile = make_temp_sqlite(cdb.Base.metadata)
|
||||
monkeypatch.setattr(notifications, "SessionLocal", SessionLocal)
|
||||
try:
|
||||
yield SessionLocal
|
||||
finally:
|
||||
engine.dispose()
|
||||
tmpfile.close()
|
||||
try:
|
||||
os.unlink(tmpfile.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _req(user="alice"):
|
||||
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
||||
|
||||
|
||||
def _endpoint(method, path):
|
||||
router = notification_routes.setup_notification_routes()
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", None) == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise RuntimeError(f"{method} {path} not found")
|
||||
|
||||
|
||||
def test_system_events_are_logged_without_creating_inbox_items(notification_db):
|
||||
event = notifications.record_notification_event(
|
||||
owner="alice",
|
||||
title="Folder created",
|
||||
body="Inbox/Receipts",
|
||||
category="email",
|
||||
dedupe_key="email-folder:receipts",
|
||||
)
|
||||
|
||||
assert event["event_class"] == notifications.SYSTEM_EVENT
|
||||
assert notifications.list_notification_events(owner="alice")[0]["title"] == "Folder created"
|
||||
assert notifications.list_inbox_notifications(owner="alice") == []
|
||||
assert notifications.count_unread_notifications(owner="alice") == 0
|
||||
|
||||
|
||||
def test_task_notification_body_creates_deduped_inbox_record(notification_db):
|
||||
first = notifications.record_task_notification(
|
||||
owner="alice",
|
||||
task_name="Morning digest",
|
||||
status="success",
|
||||
task_id="task-1",
|
||||
run_id="run-1",
|
||||
output_target="notification",
|
||||
body="Three urgent messages need review.",
|
||||
)
|
||||
second = notifications.record_task_notification(
|
||||
owner="alice",
|
||||
task_name="Morning digest",
|
||||
status="success",
|
||||
task_id="task-1",
|
||||
run_id="run-1",
|
||||
output_target="notification",
|
||||
body="Three urgent messages need review.",
|
||||
)
|
||||
|
||||
inbox = notifications.list_inbox_notifications(owner="alice")
|
||||
assert first["id"] == second["id"]
|
||||
assert len(inbox) == 1
|
||||
assert inbox[0]["notification_kind"] == notifications.INBOX_RECORD
|
||||
assert inbox[0]["body"] == "Three urgent messages need review."
|
||||
assert notifications.count_unread_notifications(owner="alice") == 1
|
||||
|
||||
|
||||
def test_task_failure_creates_owner_scoped_actionable_notification(notification_db):
|
||||
notifications.record_task_notification(
|
||||
owner="alice",
|
||||
task_name="Urgent email check",
|
||||
status="error",
|
||||
task_id="alice-task",
|
||||
run_id="alice-run",
|
||||
body="Provider timeout",
|
||||
)
|
||||
notifications.record_task_notification(
|
||||
owner="bob",
|
||||
task_name="Bob task",
|
||||
status="error",
|
||||
task_id="bob-task",
|
||||
run_id="bob-run",
|
||||
body="Hidden from Alice",
|
||||
)
|
||||
|
||||
inbox = notifications.list_inbox_notifications(owner="alice")
|
||||
assert len(inbox) == 1
|
||||
assert inbox[0]["notification_kind"] == notifications.ACTIONABLE
|
||||
assert inbox[0]["title"] == "Task failed: Urgent email check"
|
||||
assert inbox[0]["severity"] == "error"
|
||||
assert inbox[0]["metadata"]["task_id"] == "alice-task"
|
||||
assert inbox[0]["action_url"].endswith("/alice-task")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_routes_mark_read_and_reject_cross_owner(notification_db):
|
||||
item = notifications.create_inbox_notification(
|
||||
owner="alice",
|
||||
notification_kind=notifications.ACTIONABLE,
|
||||
title="Reply needed",
|
||||
body="Urgent email from finance",
|
||||
source_type="email",
|
||||
source_id="uid-1",
|
||||
)
|
||||
count_endpoint = _endpoint("GET", "/api/notifications/count")
|
||||
read_endpoint = _endpoint("POST", "/api/notifications/{item_id}/read")
|
||||
|
||||
assert await count_endpoint(_req("alice")) == {"unread": 1}
|
||||
|
||||
marked = await read_endpoint(
|
||||
_req("alice"),
|
||||
item["id"],
|
||||
notification_routes.NotificationReadRequest(read=True),
|
||||
)
|
||||
assert marked["is_read"] is True
|
||||
assert await count_endpoint(_req("alice")) == {"unread": 0}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await read_endpoint(
|
||||
_req("bob"),
|
||||
item["id"],
|
||||
notification_routes.NotificationReadRequest(read=True),
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
Loading…
Add table
Reference in a new issue