The dots covered Reports and Messages from the day those sections existed. The announcement did not: only a chat reply produced an HX-Trigger, so a scheduled run that filed a report or posted into Messages lit a green dot in a corner and said nothing at all. That is precisely the arrival nobody is watching for -- a chat reply is one you asked for a moment ago and are probably looking at. So every kind announces, each with its own once-only flag, and the payload is a list of items rather than of titles, because a notification is a thing you click and a title cannot say where. One arrival, three channels, and they must not all fire. A toast for somebody looking at the page; a count in the tab title while it is hidden, cleared on focus; a system notification for somebody elsewhere entirely. The service worker is the only place that can tell them apart -- the server cannot see whether a window is focused and the page cannot see a push it did not receive -- so it stays quiet when one of its own windows has focus. And web push, hand-rolled against RFC 8291 and RFC 8292 with the cryptography already here for Fernet. It exists because everything else is polled by an open page, and the arrival worth interrupting somebody for is a schedule firing at seven in the morning with the laptop shut. The trade is real and is written down rather than glossed: the POST goes to Google's or Mozilla's push service, the payload is sealed end to end so they cannot read it, and what they do learn is that this server sent something and when. Opt-in per device, off until asked for, and the rest of the system works without it. Nothing else in LLeMbas contacts an outside service on its own. The encryption is tested by decrypting it back with an independent implementation of the specification's other half. There is no other way to know: a push service accepts the POST and forwards bytes it cannot read, so a wrong derivation is a notification that never appears, with a 201 in the log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
76 lines
3.8 KiB
Python
76 lines
3.8 KiB
Python
"""Reports: what was found, written down once and never replied to."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import Boolean, ForeignKey, String, Text
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
|
|
|
# Where a report came from. Not a foreign key to anything -- see `source_id`.
|
|
SOURCE_SCHEDULE = "schedule"
|
|
SOURCE_CHAT = "chat"
|
|
SOURCE_MANUAL = "manual"
|
|
SOURCES = (SOURCE_SCHEDULE, SOURCE_CHAT, SOURCE_MANUAL)
|
|
|
|
|
|
class Report(UUIDPrimaryKey, Timestamps, Base):
|
|
"""A finished piece of work, filed.
|
|
|
|
Deliberately not a `Chat` with one `Message` in it. A report is read top to
|
|
bottom and never answered, so everything a conversation carries -- a
|
|
composer, a sidebar row, a title that regenerates itself, a bubble with an
|
|
avatar and a rewind button -- would be machinery to suppress rather than
|
|
machinery to use. It is the same line `services/library/` already draws
|
|
between a note and a chat: a durable artefact is not a turn.
|
|
|
|
It must also be writable with no chat behind it at all, being the fallback
|
|
destination for a scheduled run whose own chat has gone.
|
|
|
|
`body` is Markdown written by a model and goes through
|
|
`services/markdown.py` like everything else from an endpoint. Hard rule 6
|
|
applies here exactly as it does in a transcript.
|
|
"""
|
|
|
|
__tablename__ = "reports"
|
|
|
|
owner_id: Mapped[str] = mapped_column(
|
|
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
|
# One line for the list page, so a feed of forty reports can be read without
|
|
# opening any of them. Written by the model beside the body; falls back to
|
|
# the body's first line when it did not bother.
|
|
summary: Mapped[str] = mapped_column(String(500), default="")
|
|
body: Mapped[str] = mapped_column(Text, default="")
|
|
|
|
source: Mapped[str] = mapped_column(String(16), default=SOURCE_MANUAL, nullable=False)
|
|
# The chat or the schedule this came out of, kept so a report can say where
|
|
# it was made. Deliberately not a ForeignKey: `migrations.py` compiles the
|
|
# column type only, so a REFERENCES clause would exist on a fresh database
|
|
# and not on an upgraded one -- the same reason `Chat.compacted_through_id`
|
|
# and `Folder.ssh_profile_id` are plain ids. Both are validated on read, and
|
|
# the row outliving what it points at is normal rather than exceptional: a
|
|
# report is worth keeping after the chat that produced it has been deleted.
|
|
source_id: Mapped[str] = mapped_column(String(32), default="")
|
|
schedule_id: Mapped[str] = mapped_column(String(32), default="")
|
|
model_id: Mapped[str] = mapped_column(String(300), default="")
|
|
|
|
# NOT NULL with a scalar default so `migrations._add_column_sql` can backfill
|
|
# it if this column is ever added to a table that already has rows.
|
|
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
# Whether its arrival has already been announced. The dot can be shown for
|
|
# as long as it is unread; the toast and the browser notification must fire
|
|
# once. Without this the poll would announce the same report every ten
|
|
# seconds until somebody opened it, which is the shape of notification
|
|
# nobody leaves switched on. `Chat.unread_notified` exists for exactly this
|
|
# and this is the same pair.
|
|
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
# Why a run produced nothing worth reading. A scheduled report that failed
|
|
# is still a report -- one that silently did not appear is indistinguishable
|
|
# from a schedule that never fired.
|
|
error: Mapped[str] = mapped_column(Text, default="")
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<Report {self.title!r}>"
|