From 97a62265082a3ed236b38631cd1d738ce5ac2420 Mon Sep 17 00:00:00 2001 From: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:58:53 +0200 Subject: [PATCH 1/2] feat(notifications): add durable inbox --- app.py | 3 + core/database.py | 59 +++++ routes/notification_routes.py | 87 ++++++ src/database.py | 2 + src/notifications.py | 438 +++++++++++++++++++++++++++++++ src/task_scheduler.py | 39 ++- static/app.js | 4 + static/js/notifications.js | 428 ++++++++++++++++++++++++++++++ static/js/tasks.js | 3 + static/style.css | 199 ++++++++++++++ tests/test_notification_inbox.py | 145 ++++++++++ 11 files changed, 1405 insertions(+), 2 deletions(-) create mode 100644 routes/notification_routes.py create mode 100644 src/notifications.py create mode 100644 static/js/notifications.js create mode 100644 tests/test_notification_inbox.py diff --git a/app.py b/app.py index 2ae5ec761..8ddfd75ee 100644 --- a/app.py +++ b/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)) diff --git a/core/database.py b/core/database.py index a9ad90b8b..495c0f558 100644 --- a/core/database.py +++ b/core/database.py @@ -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. diff --git a/routes/notification_routes.py b/routes/notification_routes.py new file mode 100644 index 000000000..5c550dd3b --- /dev/null +++ b/routes/notification_routes.py @@ -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 diff --git a/src/database.py b/src/database.py index 8f075a564..278be42c1 100644 --- a/src/database.py +++ b/src/database.py @@ -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, diff --git a/src/notifications.py b/src/notifications.py new file mode 100644 index 000000000..67371f97b --- /dev/null +++ b/src/notifications.py @@ -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() diff --git a/src/task_scheduler.py b/src/task_scheduler.py index d5b1dad62..2be16e5f2 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -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}" diff --git a/static/app.js b/static/app.js index 97f0ae77e..84402aa31 100644 --- a/static/app.js +++ b/static/app.js @@ -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) { diff --git a/static/js/notifications.js b/static/js/notifications.js new file mode 100644 index 000000000..62f050b93 --- /dev/null +++ b/static/js/notifications.js @@ -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: '', + list: '', + inbox: '', + open: '', + check: '', + archive: '', + x: '', +}; + +function _svg(path, size = 16) { + return ``; +} + +function _esc(value) { + return String(value == null ? '' : value) + .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)}`; + 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)} + Notifications + + `; + 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 = ` +
+
${_svg(ICONS.inbox, 15)}Notifications
+
+ + +
+
+
+ `; + 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 = '
Loading...
'; +} + +function _setEmpty(text) { + const list = document.getElementById('notification-inbox-list'); + if (list) list.innerHTML = `
${_esc(text)}
`; +} + +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 ? `
${_esc(item.body)}
` : ''; + const cls = [ + 'notification-item', + item.is_read ? 'is-read' : 'is-unread', + `severity-${_esc(item.severity || 'info')}`, + ].join(' '); + return ` +
+ +
+ + + + +
+
+ `; + }).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) => ` +
+
+ ${_esc(event.title || 'Event')} + ${_esc(_relativeTime(event.created_at))} + ${event.body ? `
${_esc(event.body)}
` : ''} +
+
+ `).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; diff --git a/static/js/tasks.js b/static/js/tasks.js index 09719c452..882f8d63b 100644 --- a/static/js/tasks.js +++ b/static/js/tasks.js @@ -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) { diff --git a/static/style.css b/static/style.css index 73fdbcd5b..09f838911 100644 --- a/static/style.css +++ b/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 diff --git a/tests/test_notification_inbox.py b/tests/test_notification_inbox.py new file mode 100644 index 000000000..b209aa1e3 --- /dev/null +++ b/tests/test_notification_inbox.py @@ -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 From 8a6b08dfa2bb75c1cd35b0b6ef32b36e68ecbdae Mon Sep 17 00:00:00 2001 From: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:00:14 +0200 Subject: [PATCH 2/2] chore(ci): refresh PR checks