feat(emailgen): Ollama-driven fake email worker for IMAP/POP3 deckies
Second orchestrator worker (decnet emailgen) that drips persona-driven, threaded, multi-language fake emails into running mail deckies. Personas live on Topology.email_personas; topology-wide language_default falls through to any persona that doesn't pin its own. Em-dashes are suppressed at the prompt layer by default and only lifted for personas explicitly marked uses_llms_heavily — em-dashes are an LLM tell and a flat corpus of em-dashed mail is a giveaway. EML delivery writes into /var/spool/decnet-emails/<thread>/<msg>.eml on the mail decky via docker exec; wiring the IMAP/POP3 templates to read from that spool (replacing the hardcoded _BAIT_EMAILS) is the next step.
This commit is contained in:
@@ -58,6 +58,8 @@ from .health import (
|
||||
HealthResponse,
|
||||
)
|
||||
from .orchestrator import (
|
||||
OrchestratorEmail,
|
||||
OrchestratorEmailsResponse,
|
||||
OrchestratorEvent,
|
||||
OrchestratorEventsResponse,
|
||||
)
|
||||
@@ -193,6 +195,8 @@ __all__ = [
|
||||
"ComponentHealth",
|
||||
"HealthResponse",
|
||||
# orchestrator
|
||||
"OrchestratorEmail",
|
||||
"OrchestratorEmailsResponse",
|
||||
"OrchestratorEvent",
|
||||
"OrchestratorEventsResponse",
|
||||
# logs
|
||||
|
||||
@@ -60,3 +60,52 @@ class OrchestratorEventsResponse(BaseModel):
|
||||
limit: int
|
||||
offset: int
|
||||
data: List[dict[str, Any]]
|
||||
|
||||
|
||||
class OrchestratorEmail(SQLModel, table=True):
|
||||
"""One fake email generated by the ``decnet emailgen`` worker.
|
||||
|
||||
Sibling table to :class:`OrchestratorEvent` — kept disjoint because
|
||||
email rows carry domain-specific fields (subject, message_id,
|
||||
in_reply_to, language) that have no analogue in the SSH/file events
|
||||
and would otherwise bloat ``OrchestratorEvent.payload``.
|
||||
|
||||
The mail decky's UUID lives in ``mail_decky_uuid`` (the host serving
|
||||
the IMAP/POP3 mailbox). ``thread_id`` is a worker-side UUID used to
|
||||
chain replies; ``in_reply_to`` is the parent email's RFC 2822
|
||||
Message-ID header value (or ``None`` for thread roots).
|
||||
|
||||
``payload`` follows the same loose-JSON convention as
|
||||
:class:`OrchestratorEvent`: ``bytes``, ``generation_ms``, ``model``,
|
||||
``mannerisms_used``, etc. The worker can extend it without a
|
||||
migration.
|
||||
"""
|
||||
__tablename__ = "orchestrator_emails"
|
||||
__table_args__ = (
|
||||
Index("ix_orchestrator_emails_mail_ts", "mail_decky_uuid", "ts"),
|
||||
Index("ix_orchestrator_emails_thread", "thread_id"),
|
||||
)
|
||||
uuid: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||
ts: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
mail_decky_uuid: str = Field(index=True)
|
||||
thread_id: str = Field(index=True)
|
||||
message_id: str = Field(max_length=255)
|
||||
in_reply_to: Optional[str] = Field(default=None, max_length=255)
|
||||
sender_email: str = Field(max_length=255, index=True)
|
||||
recipient_email: str = Field(max_length=255, index=True)
|
||||
subject: str = Field(max_length=512)
|
||||
language: str = Field(max_length=8, default="en")
|
||||
eml_path: str = Field(max_length=1024)
|
||||
success: bool = Field(default=False, index=True)
|
||||
payload: str = Field(
|
||||
sa_column=Column("payload", Text, nullable=False, default="{}")
|
||||
)
|
||||
|
||||
|
||||
class OrchestratorEmailsResponse(BaseModel):
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
data: List[dict[str, Any]]
|
||||
|
||||
@@ -47,6 +47,18 @@ class Topology(SQLModel, table=True):
|
||||
# running. Drained by the mutator watch loop, which re-pushes via
|
||||
# AgentClient and clears the flag. NULL for unihost topologies.
|
||||
needs_resync: bool = Field(default=False, nullable=False)
|
||||
# JSON-serialised list of EmailPersona dicts consumed by the
|
||||
# ``decnet emailgen`` worker. Empty list = no fake mailbox owners
|
||||
# configured for this topology, the worker skips it.
|
||||
email_personas: str = Field(
|
||||
sa_column=Column(
|
||||
"email_personas", _BIG_TEXT, nullable=False, default="[]"
|
||||
)
|
||||
)
|
||||
# ISO 639-1 language code applied to any persona that doesn't override
|
||||
# ``language`` itself. English by default; ANTI's deployments default
|
||||
# to "es" by editing this column.
|
||||
language_default: str = Field(default="en", max_length=8)
|
||||
|
||||
|
||||
class LAN(SQLModel, table=True):
|
||||
|
||||
@@ -952,3 +952,57 @@ class BaseRepository(ABC):
|
||||
unbounded growth without paying the cost on every write.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def record_orchestrator_email(self, data: dict[str, Any]) -> str:
|
||||
"""Insert one orchestrator-generated email row, returning its uuid."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_orchestrator_emails(
|
||||
self,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
*,
|
||||
mail_decky_uuid: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
since_ts: Optional[Any] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Paginated orchestrator emails newest-first.
|
||||
|
||||
Optional filters narrow to a single mail decky or to one thread,
|
||||
used by the dashboard's mailbox-inspector view.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def count_orchestrator_emails(
|
||||
self,
|
||||
*,
|
||||
mail_decky_uuid: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Total orchestrator-email rows, optionally filtered by mail decky."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_orchestrator_email_threads(
|
||||
self,
|
||||
mail_decky_uuid: str,
|
||||
sender_email: str,
|
||||
recipient_email: str,
|
||||
*,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Open threads between *sender_email* and *recipient_email* on
|
||||
*mail_decky_uuid*, newest-first.
|
||||
|
||||
Used by the emailgen scheduler to decide whether to start a new
|
||||
thread or reply on an existing one. Each entry is one row's
|
||||
worth of dict — the worker only needs ``thread_id`` and the most
|
||||
recent ``message_id`` / ``subject`` to build the reply.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def prune_orchestrator_emails(self, per_decky_cap: int = 10000) -> int:
|
||||
"""Trim per-``mail_decky_uuid`` rows to a cap. Returns deleted count.
|
||||
|
||||
Mirrors :meth:`prune_orchestrator_events`; emailgen worker calls
|
||||
this on a periodic tick.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -51,6 +51,7 @@ from decnet.web.db.models import (
|
||||
TopologyEdge,
|
||||
TopologyStatusEvent,
|
||||
TopologyMutation,
|
||||
OrchestratorEmail,
|
||||
OrchestratorEvent,
|
||||
WebhookSubscription,
|
||||
)
|
||||
@@ -3003,3 +3004,113 @@ class SQLModelRepository(BaseRepository):
|
||||
deleted += res.rowcount or 0
|
||||
await session.commit()
|
||||
return deleted
|
||||
|
||||
# ---------------------------------------------------------- emailgen
|
||||
|
||||
async def record_orchestrator_email(self, data: dict[str, Any]) -> str:
|
||||
payload = data.get("payload")
|
||||
if isinstance(payload, (dict, list)):
|
||||
data = {**data, "payload": json.dumps(payload)}
|
||||
async with self._session() as session:
|
||||
row = OrchestratorEmail(**data)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row.uuid
|
||||
|
||||
async def list_orchestrator_emails(
|
||||
self,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
*,
|
||||
mail_decky_uuid: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
since_ts: Optional[datetime] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
async with self._session() as session:
|
||||
stmt = select(OrchestratorEmail)
|
||||
if mail_decky_uuid is not None:
|
||||
stmt = stmt.where(
|
||||
OrchestratorEmail.mail_decky_uuid == mail_decky_uuid
|
||||
)
|
||||
if thread_id is not None:
|
||||
stmt = stmt.where(OrchestratorEmail.thread_id == thread_id)
|
||||
if since_ts is not None:
|
||||
stmt = stmt.where(OrchestratorEmail.ts >= since_ts)
|
||||
stmt = (
|
||||
stmt.order_by(desc(OrchestratorEmail.ts))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return [r.model_dump(mode="json") for r in result.scalars().all()]
|
||||
|
||||
async def count_orchestrator_emails(
|
||||
self,
|
||||
*,
|
||||
mail_decky_uuid: Optional[str] = None,
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(OrchestratorEmail)
|
||||
if mail_decky_uuid is not None:
|
||||
stmt = stmt.where(OrchestratorEmail.mail_decky_uuid == mail_decky_uuid)
|
||||
async with self._session() as session:
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def list_orchestrator_email_threads(
|
||||
self,
|
||||
mail_decky_uuid: str,
|
||||
sender_email: str,
|
||||
recipient_email: str,
|
||||
*,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
# Most-recent row per (sender, recipient) pair under this mail decky.
|
||||
# The scheduler only needs the latest message_id/subject/thread_id to
|
||||
# construct a reply; older rows in the same thread aren't relevant
|
||||
# for the "do we reply or start fresh" decision.
|
||||
async with self._session() as session:
|
||||
stmt = (
|
||||
select(OrchestratorEmail)
|
||||
.where(
|
||||
OrchestratorEmail.mail_decky_uuid == mail_decky_uuid,
|
||||
or_(
|
||||
(OrchestratorEmail.sender_email == sender_email)
|
||||
& (OrchestratorEmail.recipient_email == recipient_email),
|
||||
(OrchestratorEmail.sender_email == recipient_email)
|
||||
& (OrchestratorEmail.recipient_email == sender_email),
|
||||
),
|
||||
OrchestratorEmail.success.is_(True),
|
||||
)
|
||||
.order_by(desc(OrchestratorEmail.ts))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return [r.model_dump(mode="json") for r in result.scalars().all()]
|
||||
|
||||
async def prune_orchestrator_emails(self, per_decky_cap: int = 10000) -> int:
|
||||
"""Trim per-mail-decky rows to *per_decky_cap*, oldest-first."""
|
||||
deleted = 0
|
||||
async with self._session() as session:
|
||||
decky_rows = await session.execute(
|
||||
select(OrchestratorEmail.mail_decky_uuid).distinct()
|
||||
)
|
||||
for (mail_uuid,) in decky_rows.all():
|
||||
keep = await session.execute(
|
||||
select(OrchestratorEmail.uuid)
|
||||
.where(OrchestratorEmail.mail_decky_uuid == mail_uuid)
|
||||
.order_by(desc(OrchestratorEmail.ts))
|
||||
.limit(per_decky_cap)
|
||||
)
|
||||
keep_uuids = [u for (u,) in keep.all()]
|
||||
if not keep_uuids:
|
||||
continue
|
||||
from sqlalchemy import delete as _delete
|
||||
stmt = _delete(OrchestratorEmail).where(
|
||||
OrchestratorEmail.mail_decky_uuid == mail_uuid,
|
||||
OrchestratorEmail.uuid.notin_(keep_uuids),
|
||||
)
|
||||
res = await session.execute(stmt)
|
||||
deleted += res.rowcount or 0
|
||||
await session.commit()
|
||||
return deleted
|
||||
|
||||
Reference in New Issue
Block a user