STATE-WP-0093: per-recipient broadcast receipts and standing notices (T01-T06).
Founder approved T01 on 2026-09-22 (D2, D3, D6 as recommended).
- T02: message_receipts table; kind/expires_at/supersedes_id on
agent_messages; migration d7e8f9a0b1c2 archives existing broadcasts,
leaves direct messages untouched, reversible.
- T03: reader-aware mark-read (unattributed broadcast mark-read is a
metered, deprecated no-op), delivery receipts on the scoped unread inbox,
POST /messages/{id}/ack, news/standing kinds, expiry, supersede, broadcast
archive no longer stamps read_at, reply writes the replier's receipt.
- T04 (state-hub part): Codex MCP reader param and acknowledge_notice;
hub-core part handed off (message 69fc387c).
- T05: GET /messages/notices, standing_notices in /state/summary,
dashboard standing-notices panel.
- T06: 17 new tests; full suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 63291@bnt-lap001
Assistant-Session: 8bd77868-ca68-4f49-bb1e-d539ecc0d703
This commit is contained in:
parent
740068fcf1
commit
ef541f58cf
18 changed files with 1256 additions and 52 deletions
|
|
@ -23,7 +23,7 @@ from api.models.technical_debt import TechnicalDebt, TDStatus
|
||||||
from api.models.contribution import Contribution, ContributionType, ContributionStatus
|
from api.models.contribution import Contribution, ContributionType, ContributionStatus
|
||||||
from api.models.sbom_snapshot import SBOMSnapshot
|
from api.models.sbom_snapshot import SBOMSnapshot
|
||||||
from api.models.sbom_entry import SBOMEntry, Ecosystem
|
from api.models.sbom_entry import SBOMEntry, Ecosystem
|
||||||
from api.models.agent_message import AgentMessage
|
from api.models.agent_message import AgentMessage, MessageReceipt
|
||||||
from api.models.capability_catalog import CapabilityCatalog
|
from api.models.capability_catalog import CapabilityCatalog
|
||||||
from api.models.capability_request import CapabilityRequest
|
from api.models.capability_request import CapabilityRequest
|
||||||
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
||||||
|
|
@ -76,6 +76,7 @@ __all__ = [
|
||||||
"SBOMSnapshot",
|
"SBOMSnapshot",
|
||||||
"SBOMEntry", "Ecosystem",
|
"SBOMEntry", "Ecosystem",
|
||||||
"AgentMessage",
|
"AgentMessage",
|
||||||
|
"MessageReceipt",
|
||||||
"CapabilityCatalog",
|
"CapabilityCatalog",
|
||||||
"CapabilityRequest",
|
"CapabilityRequest",
|
||||||
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, Text, text
|
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from api.models.base import Base, new_uuid
|
from api.models.base import Base, new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
BROADCAST = "broadcast"
|
||||||
|
MESSAGE_KINDS = ("message", "news", "standing")
|
||||||
|
|
||||||
|
|
||||||
class AgentMessage(Base):
|
class AgentMessage(Base):
|
||||||
__tablename__ = "agent_messages"
|
__tablename__ = "agent_messages"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_agent_messages_to_kind_archived", "to_agent", "kind", "archived_at"),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
|
|
@ -30,6 +37,19 @@ class AgentMessage(Base):
|
||||||
archived_at: Mapped[datetime | None] = mapped_column(
|
archived_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
# STATE-WP-0093: kind is only meaningful for broadcasts
|
||||||
|
# (message | news | standing); direct messages stay "message".
|
||||||
|
kind: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="message", server_default="message"
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
supersedes_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("agent_messages.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
server_default=text("now()"),
|
server_default=text("now()"),
|
||||||
|
|
@ -42,3 +62,29 @@ class AgentMessage(Base):
|
||||||
foreign_keys=[thread_id],
|
foreign_keys=[thread_id],
|
||||||
lazy="select",
|
lazy="select",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReceipt(Base):
|
||||||
|
"""Per-reader state for broadcast messages (STATE-WP-0093).
|
||||||
|
|
||||||
|
Written only for broadcasts; direct messages keep the global
|
||||||
|
``AgentMessage.read_at``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "message_receipts"
|
||||||
|
|
||||||
|
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("agent_messages.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
agent: Mapped[str] = mapped_column(String(100), primary_key=True, index=True)
|
||||||
|
delivered_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
acknowledged_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict
|
from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict
|
||||||
from api.models.agent_message import AgentMessage
|
from api.models.agent_message import BROADCAST, AgentMessage
|
||||||
|
from api.services.message_receipts import NEWS_DEFAULT_TTL, utcnow
|
||||||
from api.models.capability_catalog import CapabilityCatalog
|
from api.models.capability_catalog import CapabilityCatalog
|
||||||
from api.models.capability_request import CapabilityRequest
|
from api.models.capability_request import CapabilityRequest
|
||||||
from api.models.domain import Domain
|
from api.models.domain import Domain
|
||||||
|
|
@ -372,6 +373,10 @@ def _add_notification(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
body=body,
|
||||||
)
|
)
|
||||||
|
if to_agent == BROADCAST:
|
||||||
|
# STATE-WP-0093: system broadcasts are news (seen once per reader).
|
||||||
|
msg.kind = "news"
|
||||||
|
msg.expires_at = utcnow() + NEWS_DEFAULT_TTL
|
||||||
session.add(msg)
|
session.add(msg)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,29 @@
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
|
||||||
from sqlalchemy import or_, select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
from api.models.agent_message import AgentMessage
|
from api.models.agent_message import BROADCAST, MESSAGE_KINDS, AgentMessage
|
||||||
from api.schemas.agent_message import MessageCreate, MessageRead, MessageReply
|
from api.schemas.agent_message import (
|
||||||
|
MessageAck,
|
||||||
|
MessageCreate,
|
||||||
|
MessageMarkRead,
|
||||||
|
MessageRead,
|
||||||
|
MessageReply,
|
||||||
|
NoticeStatus,
|
||||||
|
)
|
||||||
|
from api.services.legacy_meter import identity_from_request, record_legacy_usage
|
||||||
|
from api.services.message_receipts import (
|
||||||
|
NEWS_DEFAULT_TTL,
|
||||||
|
broadcast_unread_clause,
|
||||||
|
is_broadcast,
|
||||||
|
notice_statuses,
|
||||||
|
receipts_for,
|
||||||
|
upsert_receipt,
|
||||||
|
utcnow,
|
||||||
|
)
|
||||||
from api.services.repository_aliases import (
|
from api.services.repository_aliases import (
|
||||||
canonicalize_repository_slug,
|
canonicalize_repository_slug,
|
||||||
resolve_repository_slug,
|
resolve_repository_slug,
|
||||||
|
|
@ -16,6 +33,38 @@ from hub_core.models.message_identity_alias import MessageIdentityAlias
|
||||||
|
|
||||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||||
|
|
||||||
|
UNATTRIBUTED_BROADCAST_READ_KEY = "rest_api:PATCH /messages/{id}/read broadcast-without-reader"
|
||||||
|
UNATTRIBUTED_BROADCAST_READ_REPLACEMENT = "PATCH /messages/{id}/read?reader=<agent>"
|
||||||
|
UNATTRIBUTED_BROADCAST_WARNING = '299 - "broadcast mark-read needs ?reader=<agent>"'
|
||||||
|
|
||||||
|
|
||||||
|
async def _reader_values(session: AsyncSession, reader: str) -> tuple[str, tuple[str, ...]]:
|
||||||
|
"""Canonical reader slug plus every historical slug it answers to."""
|
||||||
|
resolution = await resolve_repository_slug(session, reader, required=False)
|
||||||
|
if resolution is None:
|
||||||
|
return reader, (reader,)
|
||||||
|
return resolution.canonical_slug, tuple(resolution.slug_values)
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_view(
|
||||||
|
session: AsyncSession,
|
||||||
|
message: AgentMessage,
|
||||||
|
reader_values: tuple[str, ...] | None,
|
||||||
|
reader: str | None,
|
||||||
|
) -> MessageRead:
|
||||||
|
view = MessageRead.model_validate(message)
|
||||||
|
if reader_values is None or not is_broadcast(message):
|
||||||
|
return view
|
||||||
|
state = (await receipts_for(session, [message.id], reader_values)).get(message.id, {})
|
||||||
|
return view.model_copy(
|
||||||
|
update={
|
||||||
|
"reader": reader,
|
||||||
|
"delivered_at": state.get("delivered_at"),
|
||||||
|
"read_at": state.get("read_at"),
|
||||||
|
"acknowledged_at": state.get("acknowledged_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_message(reference: str, session: AsyncSession) -> AgentMessage:
|
async def _get_message(reference: str, session: AsyncSession) -> AgentMessage:
|
||||||
message_id = await resolve_message_reference(
|
message_id = await resolve_message_reference(
|
||||||
|
|
@ -43,6 +92,38 @@ async def send_message(
|
||||||
payload = body.model_dump()
|
payload = body.model_dump()
|
||||||
payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent)
|
payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent)
|
||||||
payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent)
|
payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent)
|
||||||
|
if payload["to_agent"] == BROADCAST:
|
||||||
|
kind = body.kind or "news"
|
||||||
|
if kind == "message":
|
||||||
|
kind = "news"
|
||||||
|
if kind not in MESSAGE_KINDS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"kind must be one of {', '.join(MESSAGE_KINDS[1:])} for broadcasts",
|
||||||
|
)
|
||||||
|
payload["kind"] = kind
|
||||||
|
if kind == "news" and body.expires_at is None:
|
||||||
|
payload["expires_at"] = utcnow() + NEWS_DEFAULT_TTL
|
||||||
|
if body.supersedes_id is not None:
|
||||||
|
predecessor = await session.get(AgentMessage, body.supersedes_id)
|
||||||
|
if predecessor is None or not is_broadcast(predecessor):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Superseded broadcast {body.supersedes_id} not found",
|
||||||
|
)
|
||||||
|
if predecessor.archived_at is None:
|
||||||
|
predecessor.archived_at = utcnow()
|
||||||
|
else:
|
||||||
|
if body.kind not in (None, "message"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail="kind news/standing is only valid for broadcasts"
|
||||||
|
)
|
||||||
|
if body.expires_at is not None or body.supersedes_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="expires_at and supersedes_id are only valid for broadcasts",
|
||||||
|
)
|
||||||
|
payload["kind"] = "message"
|
||||||
message = AgentMessage(**payload)
|
message = AgentMessage(**payload)
|
||||||
session.add(message)
|
session.add(message)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -57,24 +138,74 @@ async def list_messages(
|
||||||
unread_only: bool = False,
|
unread_only: bool = False,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[AgentMessage]:
|
) -> list[MessageRead]:
|
||||||
|
now = utcnow()
|
||||||
query = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
|
query = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
|
||||||
|
reader: str | None = None
|
||||||
|
reader_values: tuple[str, ...] | None = None
|
||||||
if to_agent:
|
if to_agent:
|
||||||
resolution = await resolve_repository_slug(session, to_agent, required=False)
|
reader, reader_values = await _reader_values(session, to_agent)
|
||||||
values = resolution.slug_values if resolution else (to_agent,)
|
direct = AgentMessage.to_agent.in_(reader_values)
|
||||||
|
if unread_only:
|
||||||
query = query.where(
|
query = query.where(
|
||||||
or_(AgentMessage.to_agent.in_(values), AgentMessage.to_agent == "broadcast")
|
or_(
|
||||||
|
and_(direct, AgentMessage.read_at.is_(None)),
|
||||||
|
broadcast_unread_clause(reader_values, now),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
direct,
|
||||||
|
and_(
|
||||||
|
AgentMessage.to_agent == BROADCAST,
|
||||||
|
or_(AgentMessage.expires_at.is_(None), AgentMessage.expires_at > now),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif unread_only:
|
||||||
|
query = query.where(AgentMessage.read_at.is_(None))
|
||||||
if from_agent:
|
if from_agent:
|
||||||
resolution = await resolve_repository_slug(session, from_agent, required=False)
|
resolution = await resolve_repository_slug(session, from_agent, required=False)
|
||||||
values = resolution.slug_values if resolution else (from_agent,)
|
values = resolution.slug_values if resolution else (from_agent,)
|
||||||
query = query.where(AgentMessage.from_agent.in_(values))
|
query = query.where(AgentMessage.from_agent.in_(values))
|
||||||
if unread_only:
|
|
||||||
query = query.where(AgentMessage.read_at.is_(None))
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
query.order_by(AgentMessage.created_at.desc()).limit(limit)
|
query.order_by(AgentMessage.created_at.desc()).limit(limit)
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
messages = list(result.scalars().all())
|
||||||
|
if reader_values is None or reader is None:
|
||||||
|
return [MessageRead.model_validate(m) for m in messages]
|
||||||
|
|
||||||
|
broadcast_ids = [m.id for m in messages if is_broadcast(m)]
|
||||||
|
if unread_only and broadcast_ids and reader != BROADCAST:
|
||||||
|
# D3 (founder-approved 2026-09-22): the orientation inbox call records
|
||||||
|
# an idempotent delivery receipt for each broadcast it returns.
|
||||||
|
await upsert_receipt(session, broadcast_ids, reader, delivered=True, at=now)
|
||||||
|
await session.commit()
|
||||||
|
receipts = await receipts_for(session, broadcast_ids, reader_values)
|
||||||
|
views = []
|
||||||
|
for message in messages:
|
||||||
|
view = MessageRead.model_validate(message)
|
||||||
|
if is_broadcast(message):
|
||||||
|
state = receipts.get(message.id, {})
|
||||||
|
view = view.model_copy(
|
||||||
|
update={
|
||||||
|
"reader": reader,
|
||||||
|
"delivered_at": state.get("delivered_at"),
|
||||||
|
"read_at": state.get("read_at"),
|
||||||
|
"acknowledged_at": state.get("acknowledged_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
views.append(view)
|
||||||
|
return views
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notices", response_model=list[NoticeStatus])
|
||||||
|
async def list_notices(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[NoticeStatus]:
|
||||||
|
"""Live standing notices with acknowledged / delivered-only / unreached repos."""
|
||||||
|
return await notice_statuses(session)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
|
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
|
||||||
|
|
@ -100,15 +231,67 @@ async def get_thread(
|
||||||
@router.patch("/{message_id}/read", response_model=MessageRead)
|
@router.patch("/{message_id}/read", response_model=MessageRead)
|
||||||
async def mark_read(
|
async def mark_read(
|
||||||
message_id: str,
|
message_id: str,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
reader: str | None = None,
|
||||||
|
ack: bool = False,
|
||||||
|
body: MessageMarkRead | None = Body(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> AgentMessage:
|
) -> AgentMessage | MessageRead:
|
||||||
message = await _get_message(message_id, session)
|
message = await _get_message(message_id, session)
|
||||||
|
if not is_broadcast(message):
|
||||||
if message.read_at is None:
|
if message.read_at is None:
|
||||||
message.read_at = datetime.now(timezone.utc)
|
message.read_at = datetime.now(timezone.utc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(message)
|
await session.refresh(message)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
reader = reader or (body.reader if body else None)
|
||||||
|
ack = ack or bool(body and body.ack)
|
||||||
|
if not reader:
|
||||||
|
view = MessageRead.model_validate(message)
|
||||||
|
# D2: an unattributed mark-read never hides a broadcast from anyone.
|
||||||
|
response.headers["Deprecation"] = "true"
|
||||||
|
response.headers["Warning"] = UNATTRIBUTED_BROADCAST_WARNING
|
||||||
|
response.headers["X-StateHub-Replacement"] = UNATTRIBUTED_BROADCAST_READ_REPLACEMENT
|
||||||
|
try:
|
||||||
|
await record_legacy_usage(
|
||||||
|
session,
|
||||||
|
interface_key=UNATTRIBUTED_BROADCAST_READ_KEY,
|
||||||
|
interface_kind="rest_api",
|
||||||
|
replacement_ref=UNATTRIBUTED_BROADCAST_READ_REPLACEMENT,
|
||||||
|
owner_component="state-hub.api",
|
||||||
|
replacement_verified=True,
|
||||||
|
identity=identity_from_request(request),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
return view
|
||||||
|
|
||||||
|
canonical, values = await _reader_values(session, reader)
|
||||||
|
await upsert_receipt(session, [message.id], canonical, read=True, acknowledged=ack)
|
||||||
|
await session.commit()
|
||||||
|
return await _read_view(session, message, values, canonical)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{message_id}/ack", response_model=MessageRead)
|
||||||
|
async def acknowledge_message(
|
||||||
|
message_id: str,
|
||||||
|
body: MessageAck,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> MessageRead:
|
||||||
|
"""Acknowledge a broadcast (clears a standing notice for that agent)."""
|
||||||
|
message = await _get_message(message_id, session)
|
||||||
|
if not is_broadcast(message):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="ack applies to broadcasts only; use PATCH /messages/{id}/read",
|
||||||
|
)
|
||||||
|
canonical, values = await _reader_values(session, body.agent)
|
||||||
|
await upsert_receipt(session, [message.id], canonical, acknowledged=True)
|
||||||
|
await session.commit()
|
||||||
|
return await _read_view(session, message, values, canonical)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
||||||
async def archive_message(
|
async def archive_message(
|
||||||
|
|
@ -117,7 +300,8 @@ async def archive_message(
|
||||||
) -> AgentMessage:
|
) -> AgentMessage:
|
||||||
message = await _get_message(message_id, session)
|
message = await _get_message(message_id, session)
|
||||||
message.archived_at = datetime.now(timezone.utc)
|
message.archived_at = datetime.now(timezone.utc)
|
||||||
if message.read_at is None:
|
# Broadcast archive is a global withdrawal; it no longer stamps read_at.
|
||||||
|
if message.read_at is None and not is_broadcast(message):
|
||||||
message.read_at = message.archived_at
|
message.read_at = message.archived_at
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(message)
|
await session.refresh(message)
|
||||||
|
|
@ -135,10 +319,13 @@ async def reply_to_message(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> AgentMessage:
|
) -> AgentMessage:
|
||||||
original = await _get_message(message_id, session)
|
original = await _get_message(message_id, session)
|
||||||
if original.read_at is None:
|
replier = await canonicalize_repository_slug(session, body.from_agent)
|
||||||
|
if is_broadcast(original):
|
||||||
|
await upsert_receipt(session, [original.id], replier, read=True)
|
||||||
|
elif original.read_at is None:
|
||||||
original.read_at = datetime.now(timezone.utc)
|
original.read_at = datetime.now(timezone.utc)
|
||||||
reply = AgentMessage(
|
reply = AgentMessage(
|
||||||
from_agent=await canonicalize_repository_slug(session, body.from_agent),
|
from_agent=replier,
|
||||||
to_agent=await canonicalize_repository_slug(session, original.from_agent),
|
to_agent=await canonicalize_repository_slug(session, original.from_agent),
|
||||||
subject=f"Re: {original.subject}",
|
subject=f"Re: {original.subject}",
|
||||||
body=body.body,
|
body=body.body,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from sqlalchemy.orm import noload, selectinload
|
||||||
|
|
||||||
from api.config import settings
|
from api.config import settings
|
||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
|
from api.services.message_receipts import standing_notice_digests
|
||||||
from api.services.schema_state import schema_state
|
from api.services.schema_state import schema_state
|
||||||
from api.flow_defs import assertion_result_to_dict, load_flow
|
from api.flow_defs import assertion_result_to_dict, load_flow
|
||||||
from api.models.capability_request import CapabilityRequest
|
from api.models.capability_request import CapabilityRequest
|
||||||
|
|
@ -136,6 +137,18 @@ def _apply_summary_flavor_view(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _live_summary_sections(
|
||||||
|
session: AsyncSession, *, refresh: bool = False
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Sections computed per request, outside the revision-keyed cache."""
|
||||||
|
return {
|
||||||
|
"ops_runs": await get_ops_run_projection(refresh=refresh),
|
||||||
|
# STATE-WP-0093 D5: receipts are written on inbox reads, which do not
|
||||||
|
# bump the summary revision, so standing-notice counts are live.
|
||||||
|
"standing_notices": await standing_notice_digests(session),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=StateSummary)
|
@router.get("/summary", response_model=StateSummary)
|
||||||
async def get_summary(
|
async def get_summary(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -155,7 +168,7 @@ async def get_summary(
|
||||||
if cache_status == "hit-revision" and cached is not None:
|
if cache_status == "hit-revision" and cached is not None:
|
||||||
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
||||||
return _apply_summary_flavor_view(
|
return _apply_summary_flavor_view(
|
||||||
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
|
cached.model_copy(update=await _live_summary_sections(session)),
|
||||||
include_residuals=include_residuals,
|
include_residuals=include_residuals,
|
||||||
flavor=flavor,
|
flavor=flavor,
|
||||||
)
|
)
|
||||||
|
|
@ -164,7 +177,7 @@ async def get_summary(
|
||||||
result = await apply_progress_section(session, cached, revision)
|
result = await apply_progress_section(session, cached, revision)
|
||||||
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
||||||
return _apply_summary_flavor_view(
|
return _apply_summary_flavor_view(
|
||||||
result.model_copy(update={"ops_runs": await get_ops_run_projection()}),
|
result.model_copy(update=await _live_summary_sections(session)),
|
||||||
include_residuals=include_residuals,
|
include_residuals=include_residuals,
|
||||||
flavor=flavor,
|
flavor=flavor,
|
||||||
)
|
)
|
||||||
|
|
@ -173,7 +186,7 @@ async def get_summary(
|
||||||
cache.schedule_refresh(revision)
|
cache.schedule_refresh(revision)
|
||||||
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
|
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
|
||||||
return _apply_summary_flavor_view(
|
return _apply_summary_flavor_view(
|
||||||
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
|
cached.model_copy(update=await _live_summary_sections(session)),
|
||||||
include_residuals=include_residuals,
|
include_residuals=include_residuals,
|
||||||
flavor=flavor,
|
flavor=flavor,
|
||||||
)
|
)
|
||||||
|
|
@ -182,7 +195,7 @@ async def get_summary(
|
||||||
cache.store(result, revision)
|
cache.store(result, revision)
|
||||||
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
|
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
|
||||||
return _apply_summary_flavor_view(
|
return _apply_summary_flavor_view(
|
||||||
result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)}),
|
result.model_copy(update=await _live_summary_sections(session, refresh=force_refresh)),
|
||||||
include_residuals=include_residuals,
|
include_residuals=include_residuals,
|
||||||
flavor=flavor,
|
flavor=flavor,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,78 @@
|
||||||
from hub_core.schemas.agent_message import MessageCreate, MessageRead, MessageReply
|
"""Agent message schemas.
|
||||||
|
|
||||||
|
The base shapes come from hub-core. STATE-WP-0093 extends them locally with
|
||||||
|
broadcast kinds, expiry, supersede and per-reader receipt fields; direct
|
||||||
|
messages keep the hub-core semantics (the new fields stay at their defaults).
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from hub_core.schemas.agent_message import MessageCreate as _HubMessageCreate
|
||||||
|
from hub_core.schemas.agent_message import MessageRead as _HubMessageRead
|
||||||
|
from hub_core.schemas.agent_message import MessageReply
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCreate(_HubMessageCreate):
|
||||||
|
# Only meaningful for broadcasts: news | standing. A broadcast without a
|
||||||
|
# kind defaults to ``news``; direct messages must leave it unset or
|
||||||
|
# ``message``.
|
||||||
|
kind: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
supersedes_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageRead(_HubMessageRead):
|
||||||
|
kind: str = "message"
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
supersedes_id: uuid.UUID | None = None
|
||||||
|
# Per-reader receipt state; populated for broadcasts when the request is
|
||||||
|
# scoped to a reader (``to_agent`` on list, ``reader`` on mark-read/ack).
|
||||||
|
# For such responses ``read_at`` is the reader's own read time.
|
||||||
|
reader: str | None = None
|
||||||
|
delivered_at: datetime | None = None
|
||||||
|
acknowledged_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageMarkRead(BaseModel):
|
||||||
|
reader: str | None = None
|
||||||
|
ack: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MessageAck(BaseModel):
|
||||||
|
agent: str
|
||||||
|
|
||||||
|
|
||||||
|
class NoticeStatus(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
from_agent: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
supersedes_id: uuid.UUID | None = None
|
||||||
|
acknowledged: list[str] = []
|
||||||
|
delivered_only: list[str] = []
|
||||||
|
unreached: list[str] = []
|
||||||
|
acked_count: int = 0
|
||||||
|
delivered_only_count: int = 0
|
||||||
|
unreached_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class StandingNoticeDigest(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
subject: str
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
acked: int = 0
|
||||||
|
pending: int = 0
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"MessageAck",
|
||||||
"MessageCreate",
|
"MessageCreate",
|
||||||
|
"MessageMarkRead",
|
||||||
"MessageRead",
|
"MessageRead",
|
||||||
"MessageReply",
|
"MessageReply",
|
||||||
|
"NoticeStatus",
|
||||||
|
"StandingNoticeDigest",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from api.schemas.progress_event import ProgressEventRead
|
||||||
from api.schemas.task import TaskRead
|
from api.schemas.task import TaskRead
|
||||||
from api.schemas.topic import TopicWithWorkstreams
|
from api.schemas.topic import TopicWithWorkstreams
|
||||||
from api.schemas.suggestion import RankedSuggestionDigest
|
from api.schemas.suggestion import RankedSuggestionDigest
|
||||||
|
from api.schemas.agent_message import StandingNoticeDigest
|
||||||
from api.schemas.workstream import WorkstreamWithDeps
|
from api.schemas.workstream import WorkstreamWithDeps
|
||||||
from api.schemas.ops_run import OpsRunProjection
|
from api.schemas.ops_run import OpsRunProjection
|
||||||
|
|
||||||
|
|
@ -94,6 +95,7 @@ class StateSummary(BaseModel):
|
||||||
open_capability_requests: int = 0
|
open_capability_requests: int = 0
|
||||||
ranked_suggestions: list[RankedSuggestionDigest] = []
|
ranked_suggestions: list[RankedSuggestionDigest] = []
|
||||||
ops_runs: OpsRunProjection | None = None
|
ops_runs: OpsRunProjection | None = None
|
||||||
|
standing_notices: list[StandingNoticeDigest] = []
|
||||||
|
|
||||||
|
|
||||||
class DashboardWorkplanRow(BaseModel):
|
class DashboardWorkplanRow(BaseModel):
|
||||||
|
|
|
||||||
212
api/services/message_receipts.py
Normal file
212
api/services/message_receipts.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
"""Per-recipient broadcast receipts and standing notices (STATE-WP-0093).
|
||||||
|
|
||||||
|
Broadcast visibility is per reader: a ``message_receipts`` row per
|
||||||
|
(message, agent) records delivery, read and acknowledgement. Direct messages
|
||||||
|
never get receipts and keep the global ``AgentMessage.read_at``.
|
||||||
|
|
||||||
|
- ``news`` broadcasts stay in a reader's unread inbox until that reader has
|
||||||
|
any receipt (delivery at orientation is enough).
|
||||||
|
- ``standing`` broadcasts stay until the reader acknowledges, the notice
|
||||||
|
expires, or it is superseded (which archives it).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import and_, exists, func, not_, or_, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from api.models.agent_message import BROADCAST, AgentMessage, MessageReceipt
|
||||||
|
from api.models.managed_repo import ManagedRepo
|
||||||
|
from api.schemas.agent_message import NoticeStatus, StandingNoticeDigest
|
||||||
|
|
||||||
|
NEWS_DEFAULT_TTL = timedelta(days=30)
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def is_broadcast(message: AgentMessage) -> bool:
|
||||||
|
return message.to_agent == BROADCAST
|
||||||
|
|
||||||
|
|
||||||
|
def not_expired(now: datetime):
|
||||||
|
return or_(AgentMessage.expires_at.is_(None), AgentMessage.expires_at > now)
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_unread_clause(reader_values: Sequence[str], now: datetime):
|
||||||
|
"""SQL clause: broadcast is still unread for a reader (any of its slugs)."""
|
||||||
|
has_any_receipt = exists().where(
|
||||||
|
and_(
|
||||||
|
MessageReceipt.message_id == AgentMessage.id,
|
||||||
|
MessageReceipt.agent.in_(reader_values),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
has_ack = exists().where(
|
||||||
|
and_(
|
||||||
|
MessageReceipt.message_id == AgentMessage.id,
|
||||||
|
MessageReceipt.agent.in_(reader_values),
|
||||||
|
MessageReceipt.acknowledged_at.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return and_(
|
||||||
|
AgentMessage.to_agent == BROADCAST,
|
||||||
|
not_expired(now),
|
||||||
|
or_(
|
||||||
|
and_(AgentMessage.kind == "standing", not_(has_ack)),
|
||||||
|
and_(AgentMessage.kind != "standing", not_(has_any_receipt)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_receipt(
|
||||||
|
session: AsyncSession,
|
||||||
|
message_ids: Iterable[uuid.UUID],
|
||||||
|
agent: str,
|
||||||
|
*,
|
||||||
|
delivered: bool = False,
|
||||||
|
read: bool = False,
|
||||||
|
acknowledged: bool = False,
|
||||||
|
at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Idempotently stamp receipt fields; the first timestamp wins."""
|
||||||
|
ids = list(message_ids)
|
||||||
|
if not ids or not agent or agent == BROADCAST:
|
||||||
|
return
|
||||||
|
at = at or utcnow()
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"message_id": mid,
|
||||||
|
"agent": agent,
|
||||||
|
"delivered_at": at if delivered else None,
|
||||||
|
"read_at": at if (read or acknowledged) else None,
|
||||||
|
"acknowledged_at": at if acknowledged else None,
|
||||||
|
}
|
||||||
|
for mid in ids
|
||||||
|
]
|
||||||
|
stmt = pg_insert(MessageReceipt).values(rows)
|
||||||
|
table = MessageReceipt.__table__
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=[table.c.message_id, table.c.agent],
|
||||||
|
set_={
|
||||||
|
col: func.coalesce(table.c[col], stmt.excluded[col])
|
||||||
|
for col in ("delivered_at", "read_at", "acknowledged_at")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def receipts_for(
|
||||||
|
session: AsyncSession,
|
||||||
|
message_ids: Iterable[uuid.UUID],
|
||||||
|
reader_values: Sequence[str],
|
||||||
|
) -> dict[uuid.UUID, dict[str, datetime | None]]:
|
||||||
|
"""Merged receipt state per message across a reader's historical slugs."""
|
||||||
|
ids = list(message_ids)
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
result = await session.execute(
|
||||||
|
select(MessageReceipt).where(
|
||||||
|
MessageReceipt.message_id.in_(ids),
|
||||||
|
MessageReceipt.agent.in_(reader_values),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
merged: dict[uuid.UUID, dict[str, datetime | None]] = {}
|
||||||
|
for receipt in result.scalars().all():
|
||||||
|
state = merged.setdefault(
|
||||||
|
receipt.message_id,
|
||||||
|
{"delivered_at": None, "read_at": None, "acknowledged_at": None},
|
||||||
|
)
|
||||||
|
for field in state:
|
||||||
|
value = getattr(receipt, field)
|
||||||
|
if value is not None and (state[field] is None or value < state[field]):
|
||||||
|
state[field] = value
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
async def _active_repo_slugs(session: AsyncSession) -> list[str]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ManagedRepo.slug).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def live_standing_notices(
|
||||||
|
session: AsyncSession, now: datetime | None = None
|
||||||
|
) -> list[AgentMessage]:
|
||||||
|
now = now or utcnow()
|
||||||
|
result = await session.execute(
|
||||||
|
select(AgentMessage)
|
||||||
|
.where(
|
||||||
|
AgentMessage.to_agent == BROADCAST,
|
||||||
|
AgentMessage.kind == "standing",
|
||||||
|
AgentMessage.archived_at.is_(None),
|
||||||
|
not_expired(now),
|
||||||
|
)
|
||||||
|
.order_by(AgentMessage.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def notice_statuses(session: AsyncSession) -> list[NoticeStatus]:
|
||||||
|
notices = await live_standing_notices(session)
|
||||||
|
if not notices:
|
||||||
|
return []
|
||||||
|
repos = await _active_repo_slugs(session)
|
||||||
|
result = await session.execute(
|
||||||
|
select(MessageReceipt).where(
|
||||||
|
MessageReceipt.message_id.in_([n.id for n in notices])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
by_notice: dict[uuid.UUID, list[MessageReceipt]] = {}
|
||||||
|
for receipt in result.scalars().all():
|
||||||
|
by_notice.setdefault(receipt.message_id, []).append(receipt)
|
||||||
|
statuses: list[NoticeStatus] = []
|
||||||
|
for notice in notices:
|
||||||
|
receipts = by_notice.get(notice.id, [])
|
||||||
|
acked = sorted({r.agent for r in receipts if r.acknowledged_at is not None})
|
||||||
|
seen = {r.agent for r in receipts}
|
||||||
|
delivered_only = sorted(seen - set(acked))
|
||||||
|
unreached = [slug for slug in repos if slug not in seen]
|
||||||
|
statuses.append(
|
||||||
|
NoticeStatus(
|
||||||
|
id=notice.id,
|
||||||
|
from_agent=notice.from_agent,
|
||||||
|
subject=notice.subject,
|
||||||
|
body=notice.body,
|
||||||
|
created_at=notice.created_at,
|
||||||
|
expires_at=notice.expires_at,
|
||||||
|
supersedes_id=notice.supersedes_id,
|
||||||
|
acknowledged=acked,
|
||||||
|
delivered_only=delivered_only,
|
||||||
|
unreached=unreached,
|
||||||
|
acked_count=len(acked),
|
||||||
|
delivered_only_count=len(delivered_only),
|
||||||
|
unreached_count=len(unreached),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return statuses
|
||||||
|
|
||||||
|
|
||||||
|
async def standing_notice_digests(session: AsyncSession) -> list[StandingNoticeDigest]:
|
||||||
|
statuses = await notice_statuses(session)
|
||||||
|
if not statuses:
|
||||||
|
return []
|
||||||
|
repos = set(await _active_repo_slugs(session))
|
||||||
|
digests = []
|
||||||
|
for status in statuses:
|
||||||
|
acked_repos = len(repos.intersection(status.acknowledged))
|
||||||
|
digests.append(
|
||||||
|
StandingNoticeDigest(
|
||||||
|
id=status.id,
|
||||||
|
subject=status.subject,
|
||||||
|
expires_at=status.expires_at,
|
||||||
|
acked=acked_repos,
|
||||||
|
pending=len(repos) - acked_repos,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return digests
|
||||||
15
dashboard/src/data/notices.json.py
Executable file
15
dashboard/src/data/notices.json.py
Executable file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Observable data loader: fetches /messages/notices (live standing notices)."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{API_BASE}/messages/notices", timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
print(json.dumps(data))
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(json.dumps({"error": str(e), "notices": []}))
|
||||||
|
|
@ -68,6 +68,7 @@ Current loaders:
|
||||||
| `decisions.json.py` | `/decisions/` |
|
| `decisions.json.py` | `/decisions/` |
|
||||||
| `domains.json.py` | `/domains/` |
|
| `domains.json.py` | `/domains/` |
|
||||||
| `messages.json.py` | `/messages/` |
|
| `messages.json.py` | `/messages/` |
|
||||||
|
| `notices.json.py` | `/messages/notices` |
|
||||||
| `progress.json.py` | `/progress/` |
|
| `progress.json.py` | `/progress/` |
|
||||||
| `repos.json.py` | `/repos/` |
|
| `repos.json.py` | `/repos/` |
|
||||||
| `sbom.json.py` | `/sbom/aggregated` |
|
| `sbom.json.py` | `/sbom/aggregated` |
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,16 @@ import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||||||
const inboxState = (async function*() {
|
const inboxState = (async function*() {
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
let messages = [], ok = false;
|
let messages = [], notices = [], ok = false;
|
||||||
try {
|
try {
|
||||||
const resp = await apiFetch("/messages/?limit=100");
|
const resp = await apiFetch("/messages/?limit=100");
|
||||||
ok = resp.ok;
|
ok = resp.ok;
|
||||||
if (ok) messages = await resp.json();
|
if (ok) messages = await resp.json();
|
||||||
|
const nresp = await apiFetch("/messages/notices");
|
||||||
|
if (nresp.ok) notices = await nresp.json();
|
||||||
} catch {}
|
} catch {}
|
||||||
failures = ok ? 0 : failures + 1;
|
failures = ok ? 0 : failures + 1;
|
||||||
yield {messages, ok, ts: new Date()};
|
yield {messages, notices, ok, ts: new Date()};
|
||||||
await waitForVisible(pollDelay({ok, failures}));
|
await waitForVisible(pollDelay({ok, failures}));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
@ -26,11 +28,16 @@ const inboxState = (async function*() {
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const messages = inboxState.messages ?? [];
|
const messages = inboxState.messages ?? [];
|
||||||
|
const notices = inboxState.notices ?? [];
|
||||||
const _ok = inboxState.ok ?? false;
|
const _ok = inboxState.ok ?? false;
|
||||||
const _ts = inboxState.ts;
|
const _ts = inboxState.ts;
|
||||||
|
|
||||||
const unread = messages.filter(m => !m.read_at && !m.archived_at);
|
// Broadcast read state is per reader (STATE-WP-0093): the dashboard has no
|
||||||
const read = messages.filter(m => m.read_at && !m.archived_at);
|
// reader identity, so live broadcasts get their own section.
|
||||||
|
const isBc = m => m.to_agent === "broadcast";
|
||||||
|
const unread = messages.filter(m => !isBc(m) && !m.read_at && !m.archived_at);
|
||||||
|
const read = messages.filter(m => !isBc(m) && m.read_at && !m.archived_at);
|
||||||
|
const broadcasts = messages.filter(m => isBc(m) && !m.archived_at);
|
||||||
const archived = messages.filter(m => m.archived_at);
|
const archived = messages.filter(m => m.archived_at);
|
||||||
|
|
||||||
// Group unread by agent for KPI
|
// Group unread by agent for KPI
|
||||||
|
|
@ -55,6 +62,12 @@ const _kpiBox = html`<div class="kpi-infobox">
|
||||||
<div class="kpi-row-value" style="color:${unread.length > 0 ? '#d97706' : 'inherit'}">${unread.length}</div>
|
<div class="kpi-row-value" style="color:${unread.length > 0 ? '#d97706' : 'inherit'}">${unread.length}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="kpi-row">
|
||||||
|
<span class="kpi-row-label">standing notices</span>
|
||||||
|
<div class="kpi-row-right">
|
||||||
|
<div class="kpi-row-value" style="font-size:1rem">${notices.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="kpi-row">
|
<div class="kpi-row">
|
||||||
<span class="kpi-row-label">total</span>
|
<span class="kpi-row-label">total</span>
|
||||||
<div class="kpi-row-right">
|
<div class="kpi-row-right">
|
||||||
|
|
@ -85,6 +98,39 @@ Inter-agent coordination messages. Agents send messages via `send_message()` MCP
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Standing notices
|
||||||
|
|
||||||
|
Live `standing` broadcasts stay in every agent's inbox until that agent
|
||||||
|
acknowledges them (`POST /messages/{id}/ack`). Repositories listed as not yet
|
||||||
|
acknowledged are the ones still out of date.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function repoList(label, slugs, color) {
|
||||||
|
if (!slugs.length) return "";
|
||||||
|
return html`<details class="notice-list"><summary style="color:${color}">${label} (${slugs.length})</summary>
|
||||||
|
<div class="notice-slugs">${slugs.join(", ")}</div></details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notices.length === 0) {
|
||||||
|
display(html`<p class="dim">No live standing notices.</p>`);
|
||||||
|
} else {
|
||||||
|
display(html`<div class="msg-list">${notices.map(n => html`<div class="msg-card" style="border-left-color:#7c3aed">
|
||||||
|
<div class="msg-header">
|
||||||
|
<span class="msg-from">${n.from_agent}</span>
|
||||||
|
<span class="msg-kind">standing</span>
|
||||||
|
<span class="msg-time">expires ${n.expires_at ? new Date(n.expires_at).toLocaleDateString() : "never"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg-subject">${n.subject}</div>
|
||||||
|
<div class="notice-counts">${n.acked_count} acknowledged · ${n.delivered_only_count} delivered, not acknowledged · ${n.unreached_count} not reached</div>
|
||||||
|
${repoList("Not yet acknowledged — delivered", n.delivered_only, "#d97706")}
|
||||||
|
${repoList("Not yet acknowledged — never reached", n.unreached, "#dc2626")}
|
||||||
|
${repoList("Acknowledged", n.acknowledged, "#059669")}
|
||||||
|
</div>`)}</div>`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Unread
|
## Unread
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|
@ -96,7 +142,7 @@ function fmtDate(s) {
|
||||||
|
|
||||||
function renderMessage(m, showMarkRead = false) {
|
function renderMessage(m, showMarkRead = false) {
|
||||||
const isBroadcast = m.to_agent === "broadcast";
|
const isBroadcast = m.to_agent === "broadcast";
|
||||||
const borderColor = !m.read_at ? "#d97706" : "#6b7280";
|
const borderColor = isBroadcast ? "#7c3aed" : (!m.read_at ? "#d97706" : "#6b7280");
|
||||||
|
|
||||||
async function onMarkRead() {
|
async function onMarkRead() {
|
||||||
await fetch(`${API}/messages/${m.id}/read`, {method: "PATCH"});
|
await fetch(`${API}/messages/${m.id}/read`, {method: "PATCH"});
|
||||||
|
|
@ -111,9 +157,10 @@ function renderMessage(m, showMarkRead = false) {
|
||||||
<span class="msg-from">${m.from_agent}</span>
|
<span class="msg-from">${m.from_agent}</span>
|
||||||
<span class="msg-arrow">→</span>
|
<span class="msg-arrow">→</span>
|
||||||
<span class="msg-to ${isBroadcast ? 'msg-broadcast' : ''}">${m.to_agent}</span>
|
<span class="msg-to ${isBroadcast ? 'msg-broadcast' : ''}">${m.to_agent}</span>
|
||||||
|
${isBroadcast ? html`<span class="msg-kind">${m.kind}</span>` : ""}
|
||||||
<span class="msg-time">${fmtDate(m.created_at)}</span>
|
<span class="msg-time">${fmtDate(m.created_at)}</span>
|
||||||
<div class="msg-actions">
|
<div class="msg-actions">
|
||||||
${showMarkRead ? html`<button class="msg-btn msg-btn-read" onclick=${onMarkRead}>Mark read</button>` : ""}
|
${showMarkRead && !isBroadcast ? html`<button class="msg-btn msg-btn-read" onclick=${onMarkRead}>Mark read</button>` : ""}
|
||||||
<button class="msg-btn msg-btn-archive" onclick=${onArchive}>Archive</button>
|
<button class="msg-btn msg-btn-archive" onclick=${onArchive}>Archive</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -135,6 +182,18 @@ if (unread.length === 0) {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Broadcasts
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (broadcasts.length === 0) {
|
||||||
|
display(html`<p class="dim">No live broadcasts.</p>`);
|
||||||
|
} else {
|
||||||
|
display(html`<div class="msg-list">${broadcasts.map(m => renderMessage(m, false))}</div>`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Read
|
## Read
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|
@ -186,4 +245,9 @@ if (archived.length === 0) {
|
||||||
.msg-body { white-space: pre-wrap; margin: 0.4rem 0 0; font-family: var(--mono); font-size: 0.8rem; background: var(--theme-background); padding: 0.5rem; border-radius: 4px; }
|
.msg-body { white-space: pre-wrap; margin: 0.4rem 0 0; font-family: var(--mono); font-size: 0.8rem; background: var(--theme-background); padding: 0.5rem; border-radius: 4px; }
|
||||||
.msg-thread { font-size: 0.7rem; color: var(--theme-foreground-muted, #999); margin-top: 0.2rem; }
|
.msg-thread { font-size: 0.7rem; color: var(--theme-foreground-muted, #999); margin-top: 0.2rem; }
|
||||||
.dim { color: gray; font-style: italic; }
|
.dim { color: gray; font-style: italic; }
|
||||||
|
.msg-kind { font-size: 0.7rem; font-weight: 600; color: #7c3aed; border: 1px solid #c4b5fd; border-radius: 4px; padding: 0 0.3rem; }
|
||||||
|
.notice-counts { font-size: 0.8rem; color: var(--theme-foreground-muted, #555); margin: 0.2rem 0; }
|
||||||
|
.notice-list { font-size: 0.8rem; margin-top: 0.2rem; }
|
||||||
|
.notice-list summary { cursor: pointer; font-weight: 600; }
|
||||||
|
.notice-slugs { font-family: var(--mono); font-size: 0.75rem; padding: 0.3rem 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -247,8 +247,15 @@ Use `"broadcast"` as `to_agent` to send to all agents.
|
||||||
|------|----------|-------------|
|
|------|----------|-------------|
|
||||||
| `get_messages(to_agent?, from_agent?, unread_only?, limit?)` | `to_agent`: your agent name; `unread_only`: True recommended at session start | Check for pending coordination messages. |
|
| `get_messages(to_agent?, from_agent?, unread_only?, limit?)` | `to_agent`: your agent name; `unread_only`: True recommended at session start | Check for pending coordination messages. |
|
||||||
| `send_message(from_agent, to_agent, subject, body, thread_id?)` | all except `thread_id` required | Send a coordination message to another agent (or broadcast). |
|
| `send_message(from_agent, to_agent, subject, body, thread_id?)` | all except `thread_id` required | Send a coordination message to another agent (or broadcast). |
|
||||||
| `mark_message_read(message_id)` | `message_id`: UUID | Mark a message as read after acting on it. |
|
| `mark_message_read(message_id, reader?)` | `message_id`: UUID; `reader`: your repo slug | Mark a message as read after acting on it. For broadcasts pass `reader` — read state is per reader; without it the call is a deprecated no-op for broadcasts (STATE-WP-0093). |
|
||||||
| `reply_to_message(message_id, from_agent, body)` | all required | Reply in-thread; marks original as read. |
|
| `acknowledge_notice(message_id, agent)` | both required | Acknowledge a `standing` broadcast notice after acting on it; clears it from `agent`'s inbox (`POST /messages/{id}/ack`). Codex server now; main server pending hub-core. |
|
||||||
|
| `reply_to_message(message_id, from_agent, body)` | all required | Reply in-thread; marks original as read (for a broadcast: for the replier only). |
|
||||||
|
|
||||||
|
Broadcast kinds (STATE-WP-0093): `news` (default for `to_agent="broadcast"`,
|
||||||
|
30-day default expiry) appears once per reader — the unread inbox call records
|
||||||
|
delivery; `standing` stays in each reader's unread inbox until acknowledged,
|
||||||
|
expired or superseded (`supersedes_id` archives the predecessor). Live notices
|
||||||
|
and who has acknowledged them: `GET /messages/notices`.
|
||||||
|
|
||||||
Dashboard: `http://localhost:3000/inbox`
|
Dashboard: `http://localhost:3000/inbox`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
@ -59,9 +60,22 @@ def get_messages(to_agent: str, unread_only: bool = True) -> str:
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def mark_message_read(message_id: str) -> str:
|
def mark_message_read(message_id: str, reader: str | None = None) -> str:
|
||||||
"""Mark one coordination message as read."""
|
"""Mark one coordination message as read.
|
||||||
return _json(_request("PATCH", f"/messages/{message_id}/read", {}))
|
|
||||||
|
Pass ``reader`` (your repository slug) so a broadcast is marked read for
|
||||||
|
you only; without it a broadcast stays visible and the call is deprecated.
|
||||||
|
"""
|
||||||
|
path = f"/messages/{message_id}/read"
|
||||||
|
if reader:
|
||||||
|
path += f"?reader={quote(reader, safe='')}"
|
||||||
|
return _json(_request("PATCH", path, {}))
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def acknowledge_notice(message_id: str, agent: str) -> str:
|
||||||
|
"""Acknowledge a standing notice after acting on it (clears it for ``agent``)."""
|
||||||
|
return _json(_request("POST", f"/messages/{message_id}/ack", {"agent": agent}))
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""per-recipient broadcast receipts and standing notices (STATE-WP-0093)
|
||||||
|
|
||||||
|
Creates ``message_receipts`` (per-reader delivered/read/acknowledged state for
|
||||||
|
broadcasts) and adds ``kind``, ``expires_at`` and ``supersedes_id`` to
|
||||||
|
``agent_messages``. Existing broadcasts are backfilled to ``kind='news'`` and,
|
||||||
|
per the founder decision on D6 (2026-09-22), archived so that none of them
|
||||||
|
re-surfaces once broadcast visibility stops depending on the global
|
||||||
|
``read_at``. Direct messages are not touched.
|
||||||
|
|
||||||
|
Downgrade drops the table, index and columns. Archive stamps stay (they are
|
||||||
|
reversible through the API and harmless under the old global read state).
|
||||||
|
|
||||||
|
Revision ID: d7e8f9a0b1c2
|
||||||
|
Revises: c6f7a8b9d0e1
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "d7e8f9a0b1c2"
|
||||||
|
down_revision = "c6f7a8b9d0e1"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"agent_messages",
|
||||||
|
sa.Column(
|
||||||
|
"kind",
|
||||||
|
sa.String(length=20),
|
||||||
|
nullable=False,
|
||||||
|
server_default="message",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"agent_messages",
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"agent_messages",
|
||||||
|
sa.Column(
|
||||||
|
"supersedes_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey(
|
||||||
|
"agent_messages.id",
|
||||||
|
ondelete="SET NULL",
|
||||||
|
name="fk_agent_messages_supersedes_id",
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_agent_messages_to_kind_archived",
|
||||||
|
"agent_messages",
|
||||||
|
["to_agent", "kind", "archived_at"],
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"message_receipts",
|
||||||
|
sa.Column(
|
||||||
|
"message_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("agent_messages.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column("agent", sa.String(length=100), primary_key=True),
|
||||||
|
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_message_receipts_agent", "message_receipts", ["agent"])
|
||||||
|
|
||||||
|
# D6: existing broadcasts become news and are archived (only broadcasts).
|
||||||
|
op.execute(
|
||||||
|
"UPDATE agent_messages SET kind = 'news' WHERE to_agent = 'broadcast'"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE agent_messages SET archived_at = now() "
|
||||||
|
"WHERE to_agent = 'broadcast' AND archived_at IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_message_receipts_agent", table_name="message_receipts")
|
||||||
|
op.drop_table("message_receipts")
|
||||||
|
op.drop_index("ix_agent_messages_to_kind_archived", table_name="agent_messages")
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_agent_messages_supersedes_id", "agent_messages", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.drop_column("agent_messages", "supersedes_id")
|
||||||
|
op.drop_column("agent_messages", "expires_at")
|
||||||
|
op.drop_column("agent_messages", "kind")
|
||||||
|
|
@ -9,6 +9,7 @@ def test_codex_server_has_only_repository_coordination_tools() -> None:
|
||||||
"get_domain_summary",
|
"get_domain_summary",
|
||||||
"get_messages",
|
"get_messages",
|
||||||
"mark_message_read",
|
"mark_message_read",
|
||||||
|
"acknowledge_notice",
|
||||||
"add_progress_event",
|
"add_progress_event",
|
||||||
"record_decision",
|
"record_decision",
|
||||||
"update_task_status",
|
"update_task_status",
|
||||||
|
|
@ -17,3 +18,18 @@ def test_codex_server_has_only_repository_coordination_tools() -> None:
|
||||||
|
|
||||||
def test_codex_server_uses_distinct_server_identity() -> None:
|
def test_codex_server_uses_distinct_server_identity() -> None:
|
||||||
assert codex_server.mcp.name == "dev-hub-codex"
|
assert codex_server.mcp.name == "dev-hub-codex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_message_read_passes_reader(monkeypatch) -> None:
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
codex_server, "_request", lambda method, path, body=None: calls.append((method, path, body)) or {}
|
||||||
|
)
|
||||||
|
codex_server.mark_message_read("abc", reader="state-hub")
|
||||||
|
codex_server.mark_message_read("abc")
|
||||||
|
codex_server.acknowledge_notice("abc", "state-hub")
|
||||||
|
assert calls == [
|
||||||
|
("PATCH", "/messages/abc/read?reader=state-hub", {}),
|
||||||
|
("PATCH", "/messages/abc/read", {}),
|
||||||
|
("POST", "/messages/abc/ack", {"agent": "state-hub"}),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,12 @@ from hub_core.schemas.tpsc import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_state_hub_reexports_core_message_schema() -> None:
|
def test_state_hub_message_schema_extends_core() -> None:
|
||||||
assert MessageCreate is CoreMessageCreate
|
# STATE-WP-0093 extends the hub-core shape locally (kind, expires_at,
|
||||||
|
# supersedes_id) until hub-core absorbs the fields; then this returns to
|
||||||
|
# an identity re-export.
|
||||||
|
assert issubclass(MessageCreate, CoreMessageCreate)
|
||||||
|
assert set(CoreMessageCreate.model_fields) <= set(MessageCreate.model_fields)
|
||||||
|
|
||||||
|
|
||||||
def test_state_hub_reexports_core_doi_schema() -> None:
|
def test_state_hub_reexports_core_doi_schema() -> None:
|
||||||
|
|
|
||||||
392
tests/test_message_receipts.py
Normal file
392
tests/test_message_receipts.py
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
"""STATE-WP-0093: per-recipient broadcast receipts and standing notices."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import inspect, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from api.models.agent_message import AgentMessage, MessageReceipt
|
||||||
|
from api.models.legacy_meter import LegacyInterface
|
||||||
|
from api.models.managed_repo import ManagedRepo
|
||||||
|
from api.models.repository_rename import RepositorySlug
|
||||||
|
from api.routers.messages import UNATTRIBUTED_BROADCAST_READ_KEY
|
||||||
|
from tests.conftest import create_test_domain, create_test_repo
|
||||||
|
|
||||||
|
|
||||||
|
async def _send(client, **payload):
|
||||||
|
body = {"from_agent": "the-custodian", "subject": "s", "body": "b", **payload}
|
||||||
|
body.setdefault("to_agent", "broadcast")
|
||||||
|
resp = await client.post("/messages/", json=body)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _inbox(client, agent, unread_only=True):
|
||||||
|
resp = await client.get(
|
||||||
|
"/messages/", params={"to_agent": agent, "unread_only": str(unread_only).lower()}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(messages):
|
||||||
|
return {m["id"] for m in messages}
|
||||||
|
|
||||||
|
|
||||||
|
async def _receipts(test_engine, message_id):
|
||||||
|
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(MessageReceipt).where(MessageReceipt.message_id == uuid.UUID(message_id))
|
||||||
|
)
|
||||||
|
return {r.agent: r for r in result.scalars().all()}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_defaults_to_news_with_expiry(client):
|
||||||
|
msg = await _send(client)
|
||||||
|
assert msg["kind"] == "news"
|
||||||
|
expires = datetime.fromisoformat(msg["expires_at"])
|
||||||
|
assert timedelta(days=29) < expires - datetime.now(timezone.utc) <= timedelta(days=30)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_message_rejects_broadcast_fields(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/messages/",
|
||||||
|
json={"from_agent": "a", "to_agent": "b", "subject": "s", "body": "b", "kind": "standing"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
direct = await _send(client, to_agent="b")
|
||||||
|
assert direct["kind"] == "message"
|
||||||
|
assert direct["expires_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_read_by_a_stays_unread_for_b(client, test_engine):
|
||||||
|
msg = await _send(client, kind="standing")
|
||||||
|
resp = await client.patch(f"/messages/{msg['id']}/read", params={"reader": "agent-a"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["reader"] == "agent-a"
|
||||||
|
assert body["read_at"] is not None
|
||||||
|
assert msg["id"] in _ids(await _inbox(client, "agent-b"))
|
||||||
|
receipts = await _receipts(test_engine, msg["id"])
|
||||||
|
assert set(receipts) == {"agent-a", "agent-b"} # b: delivery receipt only
|
||||||
|
assert receipts["agent-b"].read_at is None
|
||||||
|
# Global read_at never set for a broadcast.
|
||||||
|
listing = (await client.get("/messages/")).json()
|
||||||
|
assert next(m for m in listing if m["id"] == msg["id"])["read_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_read_reader_in_json_body(client, test_engine):
|
||||||
|
msg = await _send(client, kind="standing")
|
||||||
|
resp = await client.patch(f"/messages/{msg['id']}/read", json={"reader": "agent-a"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert (await _receipts(test_engine, msg["id"]))["agent-a"].read_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unattributed_broadcast_mark_read_is_noop_with_deprecation(client, test_engine):
|
||||||
|
msg = await _send(client, kind="standing")
|
||||||
|
resp = await client.patch(f"/messages/{msg['id']}/read", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["Deprecation"] == "true"
|
||||||
|
assert "reader" in resp.headers["Warning"]
|
||||||
|
assert resp.json()["read_at"] is None
|
||||||
|
assert await _receipts(test_engine, msg["id"]) == {}
|
||||||
|
assert msg["id"] in _ids(await _inbox(client, "agent-a"))
|
||||||
|
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
meter = (
|
||||||
|
await session.execute(
|
||||||
|
select(LegacyInterface).where(
|
||||||
|
LegacyInterface.interface_key == UNATTRIBUTED_BROADCAST_READ_KEY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
assert meter is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_message_read_archive_reply_unchanged(client, test_engine):
|
||||||
|
direct = await _send(client, to_agent="agent-b")
|
||||||
|
assert direct["id"] in _ids(await _inbox(client, "agent-b"))
|
||||||
|
resp = await client.patch(f"/messages/{direct['id']}/read", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Deprecation" not in resp.headers
|
||||||
|
assert resp.json()["read_at"] is not None
|
||||||
|
assert direct["id"] not in _ids(await _inbox(client, "agent-b"))
|
||||||
|
assert await _receipts(test_engine, direct["id"]) == {}
|
||||||
|
|
||||||
|
second = await _send(client, to_agent="agent-b")
|
||||||
|
reply = await client.post(
|
||||||
|
f"/messages/{second['id']}/reply", json={"from_agent": "agent-b", "body": "ok"}
|
||||||
|
)
|
||||||
|
assert reply.status_code == 201
|
||||||
|
assert reply.json()["to_agent"] == "the-custodian"
|
||||||
|
assert second["id"] not in _ids(await _inbox(client, "agent-b"))
|
||||||
|
assert await _receipts(test_engine, second["id"]) == {}
|
||||||
|
|
||||||
|
third = await _send(client, to_agent="agent-b")
|
||||||
|
archived = (await client.patch(f"/messages/{third['id']}/archive")).json()
|
||||||
|
assert archived["archived_at"] is not None
|
||||||
|
assert archived["read_at"] == archived["archived_at"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_to_broadcast_writes_receipt_for_replier_only(client, test_engine):
|
||||||
|
msg = await _send(client, kind="standing")
|
||||||
|
reply = await client.post(
|
||||||
|
f"/messages/{msg['id']}/reply", json={"from_agent": "agent-a", "body": "noted"}
|
||||||
|
)
|
||||||
|
assert reply.status_code == 201
|
||||||
|
receipts = await _receipts(test_engine, msg["id"])
|
||||||
|
assert set(receipts) == {"agent-a"}
|
||||||
|
assert receipts["agent-a"].read_at is not None
|
||||||
|
listing = (await client.get("/messages/")).json()
|
||||||
|
assert next(m for m in listing if m["id"] == msg["id"])["read_at"] is None
|
||||||
|
assert msg["id"] in _ids(await _inbox(client, "agent-b"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_broadcast_does_not_set_read_at(client):
|
||||||
|
msg = await _send(client)
|
||||||
|
archived = (await client.patch(f"/messages/{msg['id']}/archive")).json()
|
||||||
|
assert archived["archived_at"] is not None
|
||||||
|
assert archived["read_at"] is None
|
||||||
|
assert msg["id"] not in _ids(await _inbox(client, "agent-a"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delivery_receipt_only_for_scoped_unread_listing(client, test_engine):
|
||||||
|
msg = await _send(client, kind="standing")
|
||||||
|
await client.get("/messages/")
|
||||||
|
await client.get("/messages/", params={"unread_only": "true"})
|
||||||
|
await _inbox(client, "agent-a", unread_only=False)
|
||||||
|
assert await _receipts(test_engine, msg["id"]) == {}
|
||||||
|
|
||||||
|
first = await _inbox(client, "agent-a")
|
||||||
|
delivered = (await _receipts(test_engine, msg["id"]))["agent-a"].delivered_at
|
||||||
|
assert delivered is not None
|
||||||
|
assert first[0]["delivered_at"] is not None
|
||||||
|
await _inbox(client, "agent-a")
|
||||||
|
receipts = await _receipts(test_engine, msg["id"])
|
||||||
|
assert len(receipts) == 1
|
||||||
|
assert receipts["agent-a"].delivered_at == delivered # first timestamp wins
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_news_disappears_after_delivery_standing_persists_until_ack(client):
|
||||||
|
news = await _send(client, kind="news")
|
||||||
|
standing = await _send(client, kind="standing")
|
||||||
|
first = _ids(await _inbox(client, "agent-a"))
|
||||||
|
assert {news["id"], standing["id"]} <= first
|
||||||
|
second = _ids(await _inbox(client, "agent-a"))
|
||||||
|
assert news["id"] not in second
|
||||||
|
assert standing["id"] in second
|
||||||
|
# Plain read does not clear a standing notice.
|
||||||
|
await client.patch(f"/messages/{standing['id']}/read", params={"reader": "agent-a"})
|
||||||
|
assert standing["id"] in _ids(await _inbox(client, "agent-a"))
|
||||||
|
ack = await client.post(f"/messages/{standing['id']}/ack", json={"agent": "agent-a"})
|
||||||
|
assert ack.status_code == 200
|
||||||
|
assert ack.json()["acknowledged_at"] is not None
|
||||||
|
assert standing["id"] not in _ids(await _inbox(client, "agent-a"))
|
||||||
|
assert standing["id"] in _ids(await _inbox(client, "agent-b"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ack_via_mark_read_flag_and_ack_rejects_direct(client):
|
||||||
|
standing = await _send(client, kind="standing")
|
||||||
|
await client.patch(
|
||||||
|
f"/messages/{standing['id']}/read", params={"reader": "agent-a", "ack": "true"}
|
||||||
|
)
|
||||||
|
assert standing["id"] not in _ids(await _inbox(client, "agent-a"))
|
||||||
|
direct = await _send(client, to_agent="agent-a")
|
||||||
|
resp = await client.post(f"/messages/{direct['id']}/ack", json={"agent": "agent-a"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_expired_broadcast_hidden(client):
|
||||||
|
past = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat()
|
||||||
|
expired = await _send(client, kind="standing", expires_at=past)
|
||||||
|
assert expired["id"] not in _ids(await _inbox(client, "agent-a"))
|
||||||
|
assert expired["id"] not in _ids(await _inbox(client, "agent-a", unread_only=False))
|
||||||
|
assert (await client.get("/messages/notices")).json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_supersede_archives_predecessor(client):
|
||||||
|
old = await _send(client, kind="standing", subject="v1")
|
||||||
|
new = await _send(client, kind="standing", subject="v2", supersedes_id=old["id"])
|
||||||
|
assert new["supersedes_id"] == old["id"]
|
||||||
|
inbox = _ids(await _inbox(client, "agent-a"))
|
||||||
|
assert new["id"] in inbox and old["id"] not in inbox
|
||||||
|
notices = (await client.get("/messages/notices")).json()
|
||||||
|
assert [n["id"] for n in notices] == [new["id"]]
|
||||||
|
missing = await client.post(
|
||||||
|
"/messages/",
|
||||||
|
json={
|
||||||
|
"from_agent": "x", "to_agent": "broadcast", "subject": "s", "body": "b",
|
||||||
|
"kind": "standing", "supersedes_id": str(uuid.uuid4()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert missing.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notices_view_and_summary_counts(client):
|
||||||
|
domain = await create_test_domain(client)
|
||||||
|
for slug in ("repo-a", "repo-b", "repo-c"):
|
||||||
|
await create_test_repo(client, domain_slug=domain["slug"], slug=slug)
|
||||||
|
notice = await _send(client, kind="standing", subject="Read the orientation")
|
||||||
|
await _send(client, kind="news", subject="not a notice")
|
||||||
|
await client.post(f"/messages/{notice['id']}/ack", json={"agent": "repo-a"})
|
||||||
|
await _inbox(client, "repo-b")
|
||||||
|
|
||||||
|
notices = (await client.get("/messages/notices")).json()
|
||||||
|
assert len(notices) == 1
|
||||||
|
status = notices[0]
|
||||||
|
assert status["acknowledged"] == ["repo-a"]
|
||||||
|
assert status["delivered_only"] == ["repo-b"]
|
||||||
|
assert status["unreached"] == ["repo-c"]
|
||||||
|
assert (status["acked_count"], status["delivered_only_count"], status["unreached_count"]) == (1, 1, 1)
|
||||||
|
|
||||||
|
summary = (await client.get("/state/summary")).json()
|
||||||
|
assert summary["standing_notices"] == [
|
||||||
|
{
|
||||||
|
"id": notice["id"],
|
||||||
|
"subject": "Read the orientation",
|
||||||
|
"expires_at": None,
|
||||||
|
"acked": 1,
|
||||||
|
"pending": 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_renamed_repo_reader_resolves_to_canonical_receipt(client, test_engine):
|
||||||
|
domain = await create_test_domain(client)
|
||||||
|
repo = await create_test_repo(client, domain_slug=domain["slug"], slug="flex-auth")
|
||||||
|
notice = await _send(client, kind="standing")
|
||||||
|
await client.post(f"/messages/{notice['id']}/ack", json={"agent": "flex-auth"})
|
||||||
|
|
||||||
|
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
managed = await session.get(ManagedRepo, uuid.UUID(repo["id"]))
|
||||||
|
old = (
|
||||||
|
await session.execute(select(RepositorySlug).where(RepositorySlug.slug == "flex-auth"))
|
||||||
|
).scalar_one()
|
||||||
|
old.kind = "alias"
|
||||||
|
old.protected = True
|
||||||
|
managed.slug = "access-engine"
|
||||||
|
managed.name = "Access Engine"
|
||||||
|
session.add(
|
||||||
|
RepositorySlug(repo_id=managed.id, slug="access-engine", kind="canonical", protected=True)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# The pre-rename ack still counts for the new name, and new writes via the
|
||||||
|
# old name land on the canonical slug.
|
||||||
|
assert notice["id"] not in _ids(await _inbox(client, "access-engine"))
|
||||||
|
second = await _send(client, kind="standing", subject="second")
|
||||||
|
await client.patch(f"/messages/{second['id']}/read", params={"reader": "flex-auth"})
|
||||||
|
assert set(await _receipts(test_engine, second["id"])) == {"access-engine"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_capability_request_broadcast_is_news(client, test_engine):
|
||||||
|
await create_test_domain(client)
|
||||||
|
resp = await client.post(
|
||||||
|
"/capability-requests/",
|
||||||
|
json={
|
||||||
|
"title": "Need a thing",
|
||||||
|
"requesting_agent": "agent-a",
|
||||||
|
"requesting_domain": "infotech",
|
||||||
|
"capability_type": "service",
|
||||||
|
"description": "unmatched",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with factory() as session:
|
||||||
|
msgs = (
|
||||||
|
await session.execute(select(AgentMessage).where(AgentMessage.to_agent == "broadcast"))
|
||||||
|
).scalars().all()
|
||||||
|
assert msgs and all(m.kind == "news" and m.expires_at is not None for m in msgs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_archives_broadcasts_and_is_reversible(test_engine):
|
||||||
|
migration = importlib.import_module(
|
||||||
|
"migrations.versions.d7e8f9a0b1c2_broadcast_receipts_and_notices"
|
||||||
|
)
|
||||||
|
from alembic.migration import MigrationContext
|
||||||
|
from alembic.operations import Operations
|
||||||
|
|
||||||
|
schema = f"receipts_migration_{uuid.uuid4().hex}"
|
||||||
|
direct_id, read_bc, unread_bc, archived_bc = (uuid.uuid4() for _ in range(4))
|
||||||
|
old_archive = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
async with test_engine.connect() as connection:
|
||||||
|
def run(sync_connection):
|
||||||
|
quoted = sync_connection.dialect.identifier_preparer.quote(schema)
|
||||||
|
sync_connection.exec_driver_sql(f"CREATE SCHEMA {quoted}")
|
||||||
|
sync_connection.exec_driver_sql(f"SET LOCAL search_path TO {quoted}")
|
||||||
|
sync_connection.exec_driver_sql(
|
||||||
|
"CREATE TABLE agent_messages ("
|
||||||
|
"id uuid PRIMARY KEY, from_agent varchar(100) NOT NULL, "
|
||||||
|
"to_agent varchar(100) NOT NULL, subject varchar(500) NOT NULL, "
|
||||||
|
"body text NOT NULL, thread_id uuid REFERENCES agent_messages(id), "
|
||||||
|
"read_at timestamptz, archived_at timestamptz, "
|
||||||
|
"created_at timestamptz NOT NULL DEFAULT now())"
|
||||||
|
)
|
||||||
|
sync_connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO agent_messages (id, from_agent, to_agent, subject, body, read_at, archived_at) VALUES "
|
||||||
|
"(:d, 'a', 'b', 's', 'b', NULL, NULL), "
|
||||||
|
"(:r, 'gate-house', 'broadcast', 's', 'b', now(), NULL), "
|
||||||
|
"(:u, 'system', 'broadcast', 's', 'b', NULL, NULL), "
|
||||||
|
"(:x, 'system', 'broadcast', 's', 'b', now(), :old)"
|
||||||
|
),
|
||||||
|
{"d": direct_id, "r": read_bc, "u": unread_bc, "x": archived_bc, "old": old_archive},
|
||||||
|
)
|
||||||
|
original_op = migration.op
|
||||||
|
migration.op = Operations(MigrationContext.configure(sync_connection))
|
||||||
|
try:
|
||||||
|
migration.upgrade()
|
||||||
|
assert "message_receipts" in inspect(sync_connection).get_table_names(schema=schema)
|
||||||
|
rows = {
|
||||||
|
r.id: r
|
||||||
|
for r in sync_connection.execute(
|
||||||
|
text("SELECT id, kind, read_at, archived_at FROM agent_messages")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert rows[direct_id].kind == "message"
|
||||||
|
assert rows[direct_id].archived_at is None
|
||||||
|
assert rows[direct_id].read_at is None
|
||||||
|
for bc in (read_bc, unread_bc, archived_bc):
|
||||||
|
assert rows[bc].kind == "news"
|
||||||
|
assert rows[bc].archived_at is not None
|
||||||
|
assert rows[archived_bc].archived_at == old_archive
|
||||||
|
assert rows[unread_bc].read_at is None # archive does not stamp read_at
|
||||||
|
|
||||||
|
migration.downgrade()
|
||||||
|
columns = {
|
||||||
|
c["name"] for c in inspect(sync_connection).get_columns("agent_messages", schema=schema)
|
||||||
|
}
|
||||||
|
assert not {"kind", "expires_at", "supersedes_id"} & columns
|
||||||
|
assert "message_receipts" not in inspect(sync_connection).get_table_names(schema=schema)
|
||||||
|
count = sync_connection.execute(text("SELECT count(*) FROM agent_messages")).scalar()
|
||||||
|
assert count == 4
|
||||||
|
finally:
|
||||||
|
migration.op = original_op
|
||||||
|
|
||||||
|
await connection.run_sync(run)
|
||||||
|
await connection.rollback()
|
||||||
|
|
@ -4,12 +4,12 @@ type: workplan
|
||||||
title: "Per-recipient broadcast receipts and standing notices"
|
title: "Per-recipient broadcast receipts and standing notices"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: state-hub
|
repo: state-hub
|
||||||
status: proposed
|
status: active
|
||||||
owner: claude
|
owner: claude
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
flavor: implementation
|
flavor: implementation
|
||||||
created: "2026-09-21"
|
created: "2026-09-21"
|
||||||
updated: "2026-09-21"
|
updated: "2026-09-22"
|
||||||
related:
|
related:
|
||||||
- STATE-WP-0091
|
- STATE-WP-0091
|
||||||
origin: founder-direction
|
origin: founder-direction
|
||||||
|
|
@ -150,7 +150,7 @@ acknowledgement needs one instruction line, changed once:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0093-T01
|
id: STATE-WP-0093-T01
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "94eec945-b728-50cb-aefb-60eca5aeccd9"
|
state_hub_task_id: "94eec945-b728-50cb-aefb-60eca5aeccd9"
|
||||||
```
|
```
|
||||||
|
|
@ -162,11 +162,28 @@ broadcasts (D6). Record the choices as decisions in this file.
|
||||||
Done when D2, D3 and D6 each have a recorded founder choice and this
|
Done when D2, D3 and D6 each have a recorded founder choice and this
|
||||||
workplan moves to `ready`.
|
workplan moves to `ready`.
|
||||||
|
|
||||||
|
**Founder decisions (Bernd Worsch, 2026-09-22) — all accepted as recommended:**
|
||||||
|
|
||||||
|
1. **D3 ACCEPTED:** `GET /messages/?to_agent=X&unread_only=true` records an
|
||||||
|
idempotent `delivered_at` receipt for X on each broadcast it returns (a
|
||||||
|
deliberate write inside the GET; no protocol change for delivery tracking).
|
||||||
|
2. **D6 ACCEPTED:** the migration ARCHIVES the 5 existing broadcasts
|
||||||
|
(reversible; nothing re-surfaces). T08 asks gate-house whether to
|
||||||
|
republish its v0.7 "start here" note as a standing notice.
|
||||||
|
3. **D2/D7 ACCEPTED:** unattributed mark-read of a broadcast is a visibility
|
||||||
|
no-op with deprecation headers; one line in
|
||||||
|
`scripts/project_rules/session-protocol.template` and
|
||||||
|
`agents-codex.template` (inbox curl gains `?reader={REPO_SLUG}` plus an
|
||||||
|
ack note), carried ONCE by `update_agent_instruction_files.py`. The fleet
|
||||||
|
propagation run itself is T08, not part of the implementation session.
|
||||||
|
|
||||||
|
T07 (release) still waits on its own founder go-ahead.
|
||||||
|
|
||||||
## Schema and migration
|
## Schema and migration
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0093-T02
|
id: STATE-WP-0093-T02
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6680d408-ebce-5b2c-8a97-84f0bf011754"
|
state_hub_task_id: "6680d408-ebce-5b2c-8a97-84f0bf011754"
|
||||||
```
|
```
|
||||||
|
|
@ -182,11 +199,21 @@ Downgrade drops the table and columns (archive stamps stay).
|
||||||
Done when `alembic upgrade head` and `downgrade -1` both run clean on a
|
Done when `alembic upgrade head` and `downgrade -1` both run clean on a
|
||||||
copy of a production dump, and the 5 broadcasts end in the D6 state.
|
copy of a production dump, and the 5 broadcasts end in the D6 state.
|
||||||
|
|
||||||
|
**2026-09-22 (progress):** `MessageReceipt` model and Alembic revision
|
||||||
|
`d7e8f9a0b1c2` (on `c6f7a8b9d0e1`) implemented: `message_receipts`,
|
||||||
|
`kind`/`expires_at`/`supersedes_id`, both indexes, broadcasts backfilled to
|
||||||
|
`news` and archived (D6); direct messages untouched. Verified on a scratch
|
||||||
|
local database built from the full migration chain (upgrade head, downgrade
|
||||||
|
-1, upgrade head all clean; a read broadcast ended `news` + archived, a direct
|
||||||
|
message unchanged) and by an isolated-schema up/down test. **Remaining:** the
|
||||||
|
rehearsal on a copy of a production dump — not run from the implementation
|
||||||
|
session (no production access); do it in the T07 preflight.
|
||||||
|
|
||||||
## API: receipts, notices, ack
|
## API: receipts, notices, ack
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0093-T03
|
id: STATE-WP-0093-T03
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "33010aa6-326d-5f52-9ff3-32b09c9e88c4"
|
state_hub_task_id: "33010aa6-326d-5f52-9ff3-32b09c9e88c4"
|
||||||
```
|
```
|
||||||
|
|
@ -201,11 +228,21 @@ Capability-request broadcasts send `kind='news'`.
|
||||||
Done when direct-message behavior is byte-for-byte unchanged in the
|
Done when direct-message behavior is byte-for-byte unchanged in the
|
||||||
existing test suite and T06's new tests pass.
|
existing test suite and T06's new tests pass.
|
||||||
|
|
||||||
|
**2026-09-22 (done):** D2-D4 in `api/routers/messages.py`,
|
||||||
|
`api/services/message_receipts.py`, `api/schemas/agent_message.py` (local
|
||||||
|
subclasses of the hub-core schemas). `PATCH /read?reader=&ack=` (also JSON
|
||||||
|
body), unattributed broadcast mark-read = no-op + `Deprecation`/`Warning`
|
||||||
|
headers + legacy-meter key `rest_api:PATCH /messages/{id}/read
|
||||||
|
broadcast-without-reader`, broadcast reply writes a receipt for the replier,
|
||||||
|
`POST /messages/{id}/ack`, news default expiry 30d, supersede archives the
|
||||||
|
predecessor, expiry filtered at query time, capability-request broadcasts are
|
||||||
|
`news`. Direct-message paths unchanged; existing suite green.
|
||||||
|
|
||||||
## MCP parity (state-hub and hub-core)
|
## MCP parity (state-hub and hub-core)
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0093-T04
|
id: STATE-WP-0093-T04
|
||||||
status: wait
|
status: progress
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "43529a93-6e49-51c8-a358-726f7e737fab"
|
state_hub_task_id: "43529a93-6e49-51c8-a358-726f7e737fab"
|
||||||
```
|
```
|
||||||
|
|
@ -222,11 +259,21 @@ Done when both MCP servers can mark a broadcast read per reader and
|
||||||
acknowledge a standing notice, with tests in
|
acknowledge a standing notice, with tests in
|
||||||
`tests/test_codex_mcp_server.py` and hub-core's suite.
|
`tests/test_codex_mcp_server.py` and hub-core's suite.
|
||||||
|
|
||||||
|
**2026-09-22 (progress — split):** state-hub part done:
|
||||||
|
`mcp_server/codex_server.py` `mark_message_read(message_id, reader=None)` and
|
||||||
|
new `acknowledge_notice(message_id, agent)`, tests in
|
||||||
|
`tests/test_codex_mcp_server.py`, `mcp_server/TOOLS.md` updated (the Codex
|
||||||
|
server has no `send_message`). hub-core part handed off via State Hub message
|
||||||
|
`69fc387c-5bd3-47bc-9a3f-d3d83b6dc213` (addendum
|
||||||
|
`f8eecebf-cf76-44f9-832e-20401b1b37ec`: absorb the schema fields so state-hub
|
||||||
|
can return to a pure re-export). **Remaining:** hub-core change + release,
|
||||||
|
then bump the pinned hub-core here.
|
||||||
|
|
||||||
## Visibility: notices view, summary, dashboard
|
## Visibility: notices view, summary, dashboard
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0093-T05
|
id: STATE-WP-0093-T05
|
||||||
status: wait
|
status: progress
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "fdb44fd9-ab44-5e26-86b3-a4f0f8a4e701"
|
state_hub_task_id: "fdb44fd9-ab44-5e26-86b3-a4f0f8a4e701"
|
||||||
```
|
```
|
||||||
|
|
@ -237,11 +284,20 @@ Implement D5: `GET /messages/notices`, `standing_notices` in
|
||||||
Done when a live standing notice shows acked / delivered-only /
|
Done when a live standing notice shows acked / delivered-only /
|
||||||
unreached repo lists on the endpoint and the dashboard.
|
unreached repo lists on the endpoint and the dashboard.
|
||||||
|
|
||||||
|
**2026-09-22 (progress):** `GET /messages/notices` (acknowledged /
|
||||||
|
delivered_only / unreached against active `managed_repos`),
|
||||||
|
`standing_notices` in `/state/summary` (computed per request, outside the
|
||||||
|
revision cache), dashboard inbox "Standing notices" panel + separate
|
||||||
|
"Broadcasts" section (no global mark-read for broadcasts), loader
|
||||||
|
`dashboard/src/data/notices.json.py`. Endpoint and summary covered by tests;
|
||||||
|
dashboard build passes. **Remaining:** check the panel against a live notice
|
||||||
|
after T07.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0093-T06
|
id: STATE-WP-0093-T06
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d68aa1fa-0f09-5d64-a4fc-cbc24180f2c3"
|
state_hub_task_id: "d68aa1fa-0f09-5d64-a4fc-cbc24180f2c3"
|
||||||
```
|
```
|
||||||
|
|
@ -257,6 +313,12 @@ migration state of pre-existing broadcasts.
|
||||||
|
|
||||||
Done when the new tests pass and the full suite is green.
|
Done when the new tests pass and the full suite is green.
|
||||||
|
|
||||||
|
**2026-09-22 (done):** `tests/test_message_receipts.py` (17 tests: all
|
||||||
|
listed cases, incl. reader in JSON body, ack via mark-read flag, capability
|
||||||
|
broadcast as news, isolated-schema migration up/down). Full suite: 894 passed
|
||||||
|
(`tests/test_hub_core_imports.py` message-schema check relaxed from identity
|
||||||
|
to subclass, see T04).
|
||||||
|
|
||||||
## Release through the normal promotion path
|
## Release through the normal promotion path
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue