Files
roboco/roboco/services/messaging.py
T

1913 lines
69 KiB
Python
Raw Normal View History

2025-12-13 00:00:52 +01:00
"""
Messaging Service
Comprehensive service for managing communication:
- Channels (top-level containers)
- Groups (role-based containers within channels)
- Sessions (message boundaries)
- Messages (individual communications)
Implements the communication model.
2025-12-13 00:00:52 +01:00
"""
2025-12-30 02:35:58 +01:00
import asyncio
2026-04-21 17:48:45 +02:00
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
2025-12-30 02:35:58 +01:00
from typing import Any, ClassVar, cast
2025-12-13 00:00:52 +01:00
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
2025-12-13 00:00:52 +01:00
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
2025-12-13 00:00:52 +01:00
from roboco.config import settings
2025-12-13 00:00:52 +01:00
from roboco.db.tables import (
ChannelTable,
GroupTable,
MessageTable,
NotificationTable,
2025-12-13 00:00:52 +01:00
SessionTable,
SessionTaskTable,
2025-12-13 00:00:52 +01:00
)
from roboco.enforcement import validate_channel_access
from roboco.events import Event, EventType, get_event_bus
2025-12-13 00:00:52 +01:00
from roboco.models.base import (
MessageType,
NotificationPriority,
NotificationType,
2025-12-13 00:00:52 +01:00
SessionStatus,
)
from roboco.models.messaging import (
ChannelCreateRequest,
GroupCreateRequest,
MessageCreateRequest,
SessionCreateRequest,
)
from roboco.models.session import (
SessionForTasksCreate,
SessionTaskRelationshipType,
)
from roboco.services.base import BaseService, ConflictError, NotFoundError
2025-12-31 21:42:58 +01:00
from roboco.utils.converters import require_uuid, to_python_uuid
2025-12-13 00:00:52 +01:00
2026-04-21 17:48:45 +02:00
@dataclass(frozen=True)
class ApiSessionCreate:
"""Service-side view of the API's session-create request.
Keeps api/schemas types from leaking into the service layer; routes
translate their pydantic model into this dataclass at the boundary.
"""
group_id: UUID
max_time_window_minutes: int | None
max_message_count: int | None
max_content_length: int | None
timeout_seconds: int | None
def _minutes_to_timedelta(value: int | None) -> timedelta | None:
return timedelta(minutes=value) if value is not None else None
2025-12-13 00:00:52 +01:00
# =============================================================================
# MESSAGING SERVICE
# =============================================================================
class MessagingService(BaseService):
2025-12-13 00:00:52 +01:00
"""
Service for managing all messaging operations.
Provides:
- Channel CRUD with access control
- Group management within channels
- Session lifecycle with automatic boundaries
- Message CRUD with edit history
Usage:
service = MessagingService(db_session)
# Create channel
channel = await service.create_channel(ChannelCreateRequest(...))
# Send message (handles session automatically)
message = await service.send_message(MessageCreateRequest(...))
"""
service_name: ClassVar[str] = "messaging"
2025-12-30 02:35:58 +01:00
_background_tasks: ClassVar[set[asyncio.Task[Any]]] = set()
2025-12-13 00:00:52 +01:00
# =========================================================================
# CHANNEL OPERATIONS (TASK-013)
# =========================================================================
async def create_channel(self, req: ChannelCreateRequest) -> ChannelTable:
"""
Create a new channel.
Args:
req: Channel creation request
Returns:
Created channel
Raises:
ValueError: If slug already exists
"""
# Check slug uniqueness
existing = await self.session.execute(
select(ChannelTable).where(ChannelTable.slug == req.slug)
)
if existing.scalar_one_or_none():
raise ValueError(f"Channel with slug '{req.slug}' already exists")
channel = ChannelTable(
name=req.name,
slug=req.slug,
type=req.channel_type,
description=req.description,
members=list(req.members) if req.members else [],
writers=list(req.writers) if req.writers else [],
silent_observers=list(req.silent_observers) if req.silent_observers else [],
is_private=req.is_private,
)
self.session.add(channel)
await self.session.flush()
self.log.info(
"Channel created",
channel_id=str(channel.id),
slug=req.slug,
type=req.channel_type.value,
)
return channel
async def get_channel(self, channel_id: UUID) -> ChannelTable | None:
"""Get a channel by ID."""
result = await self.session.execute(
select(ChannelTable).where(ChannelTable.id == channel_id)
)
return result.scalar_one_or_none()
2026-04-21 17:48:45 +02:00
async def list_channels_paginated(
self,
*,
accessible_slugs: list[str],
include_archived: bool,
page: int,
page_size: int,
) -> tuple[list[ChannelTable], int]:
"""Return (channels, total) for channels filtered to `accessible_slugs`.
Two queries so the route can expose an accurate total even when
pagination clips the window. Route passes the slug set it computed
from `PermissionService`; the DB work stays here.
"""
base = select(ChannelTable).where(ChannelTable.slug.in_(accessible_slugs))
if not include_archived:
base = base.where(ChannelTable.is_archived.is_(False))
from sqlalchemy import func
count_query = select(func.count(ChannelTable.id)).where(
ChannelTable.slug.in_(accessible_slugs)
)
if not include_archived:
count_query = count_query.where(ChannelTable.is_archived.is_(False))
count_result = await self.session.execute(count_query)
total = count_result.scalar() or 0
offset = (page - 1) * page_size
base = base.order_by(ChannelTable.name).offset(offset).limit(page_size)
result = await self.session.execute(base)
return list(result.scalars().all()), total
async def get_channel_with_groups_or_raise(self, channel_id: UUID) -> ChannelTable:
"""Return a channel with its groups eager-loaded; raise if missing."""
result = await self.session.execute(
select(ChannelTable)
.where(ChannelTable.id == channel_id)
.options(selectinload(ChannelTable.groups))
)
channel = result.scalar_one_or_none()
if not channel:
raise NotFoundError(resource_type="Channel", resource_id=str(channel_id))
return channel
async def get_channel_or_raise(self, channel_id: UUID) -> ChannelTable:
"""Return a channel by id or raise NotFoundError."""
channel = await self.get_channel(channel_id)
if not channel:
raise NotFoundError(resource_type="Channel", resource_id=str(channel_id))
return channel
async def update_channel_fields(
self,
*,
channel_id: UUID,
fields: dict[str, Any],
) -> ChannelTable:
"""Apply a subset of fields to a channel.
Keeps the setattr loop out of the route module. `fields` is a plain
dict of column → new value; unknown keys are ignored to keep the
service tolerant of incidental extras on the API side.
"""
channel = await self.get_channel_or_raise(channel_id)
# Mirrors the pre-refactor CHANNEL_UPDATE_FIELDS allowlist. Membership
# (members/writers/silent_observers) is mutated via the dedicated
# add/remove endpoints, not via PATCH.
allowed = {
"name",
"description",
"topic",
"is_archived",
"allow_threads",
"allow_reactions",
"message_retention_days",
"max_message_length",
}
for key, value in fields.items():
if value is None or key not in allowed:
continue
setattr(channel, key, value)
await self.session.flush()
return channel
async def add_channel_member_or_raise(
self,
*,
channel_id: UUID,
member_id: UUID,
can_write: bool,
) -> None:
"""Add a member (and optionally writer) to a channel; 404 if missing."""
channel = await self.get_channel_or_raise(channel_id)
if member_id not in channel.members:
channel.members = [*channel.members, member_id]
if can_write and member_id not in channel.writers:
channel.writers = [*channel.writers, member_id]
await self.session.flush()
async def remove_channel_member_or_raise(
self,
*,
channel_id: UUID,
member_id: UUID,
) -> None:
"""Remove a member (and any writer entry) from a channel; 404 if missing."""
channel = await self.get_channel_or_raise(channel_id)
channel.members = [m for m in channel.members if m != member_id]
channel.writers = [w for w in channel.writers if w != member_id]
await self.session.flush()
2025-12-13 00:00:52 +01:00
async def get_channel_by_slug(self, slug: str) -> ChannelTable | None:
"""Get a channel by slug.
Strips a leading ``#`` so agents passing channel names with the
Slack-style ``#`` prefix (e.g. ``#main-pm-board``) resolve to the
same row stored without it.
"""
normalized = slug.lstrip("#") if slug else slug
2025-12-13 00:00:52 +01:00
result = await self.session.execute(
select(ChannelTable).where(ChannelTable.slug == normalized)
2025-12-13 00:00:52 +01:00
)
return result.scalar_one_or_none()
async def get_or_create_channel_by_slug(self, slug: str) -> ChannelTable | None:
"""Get a channel by slug, auto-creating from config if needed.
If the channel doesn't exist in the database but is defined in
DEFAULT_CHANNELS, it will be automatically created.
This allows the system to work without requiring explicit database
seeding while still validating that only configured channels are used.
Args:
slug: Channel slug to look up
Returns:
Channel if found or created, None if not a valid channel
"""
normalized = slug.lstrip("#") if slug else slug
# First try database
channel = await self.get_channel_by_slug(normalized)
if channel:
return channel
# Not in DB - check if it's a valid channel from config
from roboco.models.base import ChannelType
from roboco.seeds import DEFAULT_CHANNELS
channel_data = next(
(c for c in DEFAULT_CHANNELS if c["slug"] == normalized),
None,
)
if not channel_data:
return None
# Auto-create from config. Two concurrent callers (e.g. two Main-PM
# group-create requests) can both miss the lookup and both try to
# INSERT the same seed channel; ``channels.slug`` is UNIQUE, so the
# loser's flush raises ``IntegrityError``. Isolate the insert in a
# savepoint so a lost race rolls back only this insert (not the
# caller's pending work), then re-fetch the winner's row. A conflict
# that produced no row on re-fetch is a real failure — re-raise it
# rather than masking it as a silent None.
channel = ChannelTable(
name=channel_data["name"],
slug=channel_data["slug"],
type=ChannelType(channel_data["channel_type"]),
description=channel_data.get("description", ""),
)
try:
async with self.session.begin_nested():
self.session.add(channel)
await self.session.flush()
except IntegrityError:
existing = await self.get_channel_by_slug(normalized)
if existing is not None:
self.log.info(
"Channel race lost; reusing existing channel",
slug=slug,
type=channel_data["channel_type"],
)
return existing
raise
self.log.info(
"Channel auto-created from config",
slug=slug,
type=channel_data["channel_type"],
)
return channel
2025-12-13 00:00:52 +01:00
# =========================================================================
# GROUP OPERATIONS
# =========================================================================
async def create_group(self, req: GroupCreateRequest) -> GroupTable:
"""Create a group within a channel."""
# Verify channel exists
channel = await self.get_channel(req.channel_id)
if not channel:
raise ValueError(f"Channel {req.channel_id} not found")
group = GroupTable(
name=req.name,
channel_id=req.channel_id,
allowed_roles=list(req.allowed_roles) if req.allowed_roles else [],
hierarchy_level=req.hierarchy_level,
members=list(req.members) if req.members else [],
)
self.session.add(group)
# Update channel group count
channel.group_count += 1
await self.session.flush()
self.log.info(
"Group created",
group_id=str(group.id),
channel_id=str(req.channel_id),
name=req.name,
)
return group
async def get_group(self, group_id: UUID) -> GroupTable | None:
"""Get a group by ID."""
result = await self.session.execute(
select(GroupTable).where(GroupTable.id == group_id)
)
return result.scalar_one_or_none()
async def list_groups_in_channel(self, channel_id: UUID) -> list[GroupTable]:
"""List all groups in a channel."""
result = await self.session.execute(
select(GroupTable)
.where(GroupTable.channel_id == channel_id)
.order_by(GroupTable.hierarchy_level, GroupTable.name)
)
return list(result.scalars().all())
# =========================================================================
# SESSION OPERATIONS (TASK-015)
# =========================================================================
@staticmethod
def _resolve_session_timeout(requested: int | None) -> int:
"""Resolve a session's idle-timeout, defaulting to the configurable value.
An unset timeout previously fell through to the column default of 300s,
which is shorter than a human conversation pause — the session was swept
between messages and a new one opened on the next post. Resolve it to
``session_idle_timeout_seconds`` so human-paced chats stay on one session.
"""
if requested is not None:
return requested
return settings.session_idle_timeout_seconds
2025-12-13 00:00:52 +01:00
async def create_session(self, req: SessionCreateRequest) -> SessionTable:
"""
Create a new session in a group.
Sets the session as the group's active session.
"""
# Verify group exists
group = await self.get_group(req.group_id)
if not group:
raise ValueError(f"Group {req.group_id} not found")
# A group has ONE live session that all participants post into. Reuse it
# instead of closing it and opening a new one on every call — the
# close-and-recreate pattern churned a single conversation across many
# sessions (the smoke run showed ~one session per message, and the CEO
# could not hold a conversation). Only open a fresh session when none is
# currently active (the prior one closed via timeout / boundary / merge).
2025-12-13 00:00:52 +01:00
if group.active_session_id:
existing = await self.get_session(cast("UUID", group.active_session_id))
if existing is not None and existing.status == SessionStatus.ACTIVE:
return existing
2025-12-13 00:00:52 +01:00
session = SessionTable(
group_id=req.group_id,
max_message_count=req.max_message_count,
max_content_length=req.max_content_length,
timeout_seconds=self._resolve_session_timeout(req.timeout_seconds),
2025-12-13 00:00:52 +01:00
status=SessionStatus.ACTIVE,
scope=req.scope,
2025-12-13 00:00:52 +01:00
)
self.session.add(session)
# Flush so session.id (a flush-time uuid4 default) is materialized BEFORE we
# link it on the group. active_session_id is a plain scalar FK with no
# relationship, so SQLAlchemy cannot defer-populate it — assigning session.id
# while it is still None persists active_session_id as NULL, and the group then
# opens a brand-new session on every post instead of reusing this one.
await self.session.flush()
2025-12-13 00:00:52 +01:00
# Update group
group.active_session_id = session.id
group.total_sessions += 1
group.last_activity = datetime.now(UTC)
await self.session.flush()
# Publish event
try:
bus = get_event_bus()
2026-04-19 16:13:42 +02:00
if bus.is_connected():
2025-12-13 00:00:52 +01:00
await bus.publish(
Event(
type=EventType.SESSION_CREATED,
data={
"session_id": str(session.id),
"group_id": str(req.group_id),
},
)
)
except Exception as e:
self.log.warning("Failed to publish session event", error=str(e))
self.log.info(
"Session created",
session_id=str(session.id),
group_id=str(req.group_id),
)
return session
async def get_session(self, session_id: UUID) -> SessionTable | None:
"""Get a session by ID."""
result = await self.session.execute(
select(SessionTable).where(SessionTable.id == session_id)
)
return result.scalar_one_or_none()
2026-04-19 16:13:42 +02:00
async def sweep_timed_out_sessions(self) -> int:
"""Close sessions whose inactivity exceeds `timeout_seconds`.
`SessionTable.timeout_seconds` and `SessionTable.max_time_window` were
stored but never enforced; sessions stayed ACTIVE indefinitely.
The orchestrator's session-sweeper loop calls this periodically.
Returns the number of sessions closed.
"""
now = datetime.now(UTC)
result = await self.session.execute(
select(SessionTable).where(SessionTable.status == SessionStatus.ACTIVE)
)
active_sessions = list(result.scalars().all())
closed = 0
for session in active_sessions:
last_active = session.last_activity_at or session.started_at
idle = (now - last_active).total_seconds()
timeout_exceeded = (
session.timeout_seconds is not None and idle >= session.timeout_seconds
2026-04-19 16:13:42 +02:00
)
window_exceeded = (
session.max_time_window is not None
and (now - session.started_at) >= session.max_time_window
)
if not (timeout_exceeded or window_exceeded):
continue
reason = "Inactivity timeout" if timeout_exceeded else "Max time window"
await self.close_session(cast("UUID", session.id), reason)
closed += 1
if closed:
self.log.info("Session sweeper closed sessions", count=closed)
return closed
2025-12-13 00:00:52 +01:00
async def close_session(
self,
session_id: UUID,
reason: str = "Manual close",
) -> SessionTable | None:
"""Close a session."""
session = await self.get_session(session_id)
if not session:
return None
if session.status != SessionStatus.ACTIVE:
return session # Already closed
session.status = SessionStatus.CLOSED
session.closed_at = datetime.now(UTC)
# Clear group's active session
2025-12-13 05:50:34 +01:00
group = await self.get_group(cast("UUID", session.group_id))
2025-12-13 00:00:52 +01:00
if group and group.active_session_id == session_id:
group.active_session_id = None
await self.session.flush()
# Publish event
try:
bus = get_event_bus()
2026-04-19 16:13:42 +02:00
if bus.is_connected():
2025-12-13 00:00:52 +01:00
await bus.publish(
Event(
type=EventType.SESSION_CLOSED,
data={
"session_id": str(session_id),
"reason": reason,
},
)
)
except Exception as e:
self.log.warning("Failed to publish session event", error=str(e))
self.log.info(
"Session closed",
session_id=str(session_id),
reason=reason,
)
return session
2026-04-21 17:48:45 +02:00
async def list_group_sessions_for_agent(
self,
*,
group_id: UUID,
agent_id: UUID,
status_filter: SessionStatus | None,
limit: int,
) -> list[SessionTable]:
"""Return sessions visible to `agent_id` within `group_id`.
Auth: agent must be a group-channel member/observer, or hold a
privileged role. Raises NotFoundError if the group doesn't exist
and PermissionError if the agent can't read it. Task-links are
eager-loaded so the route can render them without extra queries.
"""
from roboco.services.permissions import has_privileged_access
group_result = await self.session.execute(
select(GroupTable)
.where(GroupTable.id == group_id)
.options(selectinload(GroupTable.channel))
)
group = group_result.scalar_one_or_none()
if not group:
raise NotFoundError(resource_type="Group", resource_id=str(group_id))
channel = group.channel
allowed = (
agent_id in channel.members
or agent_id in channel.silent_observers
or await has_privileged_access(self.session, agent_id)
)
if not allowed:
raise PermissionError("You don't have access to this group")
query = (
select(SessionTable)
.where(SessionTable.group_id == group_id)
.options(
selectinload(SessionTable.task_links).selectinload(
SessionTaskTable.task
)
)
)
if status_filter is not None:
query = query.where(SessionTable.status == status_filter)
query = query.order_by(SessionTable.started_at.desc()).limit(limit)
result = await self.session.execute(query)
return list(result.scalars().all())
async def create_session_with_access_check(
self,
*,
agent_id: UUID,
request: "ApiSessionCreate",
) -> SessionTable:
"""Create a session after verifying the agent may write to the group's channel.
Mirrors the old route-inline logic: fetches the group + channel,
rejects if the agent isn't in `channel.writers` (unless privileged),
closes any existing ACTIVE session, then creates the new one. Uses
primitive fields (wrapped in `ApiSessionCreate`) so api/schemas types
never leak into service signatures.
"""
from roboco.services.permissions import has_privileged_access
group_result = await self.session.execute(
select(GroupTable)
.where(GroupTable.id == request.group_id)
.options(selectinload(GroupTable.channel))
)
group = group_result.scalar_one_or_none()
if not group:
raise NotFoundError(
resource_type="Group", resource_id=str(request.group_id)
)
channel = group.channel
may_write = agent_id in channel.writers or await has_privileged_access(
self.session, agent_id
)
if not may_write:
raise PermissionError("You don't have write access to this group")
active_result = await self.session.execute(
select(SessionTable).where(
SessionTable.group_id == request.group_id,
SessionTable.status == SessionStatus.ACTIVE,
)
)
active = active_result.scalar_one_or_none()
if active is not None:
# Reuse the group's live session (see create_session). The CEO and
# agents post into one session per group, not a fresh one per open —
# closing + recreating here is what fragmented one conversation
# across many sessions.
return active
2026-04-21 17:48:45 +02:00
new_session = SessionTable(
group_id=request.group_id,
max_time_window=(_minutes_to_timedelta(request.max_time_window_minutes)),
max_message_count=request.max_message_count,
max_content_length=request.max_content_length,
timeout_seconds=self._resolve_session_timeout(request.timeout_seconds),
2026-04-21 17:48:45 +02:00
status=SessionStatus.ACTIVE,
)
self.session.add(new_session)
# Flush so new_session.id is materialized before we link it on the group;
# assigning it pre-flush persists active_session_id as NULL (see
# create_session).
await self.session.flush()
2026-04-21 17:48:45 +02:00
group.active_session_id = new_session.id
group.total_sessions += 1
await self.session.flush()
await self._inject_proactive_context(
session_id=cast("UUID", new_session.id), agent_id=agent_id
)
2026-04-21 17:48:45 +02:00
return new_session
async def _inject_proactive_context(
self, *, session_id: UUID, agent_id: UUID
) -> None:
"""Fire-and-forget proactive-context injection for a new session.
Failure is swallowed: context injection is a best-effort enhancement,
not a hard requirement of session creation.
"""
from roboco.services.proactive import get_proactive_service
try:
proactive = await get_proactive_service()
context = await proactive.get_context_for_session(
session_id=session_id, agent_id=agent_id
)
if context and not context.is_empty():
self.log.info(
"Injected session proactive context",
session_id=str(session_id),
agent_id=str(agent_id),
)
except Exception as e:
self.log.warning(
"Failed to inject session context",
session_id=str(session_id),
error=str(e),
)
2026-04-21 17:48:45 +02:00
async def close_session_or_raise(self, session_id: UUID) -> SessionTable:
"""Close an active session; raise if missing or already closed.
Routes call this instead of reading/mutating session state directly.
"""
session_row = await self.get_session(session_id)
if not session_row:
raise NotFoundError(resource_type="Session", resource_id=str(session_id))
if session_row.status != SessionStatus.ACTIVE:
raise ValueError("Session is not active")
session_row.status = SessionStatus.CLOSED
session_row.closed_at = datetime.now(UTC)
group = await self.get_group(cast("UUID", session_row.group_id))
if group and group.active_session_id == session_id:
group.active_session_id = None
await self.session.flush()
return session_row
async def get_session_or_raise(self, session_id: UUID) -> SessionTable:
"""Return a session or raise NotFoundError.
Keeps `None`-handling out of route modules.
"""
session_row = await self.get_session(session_id)
if not session_row:
raise NotFoundError(resource_type="Session", resource_id=str(session_id))
return session_row
async def get_channel_by_slug_or_raise(self, slug: str) -> ChannelTable:
"""Return a channel by slug or raise NotFoundError."""
channel = await self.get_channel_by_slug(slug)
if not channel:
raise NotFoundError(resource_type="Channel", resource_id=slug)
return channel
2025-12-13 00:00:52 +01:00
async def get_or_create_active_session(
self,
group_id: UUID,
) -> SessionTable:
"""Get the active session for a group, or create one if none exists."""
group = await self.get_group(group_id)
if not group:
raise ValueError(f"Group {group_id} not found")
# Return active session if exists
if group.active_session_id:
2025-12-13 05:50:34 +01:00
session = await self.get_session(cast("UUID", group.active_session_id))
2025-12-13 00:00:52 +01:00
if session and session.status == SessionStatus.ACTIVE:
return session
# Create new session
return await self.create_session(SessionCreateRequest(group_id=group_id))
# =========================================================================
# SESSION-TASK LINKING OPERATIONS
# =========================================================================
async def link_session_to_task(
self,
session_id: UUID,
task_id: UUID,
added_by: UUID,
is_primary: bool = False,
relationship_type: SessionTaskRelationshipType = (
SessionTaskRelationshipType.DISCUSSION
),
) -> SessionTaskTable:
"""
Link a session to a task (idempotent).
Args:
session_id: Session to link
task_id: Task to link
added_by: PM who created this link
is_primary: Mark as primary discussion session for this task
relationship_type: Type of relationship
Returns:
Created link, or the existing link if (session_id, task_id) is
already linked. Re-linking the same pair is a no-op — upstream
callers sometimes re-issue the link after a create_session_for_
_tasks call, and that should be a cheap success, not a 409.
Raises:
NotFoundError: If session not found
ConflictError: If `is_primary=True` but the task already has a
different primary session.
"""
# Verify session exists
session = await self.get_session(session_id)
if not session:
raise NotFoundError(f"Session {session_id} not found")
# Idempotent duplicate handling: if this exact (session, task) pair
# is already linked, return the existing row. Re-creating sessions
# in agent flows (create_session_for_tasks → ancestor reuse → then
# an explicit link call) is common; the 409 it used to produce was
# pure noise.
existing = await self.session.execute(
select(SessionTaskTable).where(
SessionTaskTable.session_id == session_id,
SessionTaskTable.task_id == task_id,
)
)
existing_link = existing.scalar_one_or_none()
if existing_link:
return existing_link
# Primary constraint holds across sessions: if the task already has
# a primary on a *different* session, that's a real conflict —
# promoting two distinct sessions to primary for the same task
# would break the session-of-record invariant.
if is_primary:
existing_primary = await self.session.execute(
select(SessionTaskTable).where(
SessionTaskTable.task_id == task_id,
SessionTaskTable.is_primary.is_(True),
)
)
if existing_primary.scalar_one_or_none():
raise ConflictError(f"Task {task_id} already has a primary session")
# Handle both enum and string (RobocoBase uses use_enum_values=True)
rel_value = getattr(relationship_type, "value", relationship_type)
link = SessionTaskTable(
session_id=session_id,
task_id=task_id,
is_primary=is_primary,
relationship_type=rel_value,
added_by=added_by,
)
self.session.add(link)
await self.session.flush()
self.log.info(
"Session linked to task",
session_id=str(session_id),
task_id=str(task_id),
is_primary=is_primary,
relationship_type=rel_value,
)
return link
async def unlink_session_from_task(
self,
session_id: UUID,
task_id: UUID,
) -> bool:
"""
Remove a session-task link.
Args:
session_id: Session to unlink
task_id: Task to unlink
Returns:
True if link was removed, False if not found
"""
result = await self.session.execute(
select(SessionTaskTable).where(
SessionTaskTable.session_id == session_id,
SessionTaskTable.task_id == task_id,
)
)
link = result.scalar_one_or_none()
if not link:
return False
await self.session.delete(link)
await self.session.flush()
self.log.info(
"Session unlinked from task",
session_id=str(session_id),
task_id=str(task_id),
)
return True
async def get_sessions_for_task(
self,
task_id: UUID,
relationship_type: SessionTaskRelationshipType | None = None,
) -> list[SessionTaskTable]:
"""
Get all sessions linked to a task.
Args:
task_id: Task to get sessions for
relationship_type: Filter by relationship type
Returns:
List of session-task links (with session→group→channel loaded)
"""
query = (
select(SessionTaskTable)
.where(SessionTaskTable.task_id == task_id)
.options(
joinedload(SessionTaskTable.session)
.joinedload(SessionTable.group)
.joinedload(GroupTable.channel)
)
)
if relationship_type:
rel_value = getattr(relationship_type, "value", relationship_type)
query = query.where(SessionTaskTable.relationship_type == rel_value)
query = query.order_by(SessionTaskTable.added_at.desc())
result = await self.session.execute(query)
return list(result.scalars().unique().all())
async def get_primary_session_for_task(
self,
task_id: UUID,
) -> SessionTaskTable | None:
"""
Get the primary session for a task.
Args:
task_id: Task to get primary session for
Returns:
Primary session-task link, or None if no primary session
"""
result = await self.session.execute(
select(SessionTaskTable).where(
SessionTaskTable.task_id == task_id,
SessionTaskTable.is_primary.is_(True),
)
)
return result.scalar_one_or_none()
async def get_tasks_for_session(
self,
session_id: UUID,
) -> list[SessionTaskTable]:
"""
Get all tasks linked to a session.
Args:
session_id: Session to get tasks for
Returns:
List of session-task links (with task relationship loaded)
"""
result = await self.session.execute(
select(SessionTaskTable)
.where(SessionTaskTable.session_id == session_id)
.order_by(SessionTaskTable.added_at.desc())
)
return list(result.scalars().all())
async def propagate_sessions_to_subtask(
self,
parent_task_id: UUID,
subtask_id: UUID,
added_by: UUID,
) -> list[SessionTaskTable]:
"""Link every session attached to ``parent_task_id`` onto ``subtask_id``.
The pre-gateway flow created a session with the whole task tree at
once, so subtasks were visible in the parent's group chat the moment
they existed. The gateway creates subtasks one at a time via
``delegate()``, so this step re-attaches every existing parent
session link to the new child.
``link_session_to_task`` is idempotent on duplicate (session, task)
pairs, so re-runs are no-ops. Primary status is NOT propagated —
each subtask owns its own primary slot, and a primary on the
parent should not auto-claim the subtask's primary too.
"""
parent_links = await self.get_sessions_for_task(parent_task_id)
propagated: list[SessionTaskTable] = []
for parent_link in parent_links:
session_id = cast("UUID", parent_link.session_id)
rel_raw = parent_link.relationship_type
try:
rel = SessionTaskRelationshipType(rel_raw)
except (TypeError, ValueError):
rel = SessionTaskRelationshipType.DISCUSSION
link = await self.link_session_to_task(
session_id=session_id,
task_id=subtask_id,
added_by=added_by,
is_primary=False,
relationship_type=rel,
)
propagated.append(link)
return propagated
async def _walk_task_ancestors(self, task_id: UUID) -> list["TaskTable"]: # type: ignore[name-defined] # noqa: F821
"""Return [parent, grandparent, ..., root] for a task, empty if none.
Walks the `parent_task_id` chain without returning the task itself.
Cycle-safe via a visited set (would only hit on data corruption).
"""
from roboco.db.tables import TaskTable
ancestors: list[TaskTable] = []
seen: set[UUID] = {task_id}
current_id: UUID | None = task_id
while current_id:
result = await self.session.execute(
select(TaskTable).where(TaskTable.id == current_id)
)
task = result.scalar_one_or_none()
if not task or not task.parent_task_id:
break
parent_id = to_python_uuid(task.parent_task_id)
if parent_id is None or parent_id in seen:
break
seen.add(parent_id)
parent_result = await self.session.execute(
select(TaskTable).where(TaskTable.id == parent_id)
)
parent = parent_result.scalar_one_or_none()
if not parent:
break
ancestors.append(parent)
current_id = to_python_uuid(parent.parent_task_id)
return ancestors
async def _primary_session_link_for_task(
self, task_id: UUID
) -> SessionTaskTable | None:
"""Fetch the task's primary session link with group+session eager-loaded."""
result = await self.session.execute(
select(SessionTaskTable)
.where(
SessionTaskTable.task_id == task_id,
SessionTaskTable.is_primary.is_(True),
)
.options(
selectinload(SessionTaskTable.session).selectinload(SessionTable.group)
)
)
return result.scalar_one_or_none()
async def _resolve_group_from_parent_tasks(
self,
task_ids: list[UUID],
) -> GroupTable | None:
"""Resolve group by walking ancestors' primary sessions.
Groups belong to the root-task initiative: same root → same group on
a given channel. Walks the full ancestry so an ancestor at any depth
with a primary session lets subtasks reuse that group.
"""
for task_id in task_ids:
for ancestor in await self._walk_task_ancestors(task_id):
link = await self._primary_session_link_for_task(
cast("UUID", ancestor.id)
)
if link and link.session and link.session.group:
self.log.info(
"Inherited group from ancestor task's session",
task_id=str(task_id),
ancestor_task_id=str(ancestor.id),
group_id=str(link.session.group.id),
group_name=link.session.group.name,
)
return link.session.group
return None
async def _find_ancestor_session_on_channel(
self,
task_ids: list[UUID],
channel_id: UUID,
) -> SessionTable | None:
"""Find an active session on ``channel_id`` owned by any ancestor.
Drives the "same task tree → same group chat" rule: if any ancestor
of the tasks already has an active primary session in the requested
channel, new subtasks link to it instead of opening a new session.
"""
for task_id in task_ids:
for ancestor in await self._walk_task_ancestors(task_id):
link = await self._primary_session_link_for_task(
cast("UUID", ancestor.id)
)
if (
link
and link.session
and link.session.group
and link.session.group.channel_id == channel_id
and link.session.status == SessionStatus.ACTIVE
):
return link.session
return None
2026-04-21 17:48:45 +02:00
async def _resolve_group_for_session(
self,
req: SessionForTasksCreate,
channel: ChannelTable,
) -> GroupTable:
"""Resolve the group for a new session: explicit > inherited > first."""
if req.group_id:
group_result = await self.session.execute(
select(GroupTable).where(GroupTable.id == req.group_id)
)
group = group_result.scalar_one_or_none()
if not group:
raise NotFoundError(f"Group '{req.group_id}' not found")
return group
group = await self._resolve_group_from_parent_tasks(req.task_ids)
if group:
return group
groups = await self.list_groups_in_channel(cast("UUID", channel.id))
if not groups:
# Auto-create a default group so sessions don't fail
default_group = GroupTable(
name="General",
channel_id=cast("UUID", channel.id),
hierarchy_level=1,
allowed_roles=[],
members=[],
2026-04-21 17:48:45 +02:00
)
self.session.add(default_group)
channel.group_count += 1
await self.session.flush()
self.log.info(
"Auto-created default group for channel",
channel_slug=req.channel_slug,
group_id=str(default_group.id),
)
return default_group
2026-04-21 17:48:45 +02:00
fallback_group = groups[0]
self.log.warning(
"Session created without explicit group, using first group",
channel_slug=req.channel_slug,
group_name=fallback_group.name,
task_ids=[str(t) for t in req.task_ids],
)
return fallback_group
@staticmethod
def _build_session_request(
req: SessionForTasksCreate, group: GroupTable
) -> SessionCreateRequest:
"""Build a SessionCreateRequest from tasks-create input."""
return SessionCreateRequest(
group_id=cast("UUID", group.id),
max_message_count=(req.config.max_message_count if req.config else None),
max_content_length=(req.config.max_content_length if req.config else None),
timeout_seconds=(
req.config.timeout_seconds
if req.config and req.config.timeout_seconds is not None
else settings.session_idle_timeout_seconds
),
2026-04-21 17:48:45 +02:00
scope=req.scope,
)
async def _link_tasks_to_session(
self,
session: SessionTable,
req: SessionForTasksCreate,
pm_agent_id: UUID,
) -> list[SessionTaskTable]:
"""Attach each task in ``req`` to the newly-created session."""
links: list[SessionTaskTable] = []
for i, task_id in enumerate(req.task_ids):
link = await self.link_session_to_task(
session_id=cast("UUID", session.id),
task_id=task_id,
added_by=pm_agent_id,
is_primary=i == 0,
relationship_type=req.relationship_type,
)
links.append(link)
return links
async def _link_tasks_to_existing_session(
self,
session: SessionTable,
req: SessionForTasksCreate,
pm_agent_id: UUID,
) -> list[SessionTaskTable]:
"""Link tasks to an existing session.
`link_session_to_task` is itself idempotent on duplicate
(session, task) pairs — re-links return the existing row — so
we always get a link back regardless of prior state.
"""
links: list[SessionTaskTable] = []
session_id = cast("UUID", session.id)
for task_id in req.task_ids:
link = await self.link_session_to_task(
session_id=session_id,
task_id=task_id,
added_by=pm_agent_id,
is_primary=False,
relationship_type=req.relationship_type,
)
links.append(link)
return links
async def create_session_for_tasks(
self,
req: SessionForTasksCreate,
pm_agent_id: UUID,
) -> tuple[SessionTable, list[SessionTaskTable]]:
"""
Create (or reuse) a session linked to one or more tasks (PM operation).
If any ancestor of the requested tasks already has an active primary
session in the target channel, we link to that session instead of
opening a new one — keeping the whole task tree in one group chat.
Args:
req: Session creation request with task IDs
pm_agent_id: PM agent creating the session
Returns:
Tuple of (session, list of newly-created links)
Raises:
NotFoundError: If channel not found
ValueError: If no groups found in channel
"""
channel = await self.get_channel_by_slug(req.channel_slug)
if not channel:
raise NotFoundError(f"Channel '{req.channel_slug}' not found")
channel_id = cast("UUID", channel.id)
reusable = await self._find_ancestor_session_on_channel(
req.task_ids, channel_id
)
if reusable:
links = await self._link_tasks_to_existing_session(
reusable, req, pm_agent_id
)
self.log.info(
"Reused ancestor session for subtasks",
session_id=str(reusable.id),
task_count=len(req.task_ids),
newly_linked=len(links),
channel_slug=req.channel_slug,
pm_agent_id=str(pm_agent_id),
)
return reusable, links
2026-04-21 17:48:45 +02:00
group = await self._resolve_group_for_session(req, channel)
session = await self.create_session(self._build_session_request(req, group))
links = await self._link_tasks_to_session(session, req, pm_agent_id)
self.log.info(
"Session created for tasks",
session_id=str(session.id),
task_count=len(req.task_ids),
channel_slug=req.channel_slug,
pm_agent_id=str(pm_agent_id),
)
return session, links
2025-12-13 00:00:52 +01:00
def _check_session_boundaries(self, session: SessionTable) -> bool:
"""Check if session has exceeded boundaries. Returns True if should close."""
# Check message count
if (
session.max_message_count
and session.message_count >= session.max_message_count
):
return True
# Check content length
return bool(
session.max_content_length
and session.total_content_length >= session.max_content_length
)
# =========================================================================
# MESSAGE OPERATIONS (TASK-014)
# =========================================================================
async def _get_message_context(
self,
session_id: UUID,
) -> tuple[SessionTable, GroupTable, ChannelTable]:
2026-04-20 15:10:54 +02:00
"""Get session, group, and channel for sending a message.
If the requested session has closed (timed out, boundary hit,
manually closed), transparently redirect to the group's current
active session — or open a fresh one if none exists. QA/PM agents
that held a session reference from earlier in the task lifecycle
shouldn't be blocked from posting an escalation just because the
session expired; the message still belongs in the group.
"""
session = await self.get_session(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
2026-04-20 15:10:54 +02:00
if session.status != SessionStatus.ACTIVE:
2026-04-20 15:10:54 +02:00
group_id = cast("UUID", session.group_id)
group = await self.get_group(group_id)
if not group:
raise ValueError(f"Group {group_id} not found")
channel = await self.get_channel(cast("UUID", group.channel_id))
if not channel:
raise ValueError(f"Channel {group.channel_id} not found")
# Find the group's current active session, or create one.
session = await self.get_or_create_active_session(group_id)
return session, group, channel
group = await self.get_group(cast("UUID", session.group_id))
if not group:
raise ValueError(f"Group {session.group_id} not found")
channel = await self.get_channel(cast("UUID", group.channel_id))
if not channel:
raise ValueError(f"Channel {group.channel_id} not found")
return session, group, channel
async def _validate_reply_target(
self,
reply_to: UUID,
session_id: UUID,
) -> None:
"""Validate reply target exists in session."""
reply_msg = await self.get_message(reply_to)
if not reply_msg or reply_msg.session_id != session_id:
raise ValueError("Reply target not found in this session")
def _update_message_stats(
self,
session: SessionTable,
group: GroupTable,
channel: ChannelTable,
content_length: int,
) -> None:
"""Update statistics after sending a message."""
now = datetime.now(UTC)
session.message_count += 1
session.total_content_length += content_length
session.last_activity_at = now
group.total_messages += 1
group.last_activity = now
channel.message_count += 1
channel.last_activity = now
async def _notify_mentions(
self,
message: MessageTable,
sender_id: UUID,
channel_slug: str,
) -> None:
"""
Create and deliver notifications for mentioned agents.
Publishes NOTIFICATION_SENT events to Redis Streams for real-time
delivery via the WebSocket bridge.
"""
if not message.mentions:
return
# Lazy import to avoid circular dependency
from roboco.services.notification_delivery import (
get_notification_delivery_service,
)
delivery_service = get_notification_delivery_service(self.session)
for mentioned_id in message.mentions:
# Don't notify yourself
if mentioned_id == sender_id:
continue
# Create mention notification
notification = NotificationTable(
type=NotificationType.MENTION,
priority=NotificationPriority.NORMAL,
from_agent=sender_id,
to_agents=[mentioned_id],
subject=f"You were mentioned in #{channel_slug}",
body=message.content[:500], # Truncate for notification
related_task_id=message.task_id,
# F009: MENTION is informational (ACK_REQUIRED_BY_TYPE -> False).
# The column default True made every @mention require an ack,
# inflating the recipient's unacked set and soft-blocking
# i_am_idle into respawn churn.
requires_ack=False,
)
self.session.add(notification)
await self.session.flush()
# Deliver via Redis Streams -> WebSocket
2025-12-31 21:42:58 +01:00
await delivery_service.deliver(require_uuid(notification.id))
self.log.debug(
"Mention notification sent",
mentioned_id=str(mentioned_id),
message_id=str(message.id),
)
2025-12-30 02:35:58 +01:00
async def _index_message_async(self, message: MessageTable) -> None:
"""Index message in RAG system (fire-and-forget)."""
from roboco.models.optimal import IndexConversationParams
from roboco.services.optimal import get_optimal_service
try:
optimal = await get_optimal_service()
await optimal.index_conversation(
IndexConversationParams(
content=message.content,
2025-12-31 21:42:58 +01:00
channel_id=require_uuid(message.channel_id),
session_id=require_uuid(message.session_id),
agent_id=require_uuid(message.agent_id),
task_id=to_python_uuid(message.task_id),
2025-12-30 02:35:58 +01:00
message_type=message.type.value if message.type else None,
)
)
self.log.debug("Message indexed", message_id=str(message.id))
except Exception as e:
self.log.warning(
"Failed to index message",
message_id=str(message.id),
error=str(e),
)
_MAX_MSG_CHARS: ClassVar[int] = 10_000
def _assert_content(self, raw: str | None) -> None:
"""Reject empty / over-cap message content with a readable error."""
trimmed = (raw or "").strip()
if not trimmed:
raise ValueError("EMPTY_MESSAGE: message content cannot be blank.")
if len(raw or "") > self._MAX_MSG_CHARS:
raise ValueError(
f"MESSAGE_TOO_LONG: {len(raw or '')} chars exceeds "
f"{self._MAX_MSG_CHARS}. Split the message or link a doc."
)
2025-12-13 00:00:52 +01:00
async def send_message(
self,
req: MessageCreateRequest,
agent_slug: str | None = None,
) -> MessageTable:
"""
Send a message to a session.
Args:
req: Message creation request
agent_slug: Agent slug for channel access validation (optional)
Returns:
Created message
Raises:
ValueError: If session not found or not active, or if content is
blank/too long.
2025-12-13 00:00:52 +01:00
ChannelAccessDeniedError: If agent cannot write to channel
"""
self._assert_content(req.content)
session, group, channel = await self._get_message_context(req.session_id)
2025-12-13 00:00:52 +01:00
if agent_slug:
validate_channel_access(agent_slug, channel.slug, "write")
if req.reply_to:
await self._validate_reply_target(req.reply_to, req.session_id)
2025-12-13 00:00:52 +01:00
content_length = len(req.content)
message = MessageTable(
agent_id=req.agent_id,
channel_id=channel.id,
group_id=group.id,
session_id=session.id,
type=req.message_type,
content=req.content,
content_length=content_length,
is_reply=req.reply_to is not None,
reply_to=req.reply_to,
mentions=list(req.mentions) if req.mentions else [],
task_id=req.task_id,
commit_ref=req.commit_ref,
)
self.session.add(message)
self._update_message_stats(session, group, channel, content_length)
2025-12-13 00:00:52 +01:00
await self.session.flush()
# Notify mentioned agents via Redis Streams
await self._notify_mentions(message, req.agent_id, channel.slug)
2025-12-30 02:35:58 +01:00
# Index message in RAG (fire-and-forget)
bg_task = asyncio.create_task(self._index_message_async(message))
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
2025-12-13 00:00:52 +01:00
if self._check_session_boundaries(session):
2025-12-13 05:50:34 +01:00
await self.close_session(cast("UUID", session.id), "Boundary exceeded")
2025-12-13 00:00:52 +01:00
self.log.info(
"Message sent",
message_id=str(message.id),
session_id=str(session.id),
agent_id=str(req.agent_id),
type=req.message_type.value,
)
return message
2026-04-21 17:48:45 +02:00
async def get_message_or_raise(self, message_id: UUID) -> MessageTable:
"""Return a message or raise NotFoundError."""
message = await self.get_message(message_id)
if not message:
raise NotFoundError(resource_type="Message", resource_id=str(message_id))
return message
async def list_messages_for_session(
self,
*,
session_id: UUID,
before: datetime | None,
after: datetime | None,
message_type: MessageType | None,
limit: int,
) -> tuple[list[MessageTable], bool]:
"""Session-scoped message list, verifying the session exists first.
Existing `get_messages` skips the session-existence check — this
variant raises NotFoundError when the session is missing so routes
can return a clean 404 without issuing their own query.
"""
await self.get_session_or_raise(session_id)
return await self.get_messages(
session_id,
before=before,
after=after,
message_type=message_type,
limit=limit,
)
async def edit_message_or_raise(
self,
*,
message_id: UUID,
agent_id: UUID,
new_content: str,
edit_reason: str | None,
) -> MessageTable:
"""Edit a message; raise NotFoundError / PermissionError on miss."""
message = await self.get_message(message_id)
if not message:
raise NotFoundError(resource_type="Message", resource_id=str(message_id))
if message.agent_id != agent_id:
raise PermissionError("Only the author can edit this message")
edit_entry = {
"edited_at": datetime.now(UTC).isoformat(),
"previous_content": message.content,
"edit_reason": edit_reason,
}
message.edit_history = [*message.edit_history, edit_entry]
old_length = message.content_length
new_length = len(new_content)
delta = new_length - old_length
message.content = new_content
message.content_length = new_length
message.edited_at = datetime.now(UTC)
session_row = await self.get_session(cast("UUID", message.session_id))
if session_row:
session_row.total_content_length += delta
await self.session.flush()
self.log.info(
"Message edited",
message_id=str(message_id),
agent_id=str(agent_id),
)
return message
async def delete_message_or_raise(
self,
*,
message_id: UUID,
agent_id: UUID,
) -> None:
"""Hard-delete a message; adjust session counters accordingly.
The soft-delete variant (`delete_message`) is kept for callers that
want tombstoned content; this method is the hard-delete path the
API exposes.
"""
message = await self.get_message(message_id)
if not message:
raise NotFoundError(resource_type="Message", resource_id=str(message_id))
if message.agent_id != agent_id:
raise PermissionError("Only the author can delete this message")
session_row = await self.get_session(cast("UUID", message.session_id))
if session_row:
session_row.message_count -= 1
session_row.total_content_length -= message.content_length
await self.session.delete(message)
await self.session.flush()
2025-12-13 00:00:52 +01:00
async def get_message(self, message_id: UUID) -> MessageTable | None:
"""Get a message by ID."""
result = await self.session.execute(
select(MessageTable).where(MessageTable.id == message_id)
)
return result.scalar_one_or_none()
async def get_messages(
self,
session_id: UUID,
before: datetime | None = None,
after: datetime | None = None,
message_type: MessageType | None = None,
limit: int = 50,
) -> tuple[list[MessageTable], bool]:
"""
Get messages from a session.
Args:
session_id: Session to get messages from
before: Get messages before this timestamp
after: Get messages after this timestamp
message_type: Filter by message type
limit: Maximum messages to return
Returns:
Tuple of (messages, has_more)
"""
query = select(MessageTable).where(MessageTable.session_id == session_id)
if before:
query = query.where(MessageTable.timestamp < before)
if after:
query = query.where(MessageTable.timestamp > after)
if message_type:
query = query.where(MessageTable.type == message_type)
# Get one extra to check if there are more
query = query.order_by(MessageTable.timestamp.desc()).limit(limit + 1)
result = await self.session.execute(query)
messages = list(result.scalars().all())
has_more = len(messages) > limit
if has_more:
messages = messages[:limit]
return messages, has_more
async def edit_message(
self,
message_id: UUID,
agent_id: UUID,
new_content: str,
edit_reason: str | None = None,
) -> MessageTable:
"""
Edit a message.
Only the author can edit their own messages.
Args:
message_id: Message to edit
agent_id: Agent requesting the edit (must be author)
new_content: New content
edit_reason: Optional reason for the edit
Returns:
Updated message
Raises:
ValueError: If message not found or agent is not author
"""
message = await self.get_message(message_id)
if not message:
raise ValueError(f"Message {message_id} not found")
if message.agent_id != agent_id:
raise ValueError("Only the author can edit this message")
# Store edit history
edit_entry = {
"edited_at": datetime.now(UTC).isoformat(),
"previous_content": message.content,
"edit_reason": edit_reason,
}
message.edit_history = [*message.edit_history, edit_entry]
# Calculate content length delta
old_length = message.content_length
new_length = len(new_content)
delta = new_length - old_length
# Update message
message.content = new_content
message.content_length = new_length
message.edited_at = datetime.now(UTC)
# Update session total content length
2025-12-13 05:50:34 +01:00
session = await self.get_session(cast("UUID", message.session_id))
2025-12-13 00:00:52 +01:00
if session:
session.total_content_length += delta
await self.session.flush()
self.log.info(
"Message edited",
message_id=str(message_id),
agent_id=str(agent_id),
)
return message
async def delete_message(
self,
message_id: UUID,
agent_id: UUID,
) -> bool:
"""
Soft delete a message.
Only the author can delete their own messages.
Args:
message_id: Message to delete
agent_id: Agent requesting deletion (must be author)
Returns:
True if deleted
Raises:
ValueError: If message not found or agent is not author
"""
message = await self.get_message(message_id)
if not message:
raise ValueError(f"Message {message_id} not found")
if message.agent_id != agent_id:
raise ValueError("Only the author can delete this message")
# Soft delete - mark content as deleted
message.content = "[deleted]"
message.edited_at = datetime.now(UTC)
await self.session.flush()
self.log.info(
"Message deleted",
message_id=str(message_id),
agent_id=str(agent_id),
)
return True
# =========================================================================
# GATEWAY (CONTENT_ACTIONS) BACKFILL
# =========================================================================
async def _default_group_for_channel(
self,
channel: ChannelTable,
) -> GroupTable:
"""Return a usable group for posting into `channel`.
Strategy: pick the first existing group ordered by hierarchy_level
then name. If the channel has no groups yet (fresh channel), create
a single default group. Channels were originally designed to have
explicit groups created at provisioning time, but the gateway
`say` verb addresses the channel as a whole — so we paper over
that boundary here rather than forcing every caller to know about
groups.
"""
groups = await self.list_groups_in_channel(cast("UUID", channel.id))
if groups:
return groups[0]
return await self.create_group(
GroupCreateRequest(
name="default",
channel_id=cast("UUID", channel.id),
allowed_roles=[],
hierarchy_level=4,
members=[],
)
)
@staticmethod
def _task_group_name(task_id: UUID) -> str:
"""Deterministic group name that threads a task's channel chatter.
Encoding the task id in the group name lets us locate the same
per-(channel, task) group on every post without a new column —
`GroupTable.name` is reused as the lookup key.
"""
return f"task:{task_id}"
async def _task_group_for_channel(
self,
channel: ChannelTable,
task_id: UUID,
) -> GroupTable:
"""Return the per-(channel, task) group, creating it on first post.
A task's discussion on a given channel threads into ONE group so it
is not scattered across the channel's standing groups. The group is
keyed by `_task_group_name(task_id)` within the channel.
"""
wanted = self._task_group_name(task_id)
groups = await self.list_groups_in_channel(cast("UUID", channel.id))
for group in groups:
if group.name == wanted:
return group
return await self.create_group(
GroupCreateRequest(
name=wanted,
channel_id=cast("UUID", channel.id),
allowed_roles=[],
hierarchy_level=4,
members=[],
)
)
async def post_to_channel(
self,
*,
agent_id: UUID,
channel_slug: str,
content: str,
task_id: UUID | None = None,
) -> MessageTable:
"""Gateway adapter — post a message to a channel by slug.
The gateway `say` verb addresses channels by slug (`backend-cell`,
`all-hands`, ...) and doesn't carry session/group IDs. This adapter
resolves the channel by slug, picks the target group, gets or
creates the active session for that group, then sends a message via
`send_message`. When `task_id` is supplied the message threads into
the per-(channel, task) group so a task's discussion lives in one
place per channel rather than scattering across standing groups;
otherwise it falls back to the channel's default group.
Channel write-access is validated UP FRONT (before any group or
session is created) so a denied write leaves no side effects. The
agent slug is resolved from `agent_id`; if the lookup returns None
(unknown / removed agent) we fail closed by raising
ChannelAccessDeniedError. `send_message` re-validates access as
defense in depth.
"""
from roboco.enforcement.channel_access import ChannelAccessDeniedError
from roboco.services.repositories import get_agent_slug
channel = await self.get_channel_by_slug_or_raise(channel_slug)
agent_slug = await get_agent_slug(self.session, agent_id)
if agent_slug is None:
raise ChannelAccessDeniedError(
agent_id=str(agent_id),
channel_slug=channel_slug,
action="write",
2026-05-03 17:51:33 +02:00
message=(f"agent {agent_id} not found; cannot validate channel access"),
)
validate_channel_access(agent_slug, channel_slug, "write")
if task_id is not None:
group = await self._task_group_for_channel(channel, task_id)
else:
group = await self._default_group_for_channel(channel)
session = await self.get_or_create_active_session(cast("UUID", group.id))
return await self.send_message(
MessageCreateRequest(
agent_id=agent_id,
session_id=cast("UUID", session.id),
content=content,
task_id=task_id,
),
agent_slug=agent_slug,
)
2025-12-13 00:00:52 +01:00
# =============================================================================
# SERVICE FACTORY
# =============================================================================
def get_messaging_service(session: AsyncSession) -> MessagingService:
"""Factory function to create a MessagingService instance."""
return MessagingService(session)