This commit is contained in:
Matyas Gosztonyi 2026-08-04 14:15:59 +02:00 committed by GitHub
commit 78f4418159
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1405 additions and 2 deletions

3
app.py
View file

@ -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))

View file

@ -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.

View 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

View file

@ -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
View 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()

View file

@ -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}"

View file

@ -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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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;

View file

@ -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) {

View file

@ -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

View 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